Getting Started

Next.js

This guide takes a fresh Next.js App Router project to a fully offline resilient, single bootstrap, realtime synced app.

Package manager
Language
Prerequisites
  • 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+.
1

Install the core package

bash
npm install @tsworldtech/flux
This is the framework agnostic engine — IDB caching, the offline queue, the mutation pipeline, the license system, job tracking. Every other package below is a thin adapter on top of this one.
2

Install the Next.js integration

bash
npm install @tsworldtech/flux-next
This gives you withFlux() (the next.config wrapper that generates and injects your service worker), FluxProvider, HydrationGate, ServiceWorkerRegistrar, and the useFlux() / useFluxTime() hooks.
3

Choose your realtime adapter

bash
npm install @tsworldtech/flux-supabase

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.

4

Define your precache route manifest

Centralize your offline application shell routes in a dedicated config file. Flux uses this list to warm the service worker cache on first load and guarantee instant offline navigation across your primary UI sections. Keep this file to static, hand picked routes only — dynamic route shell documents are generated separately, see Step 5 and Step 13.
config/precache-routes.ts
1// config/precache-routes.ts
2//
3// Static, hand picked routes only. Dynamic route shell documents (the
4// sentinel pages used for offline coverage of routes like
5// /items/[slug]) are NOT listed here — they're derived automatically
6// from dynamicRouteShapes in next.config (see Step 5 and Step 13), so
7// don't add __flux_shell__ paths to this file by hand.
8export const PRECACHE_ROUTES = [
9 '/',
10 '/docs',
11 '/docs/getting-started',
12 '/dashboard',
13 '/dashboard/analytics',
14 '/dashboard/settings',
15 '/offline',
16] as const;
5

Configure withFlux() in next.config

Three of these fields work together for dynamic routes and are easy to under configure. 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.
next.config.ts
1// next.config.ts
2import type { NextConfig } from 'next'
3import { withFlux } from '@tsworldtech/flux-next/plugin'
4import { PRECACHE_ROUTES } from './config/precache-routes'
5
6const nextConfig: NextConfig = {
7 turbopack: {},
8 // ...your standard Next.js config remains untouched
9}
10
11export default withFlux({
12 // Routes precached into the Service Worker on boot
13 precacheRoutes: [...PRECACHE_ROUTES],
14
15 // Fallback route when both cache and network are unreachable
16 offlineShell: '/offline',
17
18 // Namespaces every IDB store and Service Worker cache instance.
19 // Must match the storagePrefix you pass to createFlux().
20 storagePrefix: 'my_app_cache',
21
22 // Background sync tags used by the offline queue
23 syncTags: ['flux:sync:default'],
24
25 // Dynamic route offline coverage
26 // dynamicRoutePrefixes: which URL prefixes are dynamic detail routes
27 // at all. When a hard navigation under one of these prefixes misses
28 // both the page cache and the network, the SW knows to look for a
29 // precached shell document instead of falling straight to the offline
30 // error page. This alone gets you ONE shell per prefix — fine if the
31 // dynamic route has no nested tabs/subpages.
32 dynamicRoutePrefixes: ['/items/'],
33
34 // dynamicRouteShapes: use this instead of (or alongside)
35 // dynamicRoutePrefixes when a dynamic route has multiple subpages that
36 // each need their own offline navigable shell — e.g. a detail page
37 // with tabs like /settings, /activity, /members. Each { prefix,
38 // subpaths } entry is expanded into one sentinel shell URL per
39 // subpath and folded automatically into the SW's precache list — you
40 // do NOT list these in precacheRoutes yourself. See Step 13 for the
41 // full pattern, including the fluxDynamicRoute wiring each dynamic
42 // page.tsx needs for these shells to actually exist as real pages at
43 // build time.
44 dynamicRouteShapes: [
45 {
46 prefix: '/items/',
47 subpaths: [''], // '' = the bare detail page itself, no subpages
48 },
49 ],
50
51 // Route prefix -> where the SW looks to decide "do we have data for
52 // this route offline?". Without this the SW can't tell whether a
53 // shell has anything to render, and falls back to a slow full scan of
54 // IndexedDB on every offline navigation. Keys must match
55 // dynamicRoutePrefixes exactly (trailing slash included).
56 //
57 // - idbKey: the idbKey of the per-item detail (LRU) channel you
58 // pass to flux.register(). Tried first, as an exact key
59 // get: '{scope}:{idbKey}:{slug}'. Only hits when the URL
60 // slug equals the key the detail entry is stored under.
61 // - listKey: the idbKey of the whole-list channel. Fallback used
62 // when the exact key misses — e.g. the URL uses a
63 // public slug but the detail entry is keyed by a UUID.
64 // One get on the list, then an in-memory match.
65 // - listFields: item fields compared against the URL slug.
66 //
67 // These strings must match the idbKey values in your flux.register()
68 // calls exactly. You can also pass a plain string
69 // ('/items/': 'item_detail') when the slug is always the detail key.
70 dynamicRouteIdbKeys: {
71 '/items/': {
72 idbKey: 'item_detail',
73 listKey: 'items:list',
74 listFields: ['slug', 'id'],
75 },
76 },
77
78 // Route prefixes that bypass the service worker completely (auth callbacks, webhooks)
79 bypassRoutePrefixes: ['/auth/callback'],
80})(nextConfig)
Why dynamicRouteIdbKeys matters

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.

