Getting Started

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.

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

bash
npm install @tsworldtech/flux-react
This gives you 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.
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 the 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.
src/config/precache-routes.ts
1// src/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 the Vite plugin

vite.config.ts
1// vite.config.ts
2import { defineConfig } from 'vite'
3import react from '@vitejs/plugin-react'
4import { fluxVitePlugin } from '@tsworldtech/flux-react/plugin'
5import { PRECACHE_ROUTES } from './src/config/precache-routes'
6
7export default defineConfig({
8 plugins: [
9 react(),
10
11 // Reads dist/.vite/manifest.json in closeBundle and bakes the SW
12 // template with your build's actual chunk hashes.
13 fluxVitePlugin({
14 // Routes to precache into the service worker on first load
15 precacheRoutes: [...PRECACHE_ROUTES],
16
17 // The fallback route when a page is neither cached nor reachable
18 offlineShell: '/offline',
19
20 // Namespaces every IDB store and SW cache this app creates
21 storagePrefix: 'my-react-app',
22
23 // Names for the SW's background-sync tags used by the queue
24 syncTags: ['flux:sync:default'],
25
26 // URL prefixes that identify dynamic routes so the SW serves the shell
27 dynamicRoutePrefixes: ['/dashboard/items/'],
28
29 // Route prefixes that bypass the service worker completely (auth callbacks, webhooks)
30 bypassRoutePrefixes: ['/auth/callback', '/portal/auth/callback'],
31 }),
32 ],
33 server: {
34 port: 5173,
35 },
36})
Unlike 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.
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": "Flux React App",
3 "short_name": "FluxApp",
4 "description": "Offline-first 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)

Optional, but recommended: keep your registered data shapes in one place per store, imported by both your state manager file (Step 8) and your engine file (Step 9) rather than declared inline in either. JavaScript users can skip straight to Step 8.
src/types/flux.ts
1// src/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.

Zustand — native, no adapter needed

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

Redux Toolkit

src/store/itemsSlice.ts
1// src/store/itemsSlice.ts
2import { createSlice, type PayloadAction } from '@reduxjs/toolkit'
3
4export interface Item {
5 id: string
6 name: string
7 description: string
8 status: string
9 price: number
10 createdAt: string
11 updatedAt: string
12}
13
14interface ItemsState {
15 list: Item[]
16}
17
18const initialState: ItemsState = { list: [] }
19
20const itemsSlice = createSlice({
21 name: 'items',
22 initialState,
23 reducers: {
24 setItems(state, action: PayloadAction<Item[]>) {
25 state.list = Array.isArray(action.payload) ? action.payload : []
26 },
27 addOptimisticItem(state, action: PayloadAction<Item>) {
28 state.list.unshift(action.payload)
29 },
30 upsertItem(state, action: PayloadAction<Item>) {
31 const incoming = action.payload
32 const optimisticIdx = state.list.findIndex(
33 (item) =>
34 item.status === 'optimistic_pending' &&
35 item.name === incoming.name
36 )
37 if (optimisticIdx !== -1) {
38 state.list[optimisticIdx] = incoming
39 return
40 }
41 const existingIdx = state.list.findIndex((item) => item.id === incoming.id)
42 if (existingIdx !== -1) {
43 state.list[existingIdx] = { ...state.list[existingIdx], ...incoming }
44 return
45 }
46 state.list.unshift(incoming)
47 },
48 removeItem(state, action: PayloadAction<string>) {
49 state.list = state.list.filter((item) => item.id !== action.payload)
50 },
51 },
52})
53
54export const { setItems, addOptimisticItem, upsertItem, removeItem } = itemsSlice.actions
55export default itemsSlice.reducer
src/store/reduxStore.ts
1// src/store/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:
src/lib/flux.ts (excerpt)
1// src/lib/flux.ts (excerpt) — bridging Redux into Flux
2import { reduxStore } from '../store/reduxStore'
3import { setItems, upsertItem, removeItem, type Item } from '../store/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 const current = reduxStore.getState().items.list
12 const hasOptimistic = current.some((i) => i.status === 'optimistic_pending')
13
14 if (hasOptimistic) {
15 const resolved = data.map((item) => {
16 if (item.status !== 'optimistic_pending') return item
17 const confirmed = data.find(
18 (i) => i.status !== 'optimistic_pending' && i.name === item.name
19 )
20 return confirmed ? null : item
21 }).filter(Boolean) as Item[]
22
23 reduxStore.dispatch(setItems(resolved))
24 return
25 }
26
27 reduxStore.dispatch(setItems(data))
28 return
29 }
30
31 if (typeof data === 'object') {
32 if (data.op === 'DELETE' || data.eventType === 'DELETE') {
33 const idToRemove = String(data.id ?? data._id ?? data.data?.id ?? '')
34 if (idToRemove) reduxStore.dispatch(removeItem(idToRemove))
35 return
36 }
37
38 const rawRecord = data.data ?? data.new ?? data
39 const recordId = String(rawRecord?.id ?? rawRecord?._id ?? data.id ?? '')
40
41 if (recordId) {
42 const normalizedItem = { id: recordId, ...rawRecord } as Item
43 reduxStore.dispatch(upsertItem(normalizedItem))
44 }
45 }
46 },
47};

