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, or 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 cannot — 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 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 response — and 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 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 // 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 cover the null fields
17// on the store side instead of the endpoint side. Only reasonable
18// if your registered stores tolerate a missing key in the payload
19// gracefully (dispatchToStore skips undefined keys, it does not
20// write nulls over existing cache) — verify this per store before
21// 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 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, not a GraphQL
2// concept — omit it or return it empty regardless of which GraphQL
3// 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, which feeds handshake conflict detection — not cosmetic.
3//
4// This is the one place GraphQL setups most commonly break
5// something REST setups don't: your reshaping route (Next.js Route
6// Handler, Express, etc.) sends its OWN Date header automatically,
7// same as any HTTP response — that part is fine. What breaks it is
8// a CDN or API gateway in front of a managed GraphQL provider
9// (Hasura Cloud, Apollo GraphOS, a load balancer) that's configured
10// to cache or strip response headers more aggressively than a
11// typical REST deployment, since GraphQL responses are POST-based
12// and less commonly cached at the edge by default tooling.
13//
14// Since your Route Handler is the thing actually responding to
15// Flux (never the raw GraphQL endpoint directly — see the
16// contract-mismatch note in Step 1), the Date header comes from
17// YOUR server's response, not the upstream GraphQL server's. This
18// is usually correct with zero extra work. Only check this if
19// 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 — identical regardless of
2// what's behind your reshaping route. The GraphQL-specific nuance
3// is entirely upstream of this table: it's YOUR route handler's job
4// to turn "200 + errors array" into a real 5xx before Flux ever sees it.
5
6// 2xx + { ok: true, data, assets } -> dispatched normally
7// 5xx -> cooldown reset to 0, next
8// online event retries
9// immediately; existing IDB
10// data keeps serving meanwhile
11// any other failure (network error,
12// 4xx, malformed JSON, CORS block) -> existing IDB data keeps
13// serving, NEVER rolled back
14
15// A GraphQL "errors" array that never gets translated into an HTTP
16// 5xx by your route handler will fall into the FIRST row of this
17// table — "dispatched normally" — with null/undefined fields inside
18// "data". This is the single most important thing this page exists
19// to prevent. If you take one thing from this page: never forward a
20// raw GraphQL response body straight through to Flux.
Fail-Closed Architecture

When using GraphQL with Flux, always enforce fail-closed status checking. Since GraphQL naturally returns 200 OK for partial errors, transforming any payload containing an errors array into an HTTP 500 error on the proxy route guarantees that client-side IDB cache fallback and retry semantics function as expected.

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.

Powered by Flux