Keep these strings in sync with flux.register()

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.

6

Add your manifest.json

Place this file at 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.
public/manifest.json
1{
2 "name": "My App",
3 "short_name": "app",
4 "description": "Offline ready realtime web application.",
5 "start_url": "/",
6 "display": "standalone",
7 "background_color": "#0B1519",
8 "theme_color": "#156A80",
9 "icons": [
10 { "src": "/icons/icon-192x192.png", "sizes": "192x192", "type": "image/png" },
11 { "src": "/icons/icon-512x512.png", "sizes": "512x512", "type": "image/png" }
12 ]
13}
7

Define your types (TypeScript only)

types/flux.ts
1// types/flux.ts
2export interface DashboardMetric {
3 id: string
4 label: string
5 value: string
6 status: 'operational' | 'syncing' | 'offline'
7}
8

Create your state store

The one non negotiable contract, regardless of which state manager you pick: whatever you pass as 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

lib/stores/dashboardStore.ts
1// lib/stores/dashboardStore.ts
2import { create } from 'zustand'
3import type { DashboardMetric } from '@/types/flux'
4
5interface DashboardStore {
6 metrics: DashboardMetric[]
7 isHydrated: boolean
8 setHydrated: () => void
9 setState: (data: any) => void
10}
11
12export const useDashboardStore = create<DashboardStore>((set, get) => ({
13 metrics: [],
14 isHydrated: false,
15 setHydrated: () => set({ isHydrated: true }),
16 setState: (data: any) => {
17 if (!data) return
18
19 // 1. Array payload — bootstrap dispatch or IDB cold boot
20 if (Array.isArray(data)) {
21 set({ metrics: [...data], isHydrated: true })
22 return
23 }
24
25 // 2. Delta frame payload — realtime stream event
26 if (typeof data === 'object') {
27 const current = get().metrics
28
29 // DELETE
30 if (data.op === 'DELETE' || data.eventType === 'DELETE') {
31 const id = String(data.id ?? data._id ?? data.data?.id ?? '')
32 if (id) set({ metrics: current.filter((m) => m.id !== id) })
33 return
34 }
35
36 const rawRecord = data.data ?? data.new ?? data
37 const recordId = String(rawRecord?.id ?? rawRecord?._id ?? data.id ?? '')
38 if (!recordId) return
39
40 const idx = current.findIndex((m) => m.id === recordId)
41 if (idx !== -1) {
42 // UPDATE — shallow merge
43 const next = [...current]
44 next[idx] = { ...next[idx], ...rawRecord, id: recordId }
45 set({ metrics: next, isHydrated: true })
46 } else {
47 // CREATE — prepend
48 set({
49 metrics: [{ id: recordId, ...rawRecord }, ...current],
50 isHydrated: true,
51 })
52 }
53 }
54 },
55}))

Redux Toolkit

