Bootstrap Endpoints

GraphQL (any backend)

GraphQL's response shape doesn't match what flux.bootstrap() expects out of the box — a query returns { data, errors } over a single POST endpoint, not { ok, data, assets }. This page covers the thin reshaping layer every GraphQL setup needs, and the one gotcha (errors-under-200) that breaks bootstrap silently if you skip it.

Prerequisites
  • Any GraphQL server — Apollo Server, Hasura, GraphQL Yoga, a managed provider
  • A thin server-side route (Next.js Route Handler, Express, etc.) to reshape the response — see Step 1
  • For plain-REST backends with no GraphQL layer, see bootstrap/rest instead
  • Familiarity with api/bootstrap on the client side
1

Why GraphQL needs its own page

A REST backend in any language can usually be shaped to return exactly Flux's { ok, data, assets } contract directly. GraphQL can't — the protocol's own response shape is fixed, and it reports failure differently than Flux expects.
json
1// A raw GraphQL response looks like this:
2
3{
4 "data": {
5 "services": [ /* ... */ ],
6 "team": [ /* ... */ ]
7 },
8 "errors": [
9 { "message": "...", "path": ["team"] }
10 ]
11}
12
13// Flux's bootstrap module (Section 4.13) expects this:
14
15{
16 "ok": true,
17 "data": { "services": [], "team": [] },
18 "assets": []
19}
20
21// These are NOT interchangeable. GraphQL servers return HTTP 200
22// even on partial or total failure — the only signal is the
23// "errors" array. flux.bootstrap() has no idea what "errors" means
24// and will happily dispatch a partially-null payload as if it
25// succeeded. Every pattern on this page exists to close that gap
26// with a thin reshaping layer between GraphQL and Flux.
Never forward a raw GraphQL response to Flux

Every pattern below is a small server-side route that sits between your GraphQL endpoint and Flux, executing the query and reshaping the result. Flux's client never talks to your GraphQL endpoint directly.

2

Write one query, aliased to your map keys

Alias each top-level selection to match the exact key your client-side map object expects. A single GraphQL query naturally covers what REST needs several endpoints for — a good fit for Flux's one-call bootstrap model.
graphql
1# The query itself does most of the work for you. Alias each field
2# to match the exact key your client-side map object expects
3# this is what makes a single GraphQL query a natural fit for
4# Flux's one-network-call bootstrap philosophy: you're not stitching
5# together N REST calls server-side, you're writing N field
6# selections in one request that was always going to be one request.
7
8query FluxBootstrapPublic {
9 services: getServices {
10 id
11 name
12 description
13 }
14 team: getTeamMembers(orderBy: NAME_ASC) {
15 id
16 name
17 role
18 photoUrl
19 }
20}
21
22# The alias on the left ("services:", "team:") is what becomes the
23# top-level key under "data" in the GraphQL responseand therefore
24# the key Flux's dispatcher matches against your registered map.
25# Get the alias wrong and dispatchToStore's "COLLECTION_ALL requires
26# array" warning (Section 4.11, step 3) is usually the first symptom.
3

Node — reshaping via graphql-request

The route handler executes the query server-side and reshapes the result. Letting the client throw on a non-empty errors array is what turns a partial GraphQL failure into a real 5xx.
typescript
1// app/api/bootstrap/public/route.ts — Next.js Route Handler,
2// executing against Apollo Server / any GraphQL endpoint server-side
3// via graphql-request, then reshaping to the Flux contract.
4
5import { GraphQLClient, gql } from 'graphql-request'
6
7const client = new GraphQLClient(process.env.GRAPHQL_ENDPOINT!, {
8 headers: { authorization: `Bearer ${process.env.GRAPHQL_SERVICE_TOKEN}` },
9})
10
11const BOOTSTRAP_QUERY = gql`
12 query FluxBootstrapPublic {
13 services: getServices { id name description }
14 team: getTeamMembers(orderBy: NAME_ASC) { id name role photoUrl }
15 }
16`
17
18export async function GET() {
19 try {
20 // graphql-request throws on a non-empty "errors" array by
21 // default — this is exactly the behavior you want here. A
22 // partial GraphQL failure becomes a real thrown error, which
23 // the catch block below turns into a genuine 5xx, which is what
24 // triggers Flux's "reset cooldown, retry immediately" path
25 // (Section 4.13) instead of silently dispatching null fields.
26 const data = await client.request(BOOTSTRAP_QUERY)
27
28 return Response.json({ ok: true, data, assets: [] })
29 } catch (err) {
30 console.error('Flux bootstrap GraphQL error:', err)
31 return Response.json({ ok: false, error: 'bootstrap_failed' }, { status: 500 })
32 }
33}
4

