<- All posts

Usero Journal

React Router 7 PWA: manifest, service worker, and the .data trap

Will Smith··10 min read

The day after we shipped PWA support to usero.io, I curled the offline fallback page on prod and got a 307.

Cloudflare’s static asset layer was redirecting /offline.html to /offline, the service worker had precached the redirected response, and the browser rejects redirected responses served to navigation requests. So the offline page would have failed exactly when a user was offline. Every local build was green. Only prod could show it.

That bug is at the end of this post. 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 ServiceWorkerGlobalScopeLike

One 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'
	/** Top-level document navigations. Network only, offline page on failure. */
	| 'network-with-offline-fallback'
	/** Everything else (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/']

// NOTE: extensionless '/offline', not '/offline.html'. See the prod bug below.
export const PRECACHED_PATHS = ['/offline', '/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'

	// React Router 7 single-fetch loader requests hit `<path>.data`. Match the
	// suffix explicitly: it is the reliable signal, and regardless of the
	// request mode, loader data must never be cached.
	if (url.pathname.endsWith('.data')) return 'passthrough'

	if (requestMode === 'navigate') return 'network-with-offline-fallback'

	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.
const STATIC_CACHE = 'usero-static-v1'
const OFFLINE_URL = '/offline'

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
}

async function networkWithOfflineFallback(request: Request): Promise<Response> {
	try {
		return await fetch(request)
	} catch {
		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, headers: { 'Content-Type': 'text/plain' } })
	}
}

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))
	} else if (decision === 'network-with-offline-fallback') {
		event.respondWith(networkWithOfflineFallback(request))
	}
	// 'passthrough': do not call respondWith, the browser handles it natively.
})

That is the entire worker. Cache-first for /assets/ (Vite’s content-hashed output) and /fonts/, network-only for document navigations with a tiny offline fallback, hands off everything else.

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/) plus two precached files for the offline page.

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-cache

The bug only prod could catch: Cloudflare 307s /offline.html

Back to the opening. We shipped, deploys went green, and the post-deploy curl turned this up:

$ curl -sI https://usero.io/offline.html
HTTP/2 307
location: /offline

Cloudflare Workers’ static asset layer has an html_handling behavior that strips .html extensions and redirects to the extensionless path. Our install step ran cache.addAll(['/offline.html']). addAll follows redirects and stores a response with redirected: true. When a navigation later fails and the worker serves that cached response, the browser rejects it: navigation requests have redirect mode manual, and a redirected response served to one is treated as a network error.

So the offline fallback would have failed in exactly one situation, the user being offline, which is the one situation it exists for. And nothing local could catch it: npm run build plus a local preview serves /offline.html fine, because the redirect lives in Cloudflare’s asset serving, not in the build output. The fix was to precache and serve the extensionless /offline everywhere, with a comment in PRECACHED_PATHS explaining why, fixed in a follow-up commit the same day.

The lesson generalizes: after you deploy a service worker, curl every URL it precaches against the real host and confirm each one returns a plain 200. A redirect in that list is a delayed failure with no error until the network drops.

What we deliberately punted

v1 is installable and offline-tolerant, and it cannot serve stale data. We skipped, for now:

  • Offline queueing of form submissions via Background Sync (the obvious v2; users on flaky mobile connections lose uploads today)
  • 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?

Content-hashed build assets (/assets/, fonts) cache-first, plus a precached offline page. Documents network-only with the offline fallback.

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.

Anything host-specific to watch?

On Cloudflare Workers static assets, .html paths 307-redirect to extensionless ones, and a redirected precached response fails when served to a navigation request. Precache extensionless paths, serve /sw.js with Cache-Control: no-cache, and curl every precached URL on prod after deploying.

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.

Build a feedback loop your team actually uses

Usero collects, clusters, and turns user feedback into shipped fixes.

Get started free