lib/stores/itemsSlice.ts
1// lib/stores/itemsSlice.ts
2import { createSlice, type PayloadAction } from '@reduxjs/toolkit'
3
4export interface Item {
5 id: string
6 title: string
7 status: string
8}
9
10interface ItemsState {
11 list: Item[]
12}
13
14const initialState: ItemsState = { list: [] }
15
16const itemsSlice = createSlice({
17 name: 'items',
18 initialState,
19 reducers: {
20 setItems(state, action: PayloadAction<Item[]>) {
21 state.list = Array.isArray(action.payload) ? action.payload : []
22 },
23 upsertItem(state, action: PayloadAction<Item>) {
24 const incoming = action.payload
25 const idx = state.list.findIndex((item) => item.id === incoming.id)
26 if (idx !== -1) {
27 state.list[idx] = { ...state.list[idx], ...incoming }
28 } else {
29 state.list.unshift(incoming)
30 }
31 },
32 removeItem(state, action: PayloadAction<string>) {
33 state.list = state.list.filter((item) => item.id !== action.payload)
34 },
35 },
36})
37
38export const { setItems, upsertItem, removeItem } = itemsSlice.actions
39export default itemsSlice.reducer
lib/stores/reduxStore.ts
1// lib/stores/reduxStore.ts
2import { configureStore } from '@reduxjs/toolkit'
3import itemsReducer from './itemsSlice'
4
5export const reduxStore = configureStore({
6 reducer: {
7 items: itemsReducer,
8 },
9})
10
11export type RootState = ReturnType<typeof reduxStore.getState>
12export type AppDispatch = typeof reduxStore.dispatch
Redux needs an adapter to handle single live delta frames, sparse merges, and optimistic placeholder resolution:
lib/flux.ts (excerpt)
1// lib/flux.ts (excerpt) — Redux bridge
2import { reduxStore } from '@/lib/stores/reduxStore'
3import { setItems, upsertItem, removeItem, type Item } from '@/lib/stores/itemsSlice'
4
5export const itemsAdapter = {
6 getState: () => reduxStore.getState().items.list,
7 setState: (data: any) => {
8 if (!data) return
9
10 if (Array.isArray(data)) {
11 reduxStore.dispatch(setItems(data))
12 return
13 }
14
15 if (typeof data === 'object') {
16 if (data.op === 'DELETE' || data.eventType === 'DELETE') {
17 const idToRemove = String(data.id ?? data._id ?? data.data?.id ?? '')
18 if (idToRemove) reduxStore.dispatch(removeItem(idToRemove))
19 return
20 }
21
22 const rawRecord = data.data ?? data.new ?? data
23 const recordId = String(rawRecord?.id ?? rawRecord?._id ?? data.id ?? '')
24 if (recordId) {
25 reduxStore.dispatch(upsertItem({ id: recordId, ...rawRecord } as Item))
26 }
27 }
28 },
29};

Jotai

lib/stores/notificationsAtom.ts
1// lib/stores/notificationsAtom.ts
2import { atom, createStore } from 'jotai'
3
4export interface Notification {
5 id: string
6 title: string
7 message: string
8}
9
10export const notificationsAtom = atom<Notification[]>([])
11export const jotaiStore = createStore()
Jotai atoms require a standalone createStore() since Flux dispatches outside of the React render tree:
lib/flux.ts (excerpt)
1// lib/flux.ts (excerpt) — Jotai bridge
2import { notificationsAtom, jotaiStore, type Notification } from '@/lib/stores/notificationsAtom'
3
4export const notificationsAdapter = {
5 getState: () => jotaiStore.get(notificationsAtom),
6 setState: (data: any) => {
7 if (!data) return
8
9 if (Array.isArray(data)) {
10 jotaiStore.set(notificationsAtom, data as Notification[])
11 return
12 }
13
14 if (typeof data === 'object') {
15 if (data.op === 'DELETE' || data.eventType === 'DELETE') {
16 const idToRemove = String(data.id ?? data._id ?? data.data?.id ?? '')
17 if (idToRemove) {
18 const current = jotaiStore.get(notificationsAtom)
19 jotaiStore.set(notificationsAtom, current.filter((n) => n.id !== idToRemove))
20 }
21 return
22 }
23
24 const rawRecord = data.data ?? data.new ?? data
25 const recordId = String(rawRecord?.id ?? rawRecord?._id ?? data.id ?? '')
26 if (!recordId) return
27
28 const current = jotaiStore.get(notificationsAtom)
29 const idx = current.findIndex((n) => n.id === recordId)
30
31 if (idx !== -1) {
32 const merged = [...current]
33 merged[idx] = { ...merged[idx], ...rawRecord, id: recordId }
34 jotaiStore.set(notificationsAtom, merged)
35 } else {
36 jotaiStore.set(notificationsAtom, [{ id: recordId, ...rawRecord } as Notification, ...current])
37 }
38 }
39 },
40};

Stunk

lib/stores/itemsChunk.ts
1// lib/stores/itemsChunk.ts
2import { chunk } from 'stunk'
3
4export interface Item {
5 id: string
6 title: string
7}
8
9export const itemsChunk = chunk<Item[]>([])
lib/flux.ts (excerpt)
1// lib/flux.ts (excerpt) — Stunk bridge
2import { createStunkStoreAdapter } from '@tsworldtech/flux'
3import { itemsChunk } from '@/lib/stores/itemsChunk'
4
5export const itemsAdapter = createStunkStoreAdapter(itemsChunk)
9

Create the Flux engine singleton

