Next.js
This guide takes a fresh Next.js App Router project to a fully offline resilient, single bootstrap, realtime synced app.
- An existing Next.js 13+ App Router project.
- (Optional)A backend you can subscribe to — Supabase, or any server that can speak Socket.io, SSE, or raw WebSocket.
- Node 18+.
Install the core package
Install the Next.js integration
withFlux() (the next.config wrapper that generates and injects your service worker), FluxProvider, HydrationGate, ServiceWorkerRegistrar, and the useFlux() / useFluxTime() hooks.Choose your realtime adapter
createPollingAdapter() still gets you full IDB caching, the offline queue, and the SW layer — you're only trading live push updates for a polling interval. Swap in a real adapter later with no changes to your register() calls. If you have no backend at all — static marketing pages or fully hardcoded data — choose the **No realtime backend** option above and use createNoopAdapter() instead. It disables the realtime layer entirely while keeping the rest of the stack.
Define your precache route manifest
Configure withFlux() in next.config
dynamicRoutePrefixes tells the service worker which URL prefixes are dynamic detail routes at all. dynamicRouteShapes tells it exactly which subpaths under each of those prefixes need their own offline navigable shell document. dynamicRouteIdbKeys tells it where to look to decide whether it actually has data for a given route. A single page dynamic route only needs a prefix with one empty string subpath; a dynamic route with tabs (settings, activity, etc.) needs one subpath entry per tab.When a dynamic route can't be reached, the service worker only serves its cached shell if it also finds data for that slug — otherwise the user would land on an empty page. With dynamicRouteIdbKeys set, the check is two cheap steps: an exact key lookup on the detail channel ({scope}:{idbKey}:{slug}), then — only if that misses — a single read of the list channel, matching the URL slug against each field in listFields. The list fallback is what makes public slugs work when the detail entry is keyed by a different id. Without an entry, every offline navigation to that prefix falls back to a full scan of IndexedDB.
idbKey and listKey must be exactly the idbKey values you pass to flux.register() for the detail and list channels, and the keys of dynamicRouteIdbKeys must match dynamicRoutePrefixes exactly, trailing slash included. If they drift apart the service worker looks in the wrong place and refuses to open a route that has perfectly good cached data — with no error.
Add your manifest.json
public/manifest.json. Flux's service worker reads it during installation to configure the PWA shell. This step is required for every app — static sites included — because the SW uses the manifest icons and theme colors for the offline splash.Define your types (TypeScript only)
Create your state store
store on register() needs a setState(data) method with this exact signature, because dispatchToStore calls it directly on every IDB cold boot read, every bootstrap dispatch, every realtime event (including arrays, delta objects, sparse updates, and deletions) — and, as of Step 16, every confirmed local write too.Zustand — native, no adapter needed
Redux Toolkit
Jotai
createStore() since Flux dispatches outside of the React render tree:Stunk
Create the Flux engine singleton
window so hot reloads and duplicate imports never create a second one. This file also exports ensureClusterBootOnce(), which starts the realtime layer — Step 11 shows where to call it.Wrap your app
The hydrate → bootstrap sequence
ensureClusterBootOnce().flux.bootstrap() only hydrates IndexedDB over a plain HTTP call — it never touches the realtime connection. Until ensureClusterBootOnce() runs, the realtime manager sits inert: channels are registered and the cache is full, but no leader tab is elected and no realtime channel is opened. Live push never fires, and neither does the catch-up sync after a reconnect — with no error. With several tabs open, Flux elects one leader to hold the connection and fans updates out to the rest, so booting the cluster is also what avoids one connection per tab. This applies to every adapter — Supabase, Socket.io, SSE and WebSocket alike. Call it after hydrate() for global data, or after a confirmed successful bootstrap for user scoped data, as shown below.
Your bootstrap API route
null, {} or [] when there is nothing yet — so "no data yet" is cached as a correct offline state instead of a permanently missing partition.Dynamic routes that work offline
ingestionType: 'LRU' so unvisited detail pages can still hydrate from IDB, plus fluxDynamicRoute handling the build time static params:idbGet() on mount — see core_concepts/idb_ttl and ingestion/lru for the full pattern. The idbKey of that LRU channel (and of the list channel, if you have one) is what you give dynamicRouteIdbKeys in Step 5, so the service worker can tell whether it has data for the route while offline.dynamicRouteShapes in Step 5 was configuring.A detail route could have any number of real slugs — precaching every one at build time isn't possible for user generated content, and precaching none of them means an item the user never visited online can't render offline at all. The sentinel slug (__flux_shell__) is a real, static page Next.js CAN build and the SW CAN precache. When a real slug 404s from both the network and the page cache while offline, the SW serves the sentinel's cached HTML back with the real slug rewritten in, and the client view then hydrates the actual data for that slug from IDB, the same way any LRU detail page does.
fluxDynamicRoute export from Step 13's blog example goes on every page in the route, including every subpath:dataReady flag your store initializer sets once bootstrap or IDB hydration actually completes — this is what stops a hard refresh from briefly rendering a 404 for an item that genuinely exists but just hasn't hydrated into the store yet:config/precache-routes.ts from Step 4 never needs to list them:Your offline fallback page
precacheRoutes and offlineShell in your withFlux() config (Step 5) — otherwise the service worker has nothing to fall back to when a page is neither cached nor reachable.Offline ready static images (optional)
flux.register(), use CachedImg to store them in Flux's image IDB cache. First render fetches normally and caches in the background; every render after that reads straight from IDB via a blob URL, completely independent of whether they were ever part of a register() payload.Sync your own writes instantly (dispatchLocalUpdate)
flux.dispatchLocalUpdate(channel, data, options?) closes that gap: once your own mutation is confirmed, hand the result straight to Flux and it goes through the exact same intake gate as bootstrap and realtime data — the store's setState(), the scope aware IDB write, the generation barrier, and the cross tab mirror all apply exactly as they do for server originated data.dispatchLocalUpdate(channel: string, data: any, options?: { persist?: boolean; broadcast?: boolean }) — persist (default true) controls whether the write lands in IDB, and broadcast (default true) controls whether it's mirrored to other open tabs. Only call this after your write is confirmed by the server — it's a confirmed write dispatch, not an optimistic one, so it should never carry unsaved local state.
The store — same setState() contract as any other channel
Register the channel, like any other
Wrap the save: write, confirm, then dispatch
lib/flux.ts next to your other registrations — it's the one place that legitimately knows about both the engine and the store, so the store itself stays a plain store like every other example on this page:Call it from your form
preferencesAdapter for any registered adapter and the save action for whatever your own mutation is. If the write fails, skip the dispatch entirely and let your existing error handling surface it; there is nothing confirmed yet to push into Flux.Deleting: dispatchLocalDelete for LRU detail entries
flux.dispatchLocalDelete(channel, id) removes it from IDB and from this tab, and signals every other open tab to drop it too. Skip it and a deleted item's detail page keeps rendering from cache.If you have no data to sync — a marketing page, a static docs page, anything with no register() calls — you don't need Steps 8 through 16 at all. Skip the engine, the adapter, the realtime cluster, and the store entirely. You still need Steps 1 to 6 (install, precache routes, config, manifest). If the page has static images, add Step 15 too. All you need is:
Add every route you want available offline to precacheRoutes, plus your offline shell page from Step 14. The service worker generated by withFlux() precaches these at build time regardless of whether createFlux() is ever called — the SW level page caching and the data sync engine are independent systems.