Getting started
PWA
Make a SvelteKit app installable with a themed web app manifest, iOS home screen tags and a service worker that keeps navigations on the network and falls back to an offline page.
A progressive web app needs three pieces: a manifest (name, icons, colors), the iOS tags that Safari still reads instead of the manifest, and a service worker. @codeatlasdev/ui/seo generates the first two. The service worker stays in your app on purpose: its cache rules depend on your routes and release flow.
Icons come from one mark
caBrandPlugin renders the 192, 512 and maskable icons, the 180 apple-touch-icon and a ready manifest.<hash>.webmanifest from your mark SVG and theme. See Brand assets.
Manifest
manifest() returns the manifest object. theme_color and background_color come from the same theme definition you give caThemeHandle: they are the surface color of the default preset in the default scheme (the dark one unless defaultScheme is "light"), the value the init script puts in <meta name="theme-color">. The splash screen and the browser chrome therefore match the first paint.
// src/routes/manifest.webmanifest/+server.ts
import { manifest } from "@codeatlasdev/ui/seo";
import { theme } from "$lib/theme"; // SvelteKit 3: "#lib/theme.ts"
export const prerender = true;
export const GET = () =>
new Response(
JSON.stringify(
manifest({
name: "Acme Seller",
shortName: "Seller",
description: "Orders and customers on the go.",
theme,
icons: [
{ src: "/icons/192.png", sizes: "192x192" },
{ src: "/icons/512.png", sizes: "512x512" },
{ src: "/icons/maskable-512.png", sizes: "512x512", purpose: "maskable" },
],
shortcuts: [{ name: "New order", url: "/orders/new" }],
}),
),
{ headers: { "content-type": "application/manifest+json" } },
);
Defaults are display: "standalone", start_url: "/", scope: "/" and short_name equal to name. Each icon gets type: "image/png" unless you set one. Pass themeColor or backgroundColor (as #rrggbb) to override the derived colors, and extra for members the builder does not cover (categories, screenshots, id).
Provide a maskable icon
Android crops other icons into a white tile. Keep the artwork of the maskable icon inside the central 80% of the canvas. manifest() logs a warning when no icon has purpose: "maskable".
Link it from the page head with <Seo manifest="/manifest.webmanifest" ... />.
iOS home screen
Safari ignores most manifest fields. appleMeta() returns the tags it does read, as an HTML string:
<script>
import { appleMeta } from "@codeatlasdev/ui/seo";
</script>
<svelte:head>
{@html appleMeta({ touchIcon: "/icons/apple-touch-180.png", title: "Seller" })}
</svelte:head>
| Tag | Effect |
|---|---|
mobile-web-app-capable and apple-mobile-web-app-capable |
Opens without browser chrome. |
apple-mobile-web-app-status-bar-style |
black-translucent (default), default or black. The default draws the page under the status bar: it needs viewport-fit=cover; pad with var(--ca-safe-top). |
apple-mobile-web-app-title |
The label under the icon, emitted when you pass title. |
apple-touch-icon |
180x180 PNG, from touchIcon. Use an opaque image: iOS fills transparency with black. |
Service worker
SvelteKit builds the worker when src/service-worker.ts (Kit 2) or src/service-worker/index.ts (Kit 3) exists and registers it for you. bunx @codeatlasdev/ui init --write creates the right one for your Kit major, plus the prerendered /offline page. The build lists give the hashed build files, static files and the app version, so the cache never serves stale assets after a deploy.
Kit 3 removed $service-worker: self comes from $app/service-worker, the file lists from $app/manifest (immutable, assets) and version from $app/env. $app/forms, $app/navigation and $app/state cannot be imported into a worker (the build fails with service_worker_invalid_import), and tsconfig.json must list src/service-worker in exclude (the worker has its own $app/tsconfig/service-worker).
The pattern below (Kit 2 imports; the rules are the same on Kit 3) mirrors what works in production apps:
| Request | Strategy | Why |
|---|---|---|
Build files (/_app/immutable/*) and static assets |
Cache first | Content-hashed, safe forever. |
| Page navigations | Network first, then the cached offline page | Users always see fresh data; offline shows a real page instead of the browser error. |
| Everything else (API, auth, form actions) | Untouched | Never cache authenticated data. |
// src/service-worker.ts
/// <reference types="@sveltejs/kit" />
/// <reference no-default-lib="true"/>
/// <reference lib="esnext" />
/// <reference lib="webworker" />
import { build, files, version } from "$service-worker";
const sw = self as unknown as ServiceWorkerGlobalScope;
const CACHE = `app-${version}`;
const ASSETS = [...build, ...files, "/offline"];
sw.addEventListener("install", (event) => {
event.waitUntil(
caches
.open(CACHE)
.then((cache) => cache.addAll(ASSETS))
.then(() => sw.skipWaiting()),
);
});
sw.addEventListener("activate", (event) => {
event.waitUntil(
caches
.keys()
.then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))))
.then(() => sw.clients.claim()),
);
});
sw.addEventListener("fetch", (event) => {
const { request } = event;
if (request.method !== "GET") return;
const url = new URL(request.url);
if (url.origin !== sw.location.origin) return;
// Network first for pages, offline page when the network fails.
if (request.mode === "navigate") {
event.respondWith(
fetch(request).catch(async () => (await caches.match("/offline")) ?? Response.error()),
);
return;
}
// Cache first for hashed build output and static files.
if (ASSETS.includes(url.pathname)) {
event.respondWith(caches.match(request).then((hit) => hit ?? fetch(request)));
}
});
Create src/routes/offline/+page.svelte with a short message and a retry button, and set export const prerender = true; in a sibling +page.ts so it exists as static HTML when the worker installs. Using version in the cache name replaces old caches on every release.
Test it like a user
Service workers only run on HTTPS or localhost, and only in a production build: run bun run build && bun run preview, open DevTools, Application, Service Workers, then tick Offline and reload.
Install prompt
Browsers only offer to install an app that meets the criteria: the manifest above (display: "standalone", 192 and 512 icons) and a service worker. InstallPrompt turns that into UI: put it once in the root layout and it shows the native install dialog on Chromium, the Share, Add to Home Screen steps on iOS, and nothing once the app is installed or the user dismissed it.
Checklist
| Check | Where |
|---|---|
| Manifest linked and valid in DevTools, Application, Manifest | Seo manifest |
| Maskable icon and 512px icon present | manifest() |
viewport-fit=cover in the viewport meta |
app.html |
| iOS tags present | appleMeta() |
| Offline page prerendered and cached on install | src/service-worker.ts |
| No authenticated responses cached | fetch handler |
| An install entry point | InstallPrompt in the root layout |