One engine instance per tab, kept on 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.
lib/flux.ts
1// lib/flux.ts
2import { createFlux } from '@tsworldtech/flux'
3import { createSupabaseAdapter } from '@tsworldtech/flux-supabase'
4import { supabase } from './supabase-client'
5import { useDashboardStore } from './stores/dashboardStore'
6
7const GLOBAL_KEY = '__flux_engine_singleton__'
8const CLUSTER_BOOT_KEY = '__flux_cluster_booted__'
9
10declare global {
11 interface Window {
12 __flux_engine_singleton__?: any
13 __flux_cluster_booted__?: boolean
14 }
15}
16
17function getOrCreateFlux() {
18 if (typeof window !== 'undefined' && (window as any)[GLOBAL_KEY]) {
19 return (window as any)[GLOBAL_KEY]
20 }
21
22 const instance = createFlux({
23 adapter: createSupabaseAdapter(supabase),
24 storagePrefix: 'my_app_cache',
25 ttl: {
26 short: 1000 * 60 * 5, // 5 minutes
27 medium: 1000 * 60 * 60, // 1 hour
28 long: 1000 * 60 * 60 * 24, // 24 hours
29 },
30 onStorageFallback: (layer, reason) => {
31 console.warn('[flux] Storage degraded to', layer, '—', reason)
32 },
33 })
34
35 if (typeof window !== 'undefined') (window as any)[GLOBAL_KEY] = instance
36 return instance
37}
38
39export const flux = getOrCreateFlux()
40
41// Starts leader election across open tabs and activates the realtime
42// connection. Without it the engine is registered and IDB is populated,
43// but the realtime manager stays inert: no leader is elected, no channel
44// is opened, so live push AND the catch-up sync after a reconnect never
45// fire. Guarded by a window flag so any number of callers trigger it
46// only once per tab. Call it from your store initializer — see Step 11.
47export function ensureClusterBootOnce(): void {
48 if (typeof window === 'undefined') return
49 if (window[CLUSTER_BOOT_KEY]) return
50 window[CLUSTER_BOOT_KEY] = true
51 flux.realtime.ensureClusterBoot()
52}
53
54export const dashboardStoreAdapter = {
55 setState: (data: any) => useDashboardStore.getState().setState(data),
56 getState: () => useDashboardStore.getState().metrics,
57}
58
59// This channel is scope: 'global' — it is never unregistered by
60// clearUserSession() (which only tears down 'user' scoped channels, see
61// core_concepts/multi_tenant_scoping), so a one shot registration guarded
62// by a permanent window flag is safe here. If you add 'user' scoped
63// channels to this file, do NOT put them inside this block — see the
64// "Re register channels on every login" callout below Step 11, and the
65// complete reference at core_concepts/lib_flux_structure.
66const REGISTERED_KEY = '__flux_registered__'
67if (typeof window !== 'undefined' && !(window as any)[REGISTERED_KEY]) {
68 (window as any)[REGISTERED_KEY] = true
69
70 flux.register({
71 channel: 'dashboard_metrics',
72 table: 'dashboard_metrics',
73 idbKey: 'dashboard_metrics:latest',
74 ttl: 'long',
75 ingestionType: 'COLLECTION_ALL',
76 diffBeforeUpdate: true,
77 event: 'UPDATE',
78 scope: 'global',
79 store: dashboardStoreAdapter,
80 })
81}
10

Wrap your app

app/components/FluxClientWrapper.tsx
1// app/components/FluxClientWrapper.tsx
2'use client'
3
4import { FluxProvider, ServiceWorkerRegistrar } from '@tsworldtech/flux-next'
5import { flux } from '@/lib/flux'
6import { PRECACHE_ROUTES } from '@/config/precache-routes'
7import { FluxStoreInitializer } from './storeInitializer'
8import { OfflineIndicator } from './OfflineIndicator'
9
10export function FluxClientWrapper({ children }: { children: React.ReactNode }) {
11 return (
12 <FluxProvider engine={flux} precacheRoutes={PRECACHE_ROUTES}>
13 <FluxStoreInitializer />
14 {children}
15 <ServiceWorkerRegistrar />
16 <OfflineIndicator />
17 </FluxProvider>
18 )
19}
app/layout.tsx
1// app/layout.tsx
2import { FLUX_SW_INITIALIZER_CODE } from '@tsworldtech/flux-next'
3import { FluxClientWrapper } from './components/FluxClientWrapper'
4
5export default function RootLayout({ children }: { children: React.ReactNode }) {
6 return (
7 <html lang="en">
8 <head>
9 <script id="flux-sw-init" dangerouslySetInnerHTML={{ __html: FLUX_SW_INITIALIZER_CODE }} />
10 </head>
11 <body>
12 <FluxClientWrapper>{children}</FluxClientWrapper>
13 </body>
14 </html>
15 )
16}
11

The hydrate → bootstrap sequence

Configure your client side store initializer. Use the Global blueprint for publicly accessible application data, or switch to the User (Auth Aware) scope for isolated multi tenant data that safely evacuates on session logout. Both end by starting the realtime cluster with ensureClusterBootOnce().
Don't forget 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.