Managed GraphQL (Hasura, etc.) via plain fetch

No client library needed for a single server-to-server call — just check errors explicitly, since res.ok being true tells you nothing about whether the query actually succeeded.
typescript
1// Managed GraphQL (Hasura, PostgREST-over-GraphQL, etc.) — same
2// pattern with a plain fetch() instead of a client library, useful
3// when you don't want a GraphQL client dependency just for one
4// server-to-server call.
5
6export async function GET() {
7 const res = await fetch(process.env.HASURA_GRAPHQL_ENDPOINT!, {
8 method: 'POST',
9 headers: {
10 'Content-Type': 'application/json',
11 'x-hasura-admin-secret': process.env.HASURA_ADMIN_SECRET!,
12 },
13 body: JSON.stringify({
14 query: `
15 query FluxBootstrapPublic {
16 services: services { id name description }
17 team: team_members(order_by: { name: asc }) { id name role photo_url }
18 }
19 `,
20 }),
21 })
22
23 const json = await res.json()
24
25 // THE step that's easy to skip: check "errors" explicitly, even
26 // though res.ok is true. Hasura (like every GraphQL server) sends
27 // HTTP 200 for a query with partial errors.
28 if (json.errors?.length) {
29 console.error('Flux bootstrap GraphQL errors:', json.errors)
30 return Response.json({ ok: false, error: 'bootstrap_failed' }, { status: 500 })
31 }
32
33 return Response.json({ ok: true, data: json.data, assets: [] })
34}
5

Partial errors — pick fail-closed or fail-open, deliberately

GraphQL can return some fields successfully and null out others with an entry in errors. Flux has no concept of a partial bootstrap — decide which behavior you want rather than letting it happen by accident.
typescript
1// GraphQL supports PARTIAL success: some top-level fields resolve,
2// others null out with an entry in "errors". Decide deliberately
3// which behavior you want — Flux itself has no concept of "partial"
4// bootstrap, a dispatch either happens for a channel or it doesn't.
5
6// Option A — fail closed (recommended default): any entry in
7// "errors" fails the whole bootstrap call, even if some fields
8// resolved. Simpler mental model, matches the "5xx = retry the
9// whole thing" path exactly. This is what both snippets above do.
10
11if (json.errors?.length) {
12 return Response.json({ ok: false, error: 'bootstrap_failed' }, { status: 500 })
13}
14
15// Option B — fail open per field: dispatch whatever resolved,
16// let Flux's existing-IDB-data fallback (Section 4.13) cover the
17// null fields on the store side instead of the endpoint side. Only
18// reasonable if your registered stores tolerate a missing key in
19// the payload gracefully (dispatchToStore skips undefined keys, it
20// does not write nulls over existing cache) — verify this per store
21// before relying on it.
22
23const cleaned = Object.fromEntries(
24 Object.entries(json.data).filter(([, v]) => v !== null)
25)
26return Response.json({ ok: true, data: cleaned, assets: [] })
27
28// If you're not sure which to pick: pick A. A partially-populated
29// bootstrap that LOOKS successful is a much harder bug to find
30// later than a bootstrap that visibly retries.
6

assets[] stays empty — orthogonal to GraphQL

Dynamic route chunk warming (Section 4.13) is a Next.js build-manifest mechanism, unrelated to whether your data layer is GraphQL or REST.
json
1// assets[] is Next.js build-manifest-specific (Section 4.13), not a
2// GraphQL concept — omit it or return it empty regardless of which
3// GraphQL server or client library sits behind your route handler:
4
5{
6 "ok": true,
7 "data": { "services": [], "team": [] },
8 "assets": []
9}
10
11// If your reshaping route DOES happen to run inside a Next.js app
12// (the common case — see bootstrap/node), you can still populate
13// assets[] from the build manifest exactly as described there. The
14// two concerns are orthogonal: one is "did GraphQL resolve cleanly,"
15// the other is "does this response also warm dynamic route chunks."
7