Jotai

src/store/notificationsAtom.ts
1// src/store/notificationsAtom.ts
2import { atom, createStore } from 'jotai'
3
4export interface Notification {
5 id: string
6 title: string
7 message: string
8 type: string
9 read: boolean
10 createdAt: string
11}
12
13export const notificationsAtom = atom<Notification[]>([])
14export const jotaiStore = createStore()
Jotai atoms require a standalone createStore() since Flux dispatches outside of the React render tree:
src/lib/flux.ts (excerpt)
1// src/lib/flux.ts (excerpt) — bridging Jotai into Flux
2import { notificationsAtom, jotaiStore, type Notification } from '../store/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 existingIdx = current.findIndex((n) => n.id === recordId)
30
31 if (existingIdx !== -1) {
32 const merged = [...current]
33 merged[existingIdx] = { ...merged[existingIdx], ...rawRecord, id: recordId }
34 jotaiStore.set(notificationsAtom, merged)
35 } else {
36 const normalized = { id: recordId, ...rawRecord } as Notification
37 jotaiStore.set(notificationsAtom, [normalized, ...current])
38 }
39 }
40 },
41};

Stunk

src/store/itemsChunk.ts
1// src/store/itemsChunk.ts
2import { chunk } from 'stunk'
3
4export interface Item {
5 id: string
6 name: string
7 description: string
8 status: string
9 price: number
10}
11
12export const itemsChunk = chunk<Item[]>([])
src/lib/flux.ts (excerpt)
1// src/lib/flux.ts (excerpt) — bridging Stunk into Flux
2import { createStunkStoreAdapter } from '@tsworldtech/flux'
3import { itemsChunk } from '../store/itemsChunk'
4
5export const itemsAdapter = createStunkStoreAdapter(itemsChunk)
9

Create the Flux engine singleton

Vite modules only execute once per page load, so createFlux naturally acts as a singleton without dev singleton guards.
bash
// src/lib/flux.ts
import { createFlux } from '@tsworldtech/flux'
import { createSupabaseAdapter } from '@tsworldtech/flux-supabase'
import { supabase } from './supabase-client'
import { useDashboardStore } from '../store/dashboardStore'
export const flux = createFlux({
adapter: createSupabaseAdapter(supabase),
storagePrefix: 'my-react-app',
ttl: {
short: 1000 * 60 * 5, // 5 minutes
medium: 1000 * 60 * 60, // 1 hour
long: 1000 * 60 * 60 * 24, // 24 hours
},
onStorageFallback: (layer, reason) => {
console.warn('[flux] Storage degraded to', layer, '—', reason)
},
})
export const dashboardStoreAdapter = {
setState: (data: any) => useDashboardStore.getState().setState(data),
getState: () => useDashboardStore.getState().metrics,
}
flux.register({
channel: 'dashboard_metrics',
table: 'dashboard_metrics',
idbKey: 'dashboard_metrics:latest',
ttl: 'long',
ingestionType: 'COLLECTION_ALL',
diffBeforeUpdate: true,
event: 'UPDATE',
scope: 'global',
store: dashboardStoreAdapter,
})

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.

10

Wrap your app

src/components/FluxClientWrapper.tsx
1// src/components/FluxClientWrapper.tsx
2import React from 'react'
3import { FluxProvider, HydrationGate, OfflineIndicator } from '@tsworldtech/flux-react'
4import { flux } from '../lib/flux'
5import { PRECACHE_ROUTES } from '../config/precache-routes'
6import { FluxStoreInitializer } from './storeInitializer'
7
8export function FluxClientWrapper({ children }: { children: React.ReactNode }) {
9 return (
10 <FluxProvider engine={flux} precacheRoutes={PRECACHE_ROUTES}>
11 <FluxStoreInitializer />
12 <HydrationGate>
13 {children}
14 </HydrationGate>
15 <OfflineIndicator />
16 </FluxProvider>
17 )
18}
11

The hydrate → bootstrap sequence