app/components/storeInitializer.tsx
1// app/components/storeInitializer.tsx (Global Scope)
2'use client'
3
4import { useEffect } from 'react'
5import { flux, dashboardStoreAdapter, ensureClusterBootOnce } from '@/lib/flux'
6import { useDashboardStore } from '@/lib/stores/dashboardStore'
7
8export function FluxStoreInitializer() {
9 useEffect(() => {
10 if (typeof window === 'undefined') return
11
12 const SESSION_KEY = 'flux_bootstrap_executed_session'
13
14 async function init() {
15 try {
16 // Step 1 — Fast IDB cold boot read (2000ms max ceiling)
17 await flux.hydrate()
18
19 // Step 2 — Start leader election + realtime. The data is public,
20 // so there's nothing to wait for. Boot right after hydrate and
21 // BEFORE the early return below, so a tab that skips bootstrap
22 // (already seeded this session) still gets live updates.
23 ensureClusterBootOnce()
24
25 const isEmpty = useDashboardStore.getState().metrics.length === 0
26 if (isEmpty) sessionStorage.removeItem(SESSION_KEY)
27
28 if (!isEmpty && sessionStorage.getItem(SESSION_KEY) === 'true') {
29 return // session already seeded from local cache
30 }
31 sessionStorage.setItem(SESSION_KEY, 'true')
32
33 // Step 3 — Single network seed call for public app data
34 await flux.bootstrap({
35 endpoint: '/api/bootstrap/public',
36 cooldownMs: 30_000,
37 scope: 'global',
38 map: {
39 dashboard_metrics: dashboardStoreAdapter,
40 },
41 })
42 } catch (err) {
43 console.warn('[FluxStoreInitializer] Failed:', err)
44 sessionStorage.removeItem(SESSION_KEY)
45 }
46 }
47
48 init()
49 }, [])
50
51 return null
52}
12

Your bootstrap API route

The single backend endpoint that replaces fragmented network waterfall requests. Always return a 200 HTTP response with partial payloads under error states so Flux can ingest valid sections into IndexedDB without aborting. Every registered channel's key should always be present in the payload — as null, {} or [] when there is nothing yet — so "no data yet" is cached as a correct offline state instead of a permanently missing partition.
app/api/bootstrap/public/route.ts
1// app/api/bootstrap/public/route.ts (Global Scope)
2import { NextResponse } from 'next/server'
3import { supabase } from '@/lib/supabase-client'
4import fs from 'fs'
5import path from 'path'
6
7export const dynamic = 'force-dynamic'
8
9function getDynamicChunkPaths(): string[] {
10 try {
11 const manifestPath = path.join(process.cwd(), '.next', 'app-build-manifest.json')
12 if (!fs.existsSync(manifestPath)) return []
13
14 const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'))
15 const pages: Record<string, string[]> = manifest.pages ?? {}
16
17 return Object.entries(pages)
18 .filter(([route]) => route.includes('['))
19 .flatMap(([, chunks]) => chunks)
20 .filter((chunk) => chunk.startsWith('static/'))
21 .map((chunk) => `/_next/${chunk}`)
22 } catch {
23 return []
24 }
25}
26
27const dynamicChunkAssets = getDynamicChunkPaths()
28
29export async function GET() {
30 try {
31 const { data, error } = await supabase
32 .from('dashboard_metrics')
33 .select('*')
34
35 if (error) {
36 return NextResponse.json({ ok: false, error: error.message }, { status: 500 })
37 }
38
39 return NextResponse.json({
40 ok: true,
41 data: {
42 dashboard_metrics: data ?? [],
43 },
44 assets: dynamicChunkAssets,
45 })
46 } catch (err: any) {
47 return NextResponse.json({ ok: false, error: err.message }, { status: 500 })
48 }
49}
13

Dynamic routes that work offline

A single page dynamic route just needs a channel registered with ingestionType: 'LRU' so unvisited detail pages can still hydrate from IDB, plus fluxDynamicRoute handling the build time static params:
app/blog/[slug]/page.tsx
1// app/blog/[slug]/page.tsx
2import { fluxDynamicRoute } from '@tsworldtech/flux-next/server'
3import BlogSlugClientView from './clientView'
4
5export const { generateStaticParams, dynamicParams } = fluxDynamicRoute
6
7export default function BlogSlugPage() {
8 return <BlogSlugClientView />
9}
The client view then reads its own slug straight from IDB with 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.
A dynamic route with multiple offline navigable subpaths — tabs, nested detail sections, anything more than one page under the same dynamic segment — needs one more piece: a real, buildable Next.js page for a sentinel slug the service worker can precache and serve back later with the real value swapped in. This is what dynamicRouteShapes in Step 5 was configuring.
Why a sentinel page instead of precaching every real slug

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.