The Date header — usually fine, one thing to check

clockSkewMs correction depends on this header, and it comes from your reshaping route's response, not the upstream GraphQL server — this is normally correct by default.
typescript
1// flux.bootstrap() reads the Date response header to compute
2// clockSkewMs (Section 4.13), which feeds handshake conflict
3// detection — not cosmetic.
4//
5// This is the one place GraphQL setups most commonly break
6// something REST setups don't: your reshaping route (Next.js Route
7// Handler, Express, etc.) sends its OWN Date header automatically,
8// same as any HTTP response — that part is fine. What breaks it is
9// a CDN or API gateway in front of a managed GraphQL provider
10// (Hasura Cloud, Apollo GraphOS, a load balancer) that's configured
11// to cache or strip response headers more aggressively than a
12// typical REST deployment, since GraphQL responses are POST-based
13// and less commonly cached at the edge by default tooling.
14//
15// Since your Route Handler is the thing actually responding to
16// Flux (never the raw GraphQL endpoint directly — see the
17// contract-mismatch note in Step 1), the Date header comes from
18// YOUR server's response, not the upstream GraphQL server's. This
19// is usually correct with zero extra work. Only check this if
20// you've put your own reshaping route behind a CDN too.
8

Extending to a 'user'-scope endpoint

Same reshaping pattern, with identity resolved from your own auth and passed as a query variable — never trust a client-supplied userId.
typescript
1// A 'user'-scope endpoint follows the exact same reshaping pattern
2// — resolve identity server-side from your OWN auth (session cookie,
3// verified JWT), pass it into the GraphQL query as a variable, never
4// trust a client-supplied userId in the query variables themselves.
5
6const BOOTSTRAP_USER_QUERY = gql`
7 query FluxBootstrapUser($userId: ID!) {
8 dashboard: getDashboard(userId: $userId) { widgets { id type data } }
9 billing: getBilling(userId: $userId) { plan status nextInvoiceAt }
10 }
11`
12
13export async function GET(req: Request) {
14 const userId = await resolveUserFromSession(req) // your own auth, never the client's
15 if (!userId) {
16 return Response.json({ ok: false, error: 'unauthenticated' }, { status: 401 })
17 }
18
19 try {
20 const data = await client.request(BOOTSTRAP_USER_QUERY, { userId })
21 return Response.json({ ok: true, data, assets: [] })
22 } catch (err) {
23 console.error('Flux bootstrap GraphQL error (user scope):', err)
24 return Response.json({ ok: false, error: 'bootstrap_failed' }, { status: 500 })
25 }
26}
27
28// The client-side call this endpoint answers:
29//
30// flux.bootstrap({
31// endpoint: 'https://yourapp.com/api/bootstrap/user',
32// scope: 'user',
33// userId: currentUser.id,
34// map: { dashboard: dashboardAdapter, billing: billingAdapter },
35// })
9

Status codes and how the client reacts

Identical to every other backend once your route has done its job — the GraphQL-specific work all happens upstream of this table, inside your reshaping route.
typescript
1// How Flux's client treats each response, from Section 4.13 —
2// identical regardless of what's behind your reshaping route. The
3// GraphQL-specific nuance is entirely upstream of this table: it's
4// YOUR route handler's job to turn "200 + errors array" into a real
5// 5xx before Flux ever sees it.
6
7// 2xx + { ok: true, data, assets } -> dispatched normally
8// 5xx -> cooldown reset to 0, next
9// online event retries
10// immediately; existing IDB
11// data keeps serving meanwhile
12// any other failure (network error,
13// 4xx, malformed JSON, CORS block) -> existing IDB data keeps
14// serving, NEVER rolled back
15
16// A GraphQL "errors" array that never gets translated into an HTTP
17// 5xx by your route handler will fall into the FIRST row of this
18// table — "dispatched normally" — with null/undefined fields inside
19// "data". This is the single most important thing this page exists
20// to prevent. If you take one thing from this page: never forward a
21// raw GraphQL response body straight through to Flux.

For plain-REST backends with no GraphQL layer, see bootstrap/rest. For Node-specific patterns including assets[] chunk warming, see bootstrap/node. For the client-side call this endpoint answers, see api/bootstrap.