Configure the client-side store initializer. Use the Global tab for public data that persists across user sessions, or the User (Auth Aware) tab for multi-tenant state isolated per user account.
src/components/storeInitializer.tsx
1// src/components/storeInitializer.tsx (Global Scope)
2import { useEffect } from 'react'
3import { flux, dashboardStoreAdapter } from '../lib/flux'
4import { useDashboardStore } from '../store/dashboardStore'
5
6export function FluxStoreInitializer() {
7 useEffect(() => {
8 if (typeof window === 'undefined') return
9
10 const SESSION_KEY = 'flux_bootstrap_executed_session'
11
12 async function init() {
13 try {
14 // Step 1 — Always hydrate first from local IndexedDB (2000ms max timeout)
15 await flux.hydrate()
16
17 const isEmpty = useDashboardStore.getState().metrics.length === 0
18 if (isEmpty) sessionStorage.removeItem(SESSION_KEY)
19
20 if (!isEmpty && sessionStorage.getItem(SESSION_KEY) === 'true') {
21 return // already bootstrapped this session, disk is warm
22 }
23 sessionStorage.setItem(SESSION_KEY, 'true')
24
25 // Step 2 — Single network seed call for public app data
26 await flux.bootstrap({
27 endpoint: 'http://localhost:3001/api/bootstrap/public',
28 cooldownMs: 30_000,
29 scope: 'global',
30 map: {
31 dashboard_metrics: dashboardStoreAdapter,
32 },
33 })
34 } catch (err) {
35 console.warn('[FluxStoreInitializer] Failed:', err)
36 sessionStorage.removeItem(SESSION_KEY)
37 }
38 }
39
40 init()
41 }, [])
42
43 return null
44}
12

Your bootstrap API route (Node / Express)

The single backend endpoint that replaces fragmented network fetches on initial app load. Always return 200 HTTP responses with partial payloads when non-critical query errors occur to allow Flux to ingest available data offline.
server/routes/bootstrapPublic.ts
1// server/routes/bootstrapPublic.ts
2import express, { Router, Request, Response } from 'express'
3import Metric from '../models/Metric'
4
5const router: Router = express.Router()
6
7// GET /api/bootstrap/public
8router.get('/', async (req: Request, res: Response) => {
9 try {
10 const metrics = await Metric.find({})
11
12 res.json({
13 ok: true,
14 data: {
15 dashboard_metrics: metrics.map((m) => ({ ...m.toObject(), id: m._id.toString() })),
16 },
17 assets: [],
18 })
19 } catch (err: any) {
20 res.status(500).json({ ok: false, error: err.message })
21 }
22})
23
24export default router
server/server.ts
1// server/server.ts (excerpt)
2import bootstrapPublicRoute from './routes/bootstrapPublic'
3import bootstrapUserRoute from './routes/bootstrapUser'
4
5app.use('/api/bootstrap/public', bootstrapPublicRoute)
6app.use('/api/bootstrap/user', bootstrapUserRoute)
13