No extra wiring is needed beyond what a single page dynamic route already uses — the same fluxDynamicRoute export from Step 13's blog example goes on every page in the route, including every subpath:
app/items/[slug]/page.tsx
1// app/items/[slug]/page.tsx
2//
3// Exactly the same one line pattern as any single page dynamic route
4// (see the blog example above). fluxDynamicRoute already handles
5// registering the sentinel shell param at build time — there's nothing
6// extra to configure here just because this route also has subpages.
7import { fluxDynamicRoute } from '@tsworldtech/flux-next/server'
8import ItemClientView from './clientView'
9
10export const { generateStaticParams, dynamicParams } = fluxDynamicRoute
11
12export default function ItemPage() {
13 return <ItemClientView />
14}
app/items/[slug]/settings/page.tsx
1// app/items/[slug]/settings/page.tsx
2//
3// Same fluxDynamicRoute one liner repeats for every subpath listed in
4// that dynamicRouteShapes entry's "subpaths" array. This is what makes
5// tabs like /settings, /activity independently open able offline (from
6// their own cached shell) rather than only the bare detail page.
7import { fluxDynamicRoute } from '@tsworldtech/flux-next/server'
8import ItemSettingsClientView from './clientView'
9
10export const { generateStaticParams, dynamicParams } = fluxDynamicRoute
11
12export default function ItemSettingsPage() {
13 return <ItemSettingsClientView />
14}
Each page's client view reads its own store, gated on the same 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:
app/items/[slug]/clientView.tsx
1// app/items/[slug]/clientView.tsx
2"use client";
3
4import { useParams, notFound } from "next/navigation";
5import { useItemStore } from "@/lib/stores/itemStore";
6import { useAuthStore } from "@/lib/stores/authStore";
7import ItemDetail from "@/components/ItemDetail";
8
9export default function ItemClientView() {
10 const { slug } = useParams<{ slug: string }>();
11 const { items } = useItemStore();
12
13 // Same flag your store initializer sets once bootstrap/IDB warm
14 // actually completes. `items` resets to [] synchronously on every
15 // hard refresh and only repopulates asynchronously — without this
16 // gate, `item` is undefined on the very first render and notFound()
17 // fires before hydration gets a chance to run, producing a 404 on
18 // refresh even for items that genuinely exist.
19 const dataReady = useAuthStore((s) => s.dataReady);
20
21 // Match on the same fields you listed in dynamicRouteIdbKeys.listFields
22 // — the URL slug may be a public slug OR the row id.
23 const item = items.find((i) => i.slug === slug || i.id === slug);
24
25 if (!dataReady && !item) {
26 return (
27 <div className="mx-auto flex min-h-[60vh] max-w-4xl flex-col gap-3 py-16">
28 <div className="h-8 w-48 rounded-lg bg-[var(--color-surface)] animate-pulse" />
29 <div className="h-14 rounded-xl bg-[var(--color-surface)] animate-pulse" />
30 <div className="h-14 rounded-xl bg-[var(--color-surface)] animate-pulse" />
31 </div>
32 );
33 }
34
35 if (!item) return notFound();
36
37 return <ItemDetail item={item} />;
38}
With every subpath's page wired this way, a next.config using the multi subpath shape looks like this — the sentinel URLs it produces are folded into the SW's precache list automatically, so config/precache-routes.ts from Step 4 never needs to list them:
next.config.ts
1// next.config.ts — a dynamic route with several offline navigable subpages
2//
3// Real world shape: a detail route with a few tabs, each of which
4// should still open (from its cached shell) if the device goes offline
5// after having visited that item at least once.
6import type { NextConfig } from 'next'
7import { withFlux } from '@tsworldtech/flux-next/plugin'
8import { PRECACHE_ROUTES } from './config/precache-routes'
9
10const nextConfig: NextConfig = {
11 turbopack: {},
12}
13
14export default withFlux({
15 // The __flux_shell__ entries below are NOT listed in precacheRoutes —
16 // dynamicRouteShapes generates and precaches every one of them
17 // automatically.
18 precacheRoutes: [...PRECACHE_ROUTES],
19
20 offlineShell: '/offline',
21 storagePrefix: 'my_app_cache',
22 syncTags: ['flux:sync:default'],
23
24 // Every subpath under /items/[slug]/ that has its own page.tsx and
25 // should work offline once the item's shell has been visited online
26 // at least once.
27 dynamicRoutePrefixes: ['/items/'],
28 dynamicRouteShapes: [
29 {
30 prefix: '/items/',
31 subpaths: ['', '/settings', '/activity'],
32 },
33 ],
34
35 // One entry per prefix. Every subpath shares the same data lookup,
36 // because the slug is always the first segment after the prefix.
37 dynamicRouteIdbKeys: {
38 '/items/': {
39 idbKey: 'item_detail',
40 listKey: 'items:list',
41 listFields: ['slug', 'id'],
42 },
43 },
44
45 bypassRoutePrefixes: ['/auth/callback'],
46})(nextConfig)
14

