React
This guide takes a fresh Vite + React project to a fully offline resilient, single bootstrap, realtime synced app — using whichever state manager you already work with.
- An existing Vite + React 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 React integration
FluxProvider, HydrationGate, FluxErrorBoundary, fluxVitePlugin (the Vite plugin that injects your service worker at build time), and the useFlux() / useFluxTime() hooks — the same hooks flux-next ships, minus anything Next.js specific like RSC handling.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 the Vite plugin
withFlux() in Next.js, this plugin has no server rendered build manifest to read for dynamic route chunk warming — Vite emits a client only dist/.vite/manifest.json, and fluxVitePlugin reads it in closeBundle to bake chunk hashes into the generated service worker.In a plain Vite SPA or CRA there is exactly one HTML document for every route. That means dynamicRoutePrefixes on its own already gets a dynamic route — and everything under it, tabs and nested subpages included — working offline: a hard miss just serves index.html and your client side router mounts the right screen from window.location, same as it does online. dynamicRouteShapes has nothing to add there, because there is no second document to precache, and dynamicRouteIdbKeys is never consulted, because the worker serves the one document without checking IDB first.
Remix is different: each route genuinely is its own server rendered document, so dynamicRouteShapes becomes meaningful there — see Step 13 — and so does dynamicRouteIdbKeys, which tells the worker where to look to confirm it has data for a slug before serving a cached shell. fluxVitePlugin resolves which mode you're in automatically by checking your package.json for a @remix-run/* dependency and defaulting isSpa accordingly — you only need to set isSpa by hand if you're running Remix's own SPA mode (unstable_ssr: false), which isn't detectable from package.json alone. An explicit value you set always overrides the detected default.
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 — 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
ensureClusterBootOnce(), which starts the realtime layer — Step 11 shows where to call it.Pick the tab matching the adapter you installed in Step 3. Vite modules execute once per page load, functioning naturally as singletons without Fast Refresh engine guards. Every tab exports ensureClusterBootOnce(), which starts the realtime layer — Step 11 shows where to call it. Note this only covers 'global' scoped registration — if you also register 'user' scoped channels, see the callout in Step 11 about registering them through registerUserChannels() rather than inline here.
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 (Node / Express)
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 detail pages that work offline (optional)
isSpa from Step 5 actually forks behavior. Pick whichever matches your setup:With isSpa at its default of true, the dynamicRoutePrefixes entry from Step 5 already covers every subpath under /dashboard/items/ — /dashboard/items/42, /dashboard/items/42/settings, anything. A hard miss on any of them serves the same cached index.html, and React Router or TanStack Router reads the real path from window.location and mounts the matching component, which then hydrates from IDB exactly like BlogSlugPage above. There is no shell document to configure per subpath because there is only ever the one document.
Your offline fallback page
precacheRoutes and offlineShell in your fluxVitePlugin() config (Step 5).Offline ready static images (optional)
flux.register(), use CachedImg to store them in Flux's image IndexedDB cache.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
scope: 'user' channel, put it inside your registerUserChannels() function from Step 11 rather than at module top level — otherwise it falls into the same "empty after first logout" gap described there.Wrap the save: write, confirm, then dispatch
src/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 (Redux, Jotai, or Stunk) and the save action for whatever your own mutation is. See each adapter's own page under State Managers for the equivalent local write wiring. If the write fails, skip the dispatch entirely and let your existing error handling surface it — there's 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. 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 fluxVitePlugin() precaches these at build time regardless of whether createFlux() is called.
Every adapter shown here — Supabase, Socket.io, SSE, WebSocket, polling — and every state manager — Zustand, Redux, Jotai, Stunk — are interchangeable independently of each other and of the framework. Nothing in @tsworldtech/flux's core engine knows whether it's running inside Next.js, a plain Vite build, or Remix.