Dynamic detail pages 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.
src/store/blogStore.ts
1// src/store/blogStore.ts
2import { create } from 'zustand'
3
4export interface BlogPost {
5 id: string
6 slug: string
7 title: string
8 excerpt: string
9 author: string
10 body: any[]
11}
12
13export type BlogCard = Omit<BlogPost, 'body'>
14
15interface BlogListStore {
16 posts: BlogCard[]
17 isHydrated: boolean
18 setState: (data: any) => void
19}
20
21export const useBlogListStore = create<BlogListStore>((set, get) => ({
22 posts: [],
23 isHydrated: false,
24 setState: (data: any) => {
25 if (!data) return
26 const incoming = Array.isArray(data) ? data : get().posts
27 set({ posts: incoming, isHydrated: true })
28 },
29}))
30
31interface BlogDetailStore {
32 activePost: BlogPost | null
33 isHydrated: boolean
34 setHydrated: () => void
35 setState: (data: any) => void
36}
37
38export const useBlogDetailStore = create<BlogDetailStore>((set) => ({
39 activePost: null,
40 isHydrated: false,
41 setHydrated: () => set({ isHydrated: true }),
42 setState: (data: any) => {
43 const post: BlogPost | null =
44 data && typeof data === 'object' && !Array.isArray(data) ? data : null
45 set({ activePost: post, isHydrated: true })
46 },
47}))
src/lib/flux.ts (excerpt)
1// src/lib/flux.ts (excerpt) — registering the list + detail channels
2import { useBlogListStore, useBlogDetailStore } from '../store/blogStore'
3
4export const blogListStoreAdapter = {
5 setState: (data: any) => useBlogListStore.getState().setState(data),
6 getState: () => useBlogListStore.getState().posts,
7}
8
9export const blogDetailStoreAdapter = {
10 setState: (data: any) => {
11 if (Array.isArray(data)) return
12 if (data && typeof data === 'object') {
13 useBlogDetailStore.getState().setState(data)
14 }
15 },
16 getState: () => {
17 const active = useBlogDetailStore.getState().activePost
18 return active ? [active] : []
19 },
20}
21
22flux.register({
23 channel: 'blog_posts',
24 table: 'blog_posts',
25 idbKey: 'blog_posts:list',
26 ttl: 'long',
27 ingestionType: 'COLLECTION_ALL',
28 diffBeforeUpdate: true,
29 event: 'UPDATE',
30 store: blogListStoreAdapter,
31})
32
33flux.register({
34 channel: 'blog_post_detail',
35 table: 'blog_posts',
36 idbKey: 'blog_post_detail',
37 ttl: 'long',
38 ingestionType: 'LRU',
39 cacheStrategy: {
40 type: 'LRU',
41 maxBytes: 15 * 1024 * 1024,
42 trackAccessTime: true,
43 },
44 store: blogDetailStoreAdapter,
45 event: 'UPDATE',
46})
src/pages/BlogSlugPage.tsx
1// src/pages/BlogSlugPage.tsx
2import { useEffect } from 'react'
3import { useParams, Link } from 'react-router-dom'
4import { idbGet } from '@tsworldtech/flux'
5import { useBlogDetailStore } from '../store/blogStore'
6
7export default function BlogSlugPage() {
8 const { slug } = useParams<{ slug: string }>()
9
10 const activePost = useBlogDetailStore((s) => s.activePost)
11 const isHydrated = useBlogDetailStore((s) => s.isHydrated)
12
13 useEffect(() => {
14 if (!slug) return
15
16 useBlogDetailStore.setState({ activePost: null, isHydrated: false })
17
18 const idbKey = `blog_post_detail:${slug}`
19 const storagePrefix = 'my-react-app'
20
21 idbGet(idbKey, storagePrefix, true).then((result) => {
22 if (result?.data) {
23 useBlogDetailStore.getState().setState(result.data)
24 } else {
25 useBlogDetailStore.getState().setHydrated()
26 }
27 }).catch(() => {
28 useBlogDetailStore.getState().setHydrated()
29 })
30 }, [slug])
31
32 if (!isHydrated) return <p>Reading from local cache…</p>
33
34 if (!activePost) {
35 return (
36 <div>
37 <p>Post not foundthis slug was not in your local cache.</p>
38 <Link to="/blog">← Back to blog</Link>
39 </div>
40 )
41 }
42
43 return (
44 <article>
45 <h1>{activePost.title}</h1>
46 <p>{activePost.excerpt}</p>
47 </article>
48 )
49}
src/main.tsx (excerpt)
1// src/main.tsx (excerpt) — wiring router inside FluxClientWrapper
2import React from 'react'
3import ReactDOM from 'react-dom/client'
4import { createBrowserRouter, RouterProvider } from 'react-router-dom'
5import { FluxClientWrapper } from './components/FluxClientWrapper'
6import BlogPage from './pages/BlogPage'
7import BlogSlugPage from './pages/BlogSlugPage'
8import OfflinePage from './pages/OfflinePage'
9import App from './App'
10import './index.css'
11
12const router = createBrowserRouter([
13 { path: '/', element: <App /> },
14 { path: '/blog', element: <BlogPage /> },
15 { path: '/blog/:slug', element: <BlogSlugPage /> },
16 { path: '/offline', element: <OfflinePage /> },
17])
18
19ReactDOM.createRoot(document.getElementById('root')!).render(
20 <React.StrictMode>
21 <FluxClientWrapper>
22 <RouterProvider router={router} />
23 </FluxClientWrapper>
24 </React.StrictMode>
25)
14

Your offline fallback page

src/pages/OfflinePage.tsx
1// src/pages/OfflinePage.tsx
2export default function OfflinePage() {
3 return (
4 <div>
5 <h1>You're offline</h1>
6 <p>Previously visited pages are still available. Anything you submit will sync automatically once you reconnect.</p>
7 </div>
8 )
9}
Must be a real route in your router, and must be listed in both precacheRoutes and offlineShell in your fluxVitePlugin() config (Step 5).
15

Offline-ready static images (optional)

For static assets not managed through flux.register(), use CachedImg to store them in Flux's image IndexedDB cache.
src/components/Logo.tsx
1// src/components/Logo.tsx
2import { CachedImg } from '@tsworldtech/flux-react'
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. All you need is:

vite.config.ts
1// vite.config.ts — static offline precaching only
2import { defineConfig } from 'vite'
3import react from '@vitejs/plugin-react'
4import { fluxVitePlugin } from '@tsworldtech/flux-react/plugin'
5
6export default defineConfig({
7 plugins: [
8 react(),
9 fluxVitePlugin({
10 precacheRoutes: ['/', '/pricing', '/about', '/offline'],
11 offlineShell: '/offline',
12 storagePrefix: 'my-static-react-app',
13 bypassRoutePrefixes: ['/auth/callback', '/portal/auth/callback'],
14 }),
15 ],
16})

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 or a plain Vite build.