Usero Journal
React Router 7 PWA: manifest, service worker, and the .data trap
We shipped a service worker that told online users they were offline. We ripped it out the same day.
The worker intercepted every page navigation and served an offline page whenever the fetch() threw. A fetch throws when the user is offline. It also throws on a Cloudflare worker cold-start, a 5xx, a dropped connection, or a navigation aborted mid-transition. So users sitting on solid wifi hit a transient blip and got a full-screen “You’re offline” page. That section is the most useful thing here, and it is at the end.
The rest is the whole recipe: how we added PWA support (web app manifest plus service worker) to a React Router 7 framework-mode app on Cloudflare Workers, with all the code inline. If you searched “react router 7 pwa” and found a GitHub issue with no answers, this is the missing writeup.
vite-plugin-pwa does not support React Router 7
The standard answer for a Vite app is vite-plugin-pwa. It does not support React Router 7 framework mode. The feature request, vite-pwa/vite-plugin-pwa#809 (“PWA Support for React Router 7”), has been open since December 2024 with no recipe and no workaround in the thread. That issue is probably why you are reading a blog post instead of plugin docs.
We hand-rolled it instead, and it turned out to be the right call regardless. Workbox-style precache manifests were a poor fit for our caching policy anyway (more on that policy below).
Quick context on the app, because it drives the design: Usero is a feedback dashboard. Founders open it to see live feedback, session replays, and AI analysis. Stale data is worse than no data. That single fact decides most of what follows.
The manifest
The boring part first. A static file at public/manifest.webmanifest:
{
"id": "/",
"name": "Usero",
"short_name": "Usero",
"description": "Usero turns user feedback into shipped code.",
"start_url": "/",
"display": "standalone",
"background_color": "#0b0d14",
"theme_color": "#0b0d14",
"icons": [
{
"src": "/icons/web-app-manifest-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icons/web-app-manifest-512x512.png",
"sizes": "512x512",
"type": "image/png"
},
{
"src": "/icons/web-app-manifest-maskable-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}Link it from your root route’s links export, and add a matching <meta name='theme-color' content='#0b0d14'> to the head.
Nothing React Router specific here, but two gotchas bit us:
Your CSS colors are probably oklch, manifests want hex. Our background is --app-bg: oklch(0.16 0.015 270). You cannot eyeball the conversion; the install splash screen and the browser theme bar sit directly next to your real app background, and a mismatch is visible. Convert properly (OKLab to linear sRGB to gamma-corrected sRGB). For us that came out to #0b0d14.
Check whether your maskable icon survives the mask. Ours was marked purpose: maskable but was a squircle with transparent corners. Android applies a circular mask and would have cropped the edges. A real maskable icon keeps everything important inside the central 80% (safe zone radius 40%). We generated one with a sharp one-liner: logo at 70% scale, centered on a solid #0b0d14 512px square.
We also deleted something. The repo previously served its manifest from a React Router resource route (app/routes/resources.manifest[.]json.ts). A static file in public/ is better on every axis: no Worker invocation per fetch, and both Cloudflare’s asset layer and Vite dev serve .webmanifest with the correct application/manifest+json content type automatically. If your manifest is static data, do not put it in a route.
The service worker: a second entry point React Router 7 doesn’t give you
Here is the first real React Router 7 problem. A service worker must be a standalone script, served from your origin at a path whose scope covers the app (so /sw.js, served at the root). It cannot be part of the client bundle. And RR7’s Vite build has no slot for a second entry point.
The fix is one npm script. esbuild is already in node_modules/.bin because Vite depends on it, so this adds zero dependencies:
{
"scripts": {
"build": "react-router build && npm run build:sw",
"build:sw": "esbuild app/pwa/sw.ts --bundle --format=iife --target=es2020 --outfile=build/client/sw.js"
}
}The output lands in build/client/, which is the directory Cloudflare Workers (or any static host) already serves as assets. So /sw.js comes back with a JavaScript content type, at scope /, with no Service-Worker-Allowed header games. You write the worker in TypeScript, it can import shared modules, and it never touches the app bundle.
Typing a service worker without forking your tsconfig
Second problem: your tsconfig compiles app/** with lib: ["DOM", ...]. Service worker globals live in lib.webworker, and the two libs conflict (both declare self, fetch, and friends with different types). The usual advice is a separate tsconfig project for the one file. We did not want a third tsconfig, so we declared minimal structural interfaces for exactly the surface we use:
interface ExtendableEventLike extends Event {
waitUntil(promise: Promise<unknown>): void
}
interface FetchEventLike extends ExtendableEventLike {
readonly request: Request
respondWith(response: Promise<Response> | Response): void
}
interface ServiceWorkerGlobalScopeLike {
addEventListener(type: 'install' | 'activate', listener: (event: ExtendableEventLike) => void): void
addEventListener(type: 'fetch', listener: (event: FetchEventLike) => void): void
skipWaiting(): Promise<void>
clients: { claim(): Promise<void> }
location: { origin: string }
caches: CacheStorage
}
const sw = self as unknown as ServiceWorkerGlobalScopeLikeOne cast, no any, no tsconfig surgery. The DOM lib already provides Request, Response, CacheStorage, and Cache, which are identical in workers.
The routing logic, extracted and unit tested
We pulled the fetch-routing decision into a pure function so it can run under vitest (the worker itself only runs inside a ServiceWorkerGlobalScope). This is app/pwa/cacheStrategy.ts, complete:
export type FetchDecision =
/** Content-hashed/immutable static files. Safe to cache forever. */
| 'cache-first'
/** Everything else (documents/navigations, loaders via *.data, API routes,
* third parties). The SW does not call respondWith, so the browser handles
* it untouched. */
| 'passthrough'
const CACHE_FIRST_PREFIXES = ['/assets/', '/fonts/']
export const PRECACHED_PATHS = ['/icons/web-app-manifest-192x192.png']
export function decideFetchStrategy(url: URL, _requestMode: RequestMode, swOrigin: string): FetchDecision {
// Never touch cross-origin requests (analytics, CDNs, the widget API, etc.)
if (url.origin !== swOrigin) return 'passthrough'
if (CACHE_FIRST_PREFIXES.some(prefix => url.pathname.startsWith(prefix))) {
return 'cache-first'
}
if (PRECACHED_PATHS.includes(url.pathname)) return 'cache-first'
// Everything else, including document navigations, is passthrough. The SW
// must never respond to a navigation, so an online user can never be shown
// a service-worker-served offline page. The browser handles documents itself.
return 'passthrough'
}And the worker itself (app/pwa/sw.ts), minus the type declarations shown above:
import { decideFetchStrategy, PRECACHED_PATHS } from './cacheStrategy'
// Bump the suffix to invalidate everything cached by previous SW versions. v2
// purges the v1 caches (which held the now-removed offline fallback page).
const STATIC_CACHE = 'usero-static-v2'
sw.addEventListener('install', event => {
event.waitUntil(
(async () => {
const cache = await sw.caches.open(STATIC_CACHE)
await cache.addAll(PRECACHED_PATHS)
await sw.skipWaiting()
})(),
)
})
sw.addEventListener('activate', event => {
event.waitUntil(
(async () => {
const names = await sw.caches.keys()
await Promise.all(names.filter(name => name !== STATIC_CACHE).map(name => sw.caches.delete(name)))
await sw.clients.claim()
})(),
)
})
async function cacheFirst(request: Request): Promise<Response> {
const cache = await sw.caches.open(STATIC_CACHE)
const cached = await cache.match(request)
if (cached) return cached
const response = await fetch(request)
// Only cache real successes. An error response cached forever under an
// immutable URL would be unrecoverable without a SW version bump.
if (response.ok) {
await cache.put(request, response.clone())
}
return response
}
sw.addEventListener('fetch', event => {
const { request } = event
// Mutations (actions) must always hit the network untouched.
if (request.method !== 'GET') return
const url = new URL(request.url)
const decision = decideFetchStrategy(url, request.mode, sw.location.origin)
if (decision === 'cache-first') {
event.respondWith(cacheFirst(request))
}
// 'passthrough' (everything else, including navigations): do not call
// respondWith, the browser handles it natively. There is no other branch,
// so the SW can never serve a response to a document/navigation request.
})That is the entire worker. Cache-first for /assets/ (Vite’s content-hashed output), /fonts/, and the precached icon. Everything else, navigations included, is passthrough: the worker never calls respondWith, so the browser handles it. The worker did not always look like this. The section on the offline-page bug below is why it does now.
Why we never cache loaders or documents (read this before copying a generic PWA recipe)
This is the section that is specific to React Router 7, and it is the thing generic PWA tutorials get wrong.
React Router 7’s single fetch sends loader data over the wire as <path>.data requests. Navigate to /dashboard client-side and the browser fetches /dashboard.data. Submit an action and React Router revalidates by refetching the .data URLs for every matched route. That revalidation is the mechanism that keeps your UI consistent after mutations.
Now apply a stock PWA recipe: “stale-while-revalidate for same-origin GET requests” or “network-first with cache fallback for API calls.” Both will cache .data responses. Here is the failure: a user archives a feedback item, the action succeeds, React Router revalidates, and the service worker serves the pre-mutation loader response from cache. The item the user just archived is back on screen. No error anywhere. The server is right, the cache is wrong, and the user concludes your app is broken.
Documents have the same problem in framework mode, because the initial HTML embeds loader data.
For Usero the decision was easy: the dashboard is live data, staleness is unacceptable, so loaders, API routes, and documents are never cached. The only things the worker caches are immutable by construction: content-hashed /assets/, versioned /fonts/, and one precached PWA icon.
Note the defensive line in decideFetchStrategy: we match on the .data suffix explicitly because that is the reliable signal. Regardless of the request mode, loader data must never be cached. We pinned .endsWith('.data') with a unit test so nobody “optimizes” it into a cache later. If you take one line of code from this post, take that one.
A side benefit: this policy is what makes skipWaiting + clients.claim safe. That update strategy is normally risky, since a new worker takes over pages built against old assets. But because we never cache documents or loader data, an old tab keeps requesting its old content-hashed /assets/* URLs, and the new worker serves or fetches them fine (Cloudflare keeps old assets around across deploys). If we ever precached the asset manifest workbox-style, we would have to rethink updates too.
Registering the service worker (production only)
app/hooks/useRegisterServiceWorker.ts, called from root.tsx:
import { useEffect } from 'react'
export function useRegisterServiceWorker() {
useEffect(() => {
if (!import.meta.env.PROD) return
if (!('serviceWorker' in navigator)) return
navigator.serviceWorker.register('/sw.js').catch((error: unknown) => {
console.error('Service worker registration failed', error)
})
}, [])
}The PROD gate matters. A service worker intercepting react-router dev requests breaks HMR in confusing ways, with stale modules served from cache long after you edited them.
While verifying the gate, I curled /sw.js against the dev server and got a 302 to /sw.js/no-env/dashboard. The file does not exist in dev (only build:sw emits it), so the request fell through to our app’s $clientId catch-all route, which happily treated sw.js as a client id and redirected to its dashboard. Harmless here, twice over: registration is PROD-gated, and the browser would reject the registration anyway (a redirect, and an HTML MIME type). But it is a good illustration of how RR7 catch-all param routes swallow static-looking paths in dev. If your app has a top-level $param route, do not expect a 404 to tell you a static file is missing.
One more deploy detail: serve /sw.js with Cache-Control: no-cache. Browsers cap service worker script caching at 24 hours, but that is still a day of stale fetch logic after a deploy. With no-cache, the browser’s update check hits origin every cycle. On Cloudflare, that is two lines in public/_headers:
/sw.js
Cache-Control: no-cacheThe offline page that broke for online users
The version of the worker you read above is the corrected one. The first version we shipped had an offline page. It intercepted document navigations, tried the network, and on any thrown fetch() served a precached “You’re offline” page:
// The version we shipped, and then killed.
async function networkWithOfflineFallback(request: Request): Promise<Response> {
try {
return await fetch(request)
} catch {
// WRONG: a thrown fetch does not mean the user is offline.
const cache = await sw.caches.open(STATIC_CACHE)
const offline = await cache.match(OFFLINE_URL)
if (offline) return offline
return new Response('Offline', { status: 503 })
}
}Read the catch block again. It treats every rejected fetch() as proof the user is offline. There is no navigator.onLine check, no status inspection, nothing. A navigation fetch() rejects for reasons that have nothing to do with the user’s connection:
- A Cloudflare Worker cold-start that takes long enough for the request to error.
- A transient 5xx or a dropped connection on an otherwise healthy origin.
- A navigation the browser aborts because a fast client-side transition started a new one.
All three fire while the user is on a perfectly good connection. So online users who hit one momentary blip got the full-screen offline page, on a live dashboard, with their real data one reload away. We saw it within hours of the deploy.
Killing it was its own lesson. Deleting networkWithOfflineFallback and stopping registration was not enough: every browser that had already installed the worker kept running the cached copy, offline page and all. To clear it for users who already had it, we shipped a hook that unregistered live workers and deleted their caches:
// Emergency build: stop registering AND tear down the live SW + its caches.
export function useRegisterServiceWorker() {
useEffect(() => {
if (!('serviceWorker' in navigator)) return
navigator.serviceWorker.getRegistrations().then(regs => {
for (const reg of regs) reg.unregister()
})
if ('caches' in window) {
caches.keys().then(keys => keys.forEach(key => caches.delete(key)))
}
}, [])
}Once that had propagated, we replaced it with the passthrough worker at the top of this post. The corrected design does not guard the offline fallback better. It removes navigation interception entirely. The worker only does cache-first for /assets/, /fonts/, and the precached icon; documents pass straight through to the network and are never touched. If the worker never handles a navigation, it can never tell a user they are offline. We bumped the cache name to usero-static-v2 so the activate handler purges the v1 caches that still held the offline page.
This is the same lesson as the .data rule earlier: do not let the worker get clever about documents or loader data. Let the browser handle the network, and keep the worker to assets that cannot go stale.
The mistake was not attempting offline. It was two specific things.
It would be easy to read this as “offline support is too risky, skip it.” That is the wrong takeaway. The bug was two decisions, and both are avoidable:
- Conflating one failed request with “the user is offline.” A single rejected
fetch()is one rejected fetch. A cold-start, a 5xx, an aborted navigation all reject too. You need a confirming signal before you believe the user lost their connection. - A full-screen takeover. Even when the user really is offline, replacing the dashboard with a “You’re offline” page throws away the feedback item, the replay, the cluster they were reading. That data was already in the browser. The takeover hid it for no reason.
So here is the model we landed on for offline on a live, server-rendered app. Offline is a status the interface wears, not a wall it puts up. Detect it with navigator.onLine plus a lightweight reachability heartbeat (a tiny HEAD /ping that touches no database), and only flip the UI when both agree, never on one failed fetch. When you are sure, show a slim non-blocking banner under the nav (“Offline, showing data from 3 min ago”) that pushes content down instead of covering it. Keep the cached data readable and mark it clearly stale with one timestamp anchor, not a warning stamped on every card. Queue writes to a local outbox, apply them optimistically, flush on reconnect, and surface any conflict honestly rather than silently overwriting. For our widget the payoff is the whole promise: write the user’s submission to a local outbox first, so a dropped network turns “your report failed” into “saved, we’ll send it when you’re back.”
To be clear about status: v1 ships the service worker without any of that. It is assets plus installability, and it keeps the worker out of navigations on purpose. The graceful offline layer above is designed and is the next iteration, not something live today. The naive version earned us the rule. The rule is the thing worth keeping.
Offline is a status the interface wears, not a wall it puts up, and you only wear it when you’re sure.
What we deliberately punted
The worker is installable and it cannot serve stale data. We skipped, for now:
- The graceful offline layer (connectivity heartbeat, stale-data banner, queued writes) covered in the section above. v1 stays out of navigations; that layer is the next iteration.
- Runtime caching of icons and images
- A custom install-prompt UI
- Push notifications
Each of those is real work with real failure modes. None of them is required to be a PWA.
FAQ
Can a React Router 7 app be a PWA?
Yes. Framework mode needs no special support; you add a manifest, a service worker, and a registration hook like any Vite app. The only missing piece is tooling.
Does vite-plugin-pwa work with React Router 7?
Not in framework mode, as of June 2026. Issue #809 is an open feature request with no recipe. Hand-rolling is about 100 lines.
How do you build a service worker in a React Router 7 project?
A separate esbuild entry point: esbuild app/pwa/sw.ts --bundle --format=iife --target=es2020 --outfile=build/client/sw.js, chained after react-router build. esbuild ships with Vite, so it costs no new dependency, and the output is served as a static asset at scope /.
What should the service worker cache?
Only immutable build output, cache-first: content-hashed /assets/, versioned /fonts/, and the precached PWA icon. Documents and navigations pass straight through to the network. The SW does not call respondWith on them, so the browser handles every page load natively.
What must it never cache?
.data requests (React Router 7's single-fetch loader URLs), API routes, and documents whose HTML embeds loader data. Caching .data breaks post-action revalidation and serves users pre-mutation state with no error. Check url.pathname.endsWith('.data') explicitly and return passthrough.
Should the service worker serve a full-screen offline page?
No, and v1 keeps the SW out of navigations on purpose. We shipped a takeover once: it served an offline page whenever the fetch threw, so online users hit a false "You're offline" screen on every cold-start, 5xx, or aborted navigation. The right way to do offline is a status the UI wears, not a wall. Detect it with navigator.onLine plus a reachability heartbeat (never one failed fetch), show a slim non-blocking banner, keep cached data readable and marked stale, and queue writes to flush on reconnect. That graceful layer is designed and is the next iteration; v1 ships assets plus installability only.
How do you remove a broken service worker that is already live?
Stopping registration is not enough; every browser that already installed the SW keeps running the cached copy. You have to ship a registration hook that unregisters existing SWs and clears their caches, and bump the cache name so the activate handler purges the old caches. Deleting the SW file alone leaves the broken worker live for everyone who cached it.
We built this for Usero, our feedback dashboard. If your app shows live data, steal the caching policy along with the code: the fastest way to ruin a dashboard is to cache it.
Continue reading
Early Founders Don't Have a Funding Problem. They Have a Feedback Problem.
Before product-market fit, investors are not reading your numbers, they are looking for proof users care. Here is what that evidence looks like, how to gather it with a dozen users, and why a fast feedback-to-ship loop produces all of it.
8 min read
Best Idea Management Software (2026): 9 Tools Compared
An honest comparison of idea management software and platforms for 2026. Product idea boards (Canny, Productboard, Featurebase, Frill, Usero) vs enterprise innovation suites (IdeaScale, Brightidea), with real pricing and who each one fits.
11 min read
Voice of Customer Tools: 10 Picks for 2026 (Honest Guide)
What voice of customer means, how to start a VoC program for free, and 10 voice of customer tools compared honestly: Qualtrics, Medallia, Verint, Hotjar, Qualaroo, SurveyMonkey, Canny, Productboard, Featurebase, and Usero.
12 min read
Build a feedback loop your team actually uses
Usero collects, clusters, and turns user feedback into shipped fixes.
Get started free