Your offline fallback page

app/offline/page.tsx
1// app/offline/page.tsx
2'use client'
3
4export default function OfflinePage() {
5 return (
6 <div>
7 <h1>You're offline</h1>
8 <p>Previously visited pages are still available. Anything you submit will sync automatically once you reconnect.</p>
9 </div>
10 )
11}
Must be a real route, and must be listed in both 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.
15

Offline ready static images (optional)

For logos, icons, or marketing images that are not fetched through 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.
app/components/Logo.tsx
1// app/components/Logo.tsx
2import { CachedImg } from '@tsworldtech/flux-next'
3
4export function Logo() {
5 return (
6 <CachedImg
7 src="/assets/logo.png"
8 alt="App Logo"
9 className="h-6 w-auto object-contain transition-transform duration-200 group-hover:scale-105"
10 />
11 )
12}
16

Sync your own writes instantly (dispatchLocalUpdate)

Every store so far reacts to data arriving from outside the client — IDB cold boot, bootstrap, or a realtime delta frame. But a locally originated write — a form submit, a settings change, anything the user just did — has to round trip through your backend and then wait for the realtime event to come back before the offline queue, IDB cache, and other open tabs catch up. 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.
Signature

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

lib/stores/preferencesStore.ts
1// lib/stores/preferencesStore.ts
2import { create } from 'zustand'
3
4export interface UserPreferences {
5 theme: 'light' | 'dark'
6 locale: string
7 digestFrequency: 'daily' | 'weekly' | 'off'
8}
9
10const defaultPreferences: UserPreferences = {
11 theme: 'light',
12 locale: 'en-US',
13 digestFrequency: 'weekly',
14}
15
16function normalizeRow(row: any, fallback: UserPreferences): UserPreferences {
17 if (!row) return fallback
18 return {
19 theme: row.theme ?? fallback.theme,
20 locale: row.locale ?? fallback.locale,
21 digestFrequency: row.digest_frequency ?? fallback.digestFrequency,
22 }
23}
24
25// The inverse — turns confirmed store state back into a raw row shape
26// before it's handed to dispatchLocalUpdate(). Pure data shape
27// conversion, no Flux dependency, same convention as any other store.
28export function preferencesToRow(prefs: UserPreferences): Record<string, any> {
29 return {
30 theme: prefs.theme,
31 locale: prefs.locale,
32 digest_frequency: prefs.digestFrequency,
33 }
34}
35
36interface PreferencesStore {
37 preferences: UserPreferences
38 original: UserPreferences | null
39 setPreference: <K extends keyof UserPreferences>(key: K, value: UserPreferences[K]) => void
40 isDirty: () => boolean
41 setState: (data: any) => void
42}
43
44export const usePreferencesStore = create<PreferencesStore>((set, get) => ({
45 preferences: { ...defaultPreferences },
46 original: null,
47
48 setPreference: (key, value) => {
49 set((s) => ({ preferences: { ...s.preferences, [key]: value } }))
50 },
51
52 isDirty: () => {
53 const s = get()
54 return s.original ? JSON.stringify(s.preferences) !== JSON.stringify(s.original) : false
55 },
56
57 /* Flux intake gate — SNAPSHOT, scope: user
58 Same setState(data) contract every registered store needs — called
59 on IDB cold boot, on bootstrap, on every realtime delta frame, AND
60 now on a confirmed local write dispatched via dispatchLocalUpdate(). */
61 setState: (data: any) => {
62 if (!data) return
63
64 if (Array.isArray(data)) {
65 if (data.length === 0) return
66 const preferences = normalizeRow(data[0], get().preferences)
67 set({ preferences, original: { ...preferences } })
68 return
69 }
70
71 if (typeof data === 'object') {
72 if (data.op === 'DELETE' || data.eventType === 'DELETE') {
73 set({ preferences: { ...defaultPreferences }, original: null })
74 return
75 }
76
77 const rawRecord = data.data ?? data.new ?? data
78 const preferences = normalizeRow(rawRecord, get().preferences)
79 set({ preferences, original: { ...preferences } })
80 }
81 },
82}))

Register the channel, like any other

