An installable app is a manifest plus a service worker. The offline guide covers the worker; this covers everything that makes the app feel like one the operating system knows about.
The manifest
A service worker makes an app survive a dead network. It does not make a browser offer to put it on a home screen — that needs a web app manifest, which is a file beside your routes:
import type { WebManifest } from '@rsc-kit/core/manifest-file'
export default {
name: 'Orders',
shortName: 'Orders',
themeColor: '#0b0b0c',
backgroundColor: '#ffffff',
icons: ['icon-192.png', 'icon-512.png'],
} satisfies WebManifestA file rather than a vite.config.ts key, and rather than metadata in a
layout. It is not build configuration — it is one more thing the app declares
about itself, so it lives where the app is, next to layout.tsx and
not-found.tsx.
Not metadata either, and for a reason worth stating: metadata is resolved
per route and can be computed per request. A manifest is one file for the whole
app and has to exist before anything renders. Putting it in an inheritable,
dynamic mechanism would invite a question — can I override it for this route?
— whose only honest answer is no.
The build reads it, writes manifest.webmanifest, and links it from every
page. There is no layout to edit — React hoists the <link> and the
theme-color meta into <head> from wherever they are rendered, so this works
on an app that already has its own root layout.
It is read at build time, before there is a module graph to evaluate it in, so it must be an object literal — not computed, not imported from elsewhere. A file that is not gets a build error rather than an app that is quietly not installable.
Icons are paths in your public/ directory, and their sizes are read from
the filename — icon-192.png and icon-192x192.png both mean 192. Declaring
the size in the config as well would be the same number written twice, and the
one that drifts is the one nobody looks at.
Every icon is marked any maskable, because without it Android crops a square
icon into a circle and takes the corners off whatever was in them.
The build tells you if it will not work
This is the failure worth guarding: a manifest with no icon is valid. It parses, it links, it sets the theme colour, and no browser ever offers to install it. Nothing is wrong and nothing works.
[rsc-kit] manifest: no icons, so no browser will offer to install this. Add a 192px and a 512px png.
[rsc-kit] manifest: Orders is installableA warning rather than a failed build — the manifest still does its other job —
but it is said out loud, because someone who wrote manifest: {…} meant
installable and would otherwise find out months later.
Your own worker code
Push, notification clicks and background sync are events the generated worker does not handle, and there is nowhere to put them in a file that says do not edit. So it will import yours:
// Plain javascript. The browser evaluates this in a worker scope with no build
// step in front of it, so what you write is what runs.
self.addEventListener('push', (event) => {
const payload = event.data ? event.data.json() : {}
event.waitUntil(
self.registration.showNotification(payload.title ?? 'Update', {
body: payload.body,
data: { url: payload.url ?? '/' },
}),
)
})
self.addEventListener('notificationclick', (event) => {
event.notification.close()
event.waitUntil(self.clients.openWindow(event.notification.data.url))
})The build copies it beside the generated worker and imports it first, so your listeners are registered before anything of ours can answer an event. It is precached like everything else — a worker whose import fails does not start, and then nothing is cached at all.
[rsc-kit] offline: 32 files precached as rsc-kit-3d9785e4, falling back to /offline, with app/sw.jsThere is no option for this. The file is there or it is not.
Push notifications
Four pieces, and only one of them is ours.
1. Keys
Push needs a VAPID key pair — the public half identifies your server to the browser, the private half signs what you send.
npx web-push generate-vapid-keysKeep the private key wherever you keep secrets. The public one reaches the browser, so it can be a plain environment variable.
2. Ask, and subscribe
'use client'
export function EnableNotifications() {
async function enable() {
if (await Notification.requestPermission() !== 'granted') return
const registration = await navigator.serviceWorker.ready
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: import.meta.env.VITE_VAPID_PUBLIC_KEY,
})
await saveSubscription(subscription.toJSON())
}
return <button onClick={enable}>Enable notifications</button>
}Ask when they have a reason to say yes. A permission prompt on first load is how an app gets denied permanently — the browser remembers a refusal, and there is no second chance.
3. Store the subscription
An ordinary server action, so it is one function and no endpoint:
'use server'
import { client } from './client'
export const saveSubscription = client.input(subscriptionSchema).handler(
async ({ input, ctx }) => db.pushSubscriptions.upsert(ctx.user.id, input),
)Store it against a user, not a session. A subscription outlives the session that created it, and that is the point of one.
4. Send
From wherever the event happens — a queue, a cron, a webhook:
import webpush from 'web-push'
webpush.setVapidDetails('mailto:you@example.com', PUBLIC_KEY, PRIVATE_KEY)
for (const subscription of await db.pushSubscriptions.forUser(userId)) {
try {
await webpush.sendNotification(subscription, JSON.stringify({
title: 'Your order shipped',
url: '/orders/42',
}))
} catch (error) {
// 404 and 410 mean the subscription is dead — the app was uninstalled, or
// the browser rotated it. Delete it rather than retrying forever.
if (error.statusCode === 404 || error.statusCode === 410) {
await db.pushSubscriptions.remove(subscription.endpoint)
}
}
}That last branch is the one people skip, and it is why push senders accumulate dead endpoints until they are mostly dead endpoints.
Background sync
For work that must happen even if the person closes the tab — a queued message, an offline edit.
self.addEventListener('sync', (event) => {
if (event.tag === 'outbox') event.waitUntil(flushOutbox())
})
async function flushOutbox() {
const db = await openDB()
for (const item of await db.getAll('outbox')) {
const response = await fetch('/api/messages', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(item.body),
})
// Left in the queue on failure, so the browser retries the whole sync
// later. Removed only once the server has it.
if (response.ok) await db.delete('outbox', item.id)
}
}Register it from the page after queueing the work:
const registration = await navigator.serviceWorker.ready
await registration.sync.register('outbox')Support is narrower than the rest of this page — Chromium has it, Safari and Firefox do not. Treat it as an optimisation over an ordinary retry, never as the only path:
if ('sync' in registration) await registration.sync.register('outbox')
else await flushNow()