GitHub

Server Data

The server field fetches data before the page renders. It runs exclusively on the server — credentials, database access, and API secrets stay there. The browser never receives the fetcher code, only its serialised output.

Basic usage

Declare a data async function inside the server object. It receives a ctx object with request context and returns a plain object:

export default {
  route: '/products/:id',
  state: { quantity: 1 },
  server: {
    data: async (ctx) => {
      const product = await db.products.findById(ctx.params.id)
      const related = await db.products.findRelated(product.category)
      return { product, related }
    },
  },
  view: (state, server) => `
    <main>
      <h1>${server.product.name}</h1>
      <p>${server.product.description}</p>
      <p>Price: £${server.product.price}</p>
      <div class="quantity">
        <button data-event="decrement">-</button>
        <span>${state.quantity}</span>
        <button data-event="increment">+</button>
      </div>
    </main>
  `,
}

The ctx object

The ctx argument passed to server.data() contains the full request context:

PropertyTypeDescription
ctx.paramsobjectURL path parameters from dynamic route segments (e.g. :id).
ctx.queryobjectParsed query string parameters (e.g. ?page=2&sort=asc).
ctx.headersobjectIncoming request headers (lowercase keys).
ctx.cookiesobjectParsed cookies from the Cookie header.
server: {
  data: async (ctx) => {
    // Dynamic route: /blog/:year/:slug
    const { year, slug } = ctx.params

    // Query string: ?page=2
    const page = parseInt(ctx.query.page ?? '1', 10)

    // Authentication via cookie
    const session = ctx.cookies.sessionId
      ? await sessions.find(ctx.cookies.sessionId)
      : null

    const post = await db.posts.findBySlug(year, slug)
    return { post, page, session }
  },
}

Server state in the view

The resolved values from all server fetchers are merged into a single object and passed to the view function as its second argument, conventionally named server. Each fetcher key becomes a property:

// server: { post: async (ctx) => ... }

view: (state, server) => `
  <article>
    <h1>${server.post.title}</h1>
    <time>${server.post.date}</time>
    ${server.post.body}
  </article>
`
If no server fetchers are declared, the second argument to view is an empty object {}.

SSR only — not available on the client

Server data is resolved before the HTML is generated and is never re-fetched in the browser. After hydration, the serialised output is available to the view as window.__PULSE_SERVER__ for client-side re-renders — it is the same value the server computed, not a new request.

Server state is serialised into the page HTML as window.__PULSE_SERVER__ and is visible to anyone who views source. Filter fetcher output to only what the view needs — never include credentials, internal IDs, or user data beyond what must be rendered.

Error handling

If server.data() throws, the server returns a 500 error response. Handle errors gracefully by catching inside the function and returning a safe fallback:

server: {
  data: async (ctx) => {
    try {
      const product = await db.products.findById(ctx.params.id)
      if (!product) return { product: null, notFound: true }
      return { product, notFound: false }
    } catch (err) {
      console.error('Failed to load product', err)
      return { product: null, notFound: true }
    }
  },
},
view: (state, server) => server.notFound
  ? `<p>Product not found.</p>`
  : `<h1>${server.product.name}</h1>`

Multiple named fetchers

The server object supports any number of named async functions. Each one receives ctx and its return value is available on server in the view under the same key. Fetchers run in parallel — the page renders once all have resolved:

export default {
  route: '/products/:id',
  state: { quantity: 1 },
  server: {
    product: async (ctx) => db.products.findById(ctx.params.id),
    reviews: async (ctx) => db.reviews.forProduct(ctx.params.id),
    related: async (ctx) => db.products.related(ctx.params.id),
  },
  view: (state, server) => `
    <h1>${server.product.name}</h1>
    <p>${server.reviews.length} reviews</p>
    ${server.related.map(p => `<a href="/products/${p.id}">${p.name}</a>`).join('')}
  `,
}

External API fetching

Server fetchers run in Node.js. API keys and credentials are read from environment variables and never leave the server — only the fetcher's return value is serialised into the page:

server: {
  weather: async (ctx) => {
    const res = await fetch(
      `https://api.weather.example.com/current?city=${ctx.query.city}`,
      { headers: { Authorization: `Bearer ${process.env.WEATHER_API_KEY}` } }
    )
    if (!res.ok) return null
    return res.json()
  },
}

Transforming API responses

Fetchers are the right place to reshape external responses before they reach the view. Filter to only what the view needs — this reduces payload size and prevents internal fields from being serialised into the page HTML:

server: {
  article: async (ctx) => {
    const res  = await fetch(`https://cms.example.com/articles/${ctx.params.slug}`)
    const data = await res.json()

    // Shape and filter before serialisation
    return {
      title:       data.fields.title,
      body:        data.fields.bodyHtml,
      publishedAt: new Date(data.sys.createdAt).toLocaleDateString('en-GB'),
      author:      data.fields.author.name,
      // data.sys.revision, internal IDs etc. are dropped here
    }
  },
}

Parallel fetches within a single fetcher

When multiple independent requests are needed inside a single fetcher, Promise.all runs them concurrently so the total wait time is the slowest request, not the sum:

server: {
  page: async (ctx) => {
    const [hero, featured, nav] = await Promise.all([
      fetch('https://cms.example.com/hero').then(r => r.json()),
      fetch('https://cms.example.com/featured').then(r => r.json()),
      fetch('https://cms.example.com/nav').then(r => r.json()),
    ])
    return { hero, featured, nav }
  },
}

Use serverTtl to cache fetcher results in-process for a number of seconds. This avoids hitting external APIs or a database on every request for data that changes infrequently.

export default {
  route: '/homepage',
  serverTtl: 60,  // cache all server fetchers for 60 seconds
  server: {
    featured: async () => fetch('https://api.example.com/featured').then(r => r.json()),
  },
}