lib/flux.ts (excerpt)
1// lib/flux.ts (excerpt) — register the preferences channel
2import { usePreferencesStore } from './stores/preferencesStore'
3
4export const preferencesAdapter = {
5 setState: (data: any) => usePreferencesStore.getState().setState(data),
6 getState: () => {
7 const preferences = usePreferencesStore.getState().preferences
8 return preferences ? [preferences] : []
9 },
10}
11
12flux.register({
13 channel: 'preferences',
14 table: 'user_preferences',
15 idbKey: 'preferences:settings',
16 ttl: 'medium',
17 ingestionType: 'SNAPSHOT',
18 diffBeforeUpdate: true,
19 event: 'UPDATE',
20 scope: 'user',
21 store: preferencesAdapter,
22})

Wrap the save: write, confirm, then dispatch

Keep this wrapper in 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:
lib/flux.ts (excerpt)
1// lib/flux.ts (excerpt) — save a local edit, then sync it into Flux
2import { usePreferencesStore, preferencesToRow } from './stores/preferencesStore'
3import { updateUserPreferences } from './actions/preferences' // your own server action / API call
4
5export async function savePreferencesAndSync(): Promise<void> {
6 const before = usePreferencesStore.getState()
7 if (!before.isDirty()) return
8
9 // 1. Do the real write first. Nothing touches Flux until the server
10 // has actually confirmed the mutation — this is not an optimistic
11 // dispatch, it's a confirmed write dispatch.
12 let confirmedRow: any
13 try {
14 confirmedRow = await updateUserPreferences(preferencesToRow(before.preferences))
15 } catch (err) {
16 console.warn('[preferences] Save failed:', err)
17 return
18 }
19
20 // 2. Only now: push the confirmed row through the exact same intake
21 // gate bootstrap and realtime data go through. It updates the
22 // store (via preferencesAdapter → usePreferencesStore's own
23 // setState, same normalizeRow path as everything else), persists
24 // to this user's namespaced IDB key immediately, and mirrors the
25 // change to any other open tab — instead of waiting on the
26 // realtime UPDATE event to eventually catch up.
27 await flux.dispatchLocalUpdate('preferences', confirmedRow ?? preferencesToRow(before.preferences))
28}

Call it from your form

app/components/PreferencesForm.tsx
1// app/components/PreferencesForm.tsx (excerpt)
2'use client'
3
4import { usePreferencesStore } from '@/lib/stores/preferencesStore'
5import { savePreferencesAndSync } from '@/lib/flux'
6
7export function PreferencesForm() {
8 const { preferences, setPreference, isDirty } = usePreferencesStore()
9
10 return (
11 <form
12 onSubmit={(e) => {
13 e.preventDefault()
14 savePreferencesAndSync()
15 }}
16 >
17 <select
18 value={preferences.theme}
19 onChange={(e) => setPreference('theme', e.target.value as 'light' | 'dark')}
20 >
21 <option value="light">Light</option>
22 <option value="dark">Dark</option>
23 </select>
24
25 <button type="submit" disabled={!isDirty()}>
26 Save
27 </button>
28 </form>
29 )
30}
The same pattern applies to any channel — swap 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

A list channel reaches other tabs as a diff patch, so dispatching the new list is enough to remove a row from it. But an LRU detail entry (Step 13) is a separate cached item that is not part of that diff. 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.
lib/flux.ts (excerpt)
1// lib/flux.ts (excerpt) — confirmed deletion of a record that has an LRU detail entry
2import { useItemStore } from './stores/itemStore'
3
4export async function deleteItemAndSync(id: string): Promise<{ error: Error | null }> {
5 // 1. Do the real delete first (your own API route / server action).
6 const res = await fetch('/api/items', {
7 method: 'DELETE',
8 headers: { 'Content-Type': 'application/json' },
9 body: JSON.stringify({ id }),
10 })
11 if (!res.ok) return { error: new Error('Delete failed (' + res.status + ')') }
12
13 // 2. Drop it from the in-memory list and mirror the new list into IDB.
14 // A list channel reaches other tabs as a diff patch.
15 useItemStore.getState().dropItemLocal(id)
16 await flux.dispatchLocalUpdate('items', useItemStore.getState().items)
17
18 // 3. The LRU detail entry is a SEPARATE cached item, not part of the
19 // list diff. dispatchLocalDelete removes it from IDB and this tab,
20 // and signals every other open tab to drop it too.
21 await flux.dispatchLocalDelete('item_detail', id)
22
23 return { error: null }
24}
Just want a static page to work offline, no backend at all?

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:

next.config.ts
1// next.config.ts
2import { withFlux } from '@tsworldtech/flux-next/plugin'
3
4export default withFlux({
5 precacheRoutes: ['/', '/pricing', '/about', '/offline'],
6 offlineShell: '/offline',
7 storagePrefix: 'my_static_site',
8 bypassRoutePrefixes: ['/auth/callback'],
9})(nextConfig)

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.

Powered by Flux