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.
config/precache-routes.ts
1// config/precache-routes.ts
2export const PRECACHE_ROUTES = [
3 '/',
4 '/docs',
5 '/docs/getting-started',
6 '/dashboard',
7 '/dashboard/analytics',
8 '/dashboard/settings',
9 '/offline',
10] as const;
5

Configure withFlux() in next.config

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 storagePrefix: 'my-app-cache',
20
21 // Background-sync tags used by the offline queue
22 syncTags: ['flux:sync:default'],
23
24 // App shell fallback prefixes for dynamic routes accessed offline
25 dynamicRoutePrefixes: ['/dashboard/items/'],
26
27 // Route prefixes that bypass the service worker completely (auth callbacks, webhooks)
28 bypassRoutePrefixes: ['/auth/callback', '/portal/auth/callback'],
29})(nextConfig)
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, and every realtime event (including arrays, delta objects, sparse updates, and deletions).

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

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__'
8
9function getOrCreateFlux() {
10 if (typeof window !== 'undefined' && (window as any)[GLOBAL_KEY]) {
11 return (window as any)[GLOBAL_KEY]
12 }
13
14 const instance = createFlux({
15 adapter: createSupabaseAdapter(supabase),
16 storagePrefix: 'my-app-cache',
17 ttl: {
18 short: 1000 * 60 * 5, // 5 minutes
19 medium: 1000 * 60 * 60, // 1 hour
20 long: 1000 * 60 * 60 * 24, // 24 hours
21 },
22 onStorageFallback: (layer, reason) => {
23 console.warn('[flux] Storage degraded to', layer, '—', reason)
24 },
25 })
26
27 if (typeof window !== 'undefined') (window as any)[GLOBAL_KEY] = instance
28 return instance
29}
30
31export const flux = getOrCreateFlux()
32
33export const dashboardStoreAdapter = {
34 setState: (data: any) => useDashboardStore.getState().setState(data),
35 getState: () => useDashboardStore.getState().metrics,
36}
37
38const REGISTERED_KEY = '__flux_registered__'
39if (typeof window !== 'undefined' && !(window as any)[REGISTERED_KEY]) {
40 (window as any)[REGISTERED_KEY] = true
41
42 flux.register({
43 channel: 'dashboard_metrics',
44 table: 'dashboard_metrics',
45 idbKey: 'dashboard_metrics:latest',
46 ttl: 'long',
47 ingestionType: 'COLLECTION_ALL',
48 diffBeforeUpdate: true,
49 event: 'UPDATE',
50 scope: 'global',
51 store: dashboardStoreAdapter,
52 })
53}
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.
app/components/storeInitializer.tsx
1// app/components/storeInitializer.tsx (Global Scope)
2'use client'
3
4import { useEffect } from 'react'
5import { flux, dashboardStoreAdapter } 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 const isEmpty = useDashboardStore.getState().metrics.length === 0
20 if (isEmpty) sessionStorage.removeItem(SESSION_KEY)
21
22 if (!isEmpty && sessionStorage.getItem(SESSION_KEY) === 'true') {
23 return // session already seeded from local cache
24 }
25 sessionStorage.setItem(SESSION_KEY, 'true')
26
27 // Step 2 — Single network seed call for public app data
28 await flux.bootstrap({
29 endpoint: '/api/bootstrap/public',
30 cooldownMs: 30_000,
31 scope: 'global',
32 map: {
33 dashboard_metrics: dashboardStoreAdapter,
34 },
35 })
36 } catch (err) {
37 console.warn('[FluxStoreInitializer] Failed:', err)
38 sessionStorage.removeItem(SESSION_KEY)
39 }
40 }
41
42 init()
43 }, [])
44
45 return null
46}
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.
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 (optional)

Only needed if you registered an LRU-ingested channel (e.g. blog posts, product detail pages) and want an unvisited slug to still render offline after the one bootstrap call seeded it.
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.
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}
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 14 at all. Skip the engine, the adapter, and the store entirely. You still need Steps 1–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', '/portal/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.