---
title: "Keep pages for reading offline"
audience: "anyone whose visitors lose their signal"
description: "You'll make an app installable, keep every page a visitor opened for when the network goes and tell an open page when a new build is ready. It takes one…"
snippet_caption: "config/app.toml"
snippet: "[pwa]\nname = \"Trails\"\nshort_name = \"Trails\"\ntheme_color = \"#2f5d3a\"\nbackground_color = \"#f4f1ea\"\noffline = \"/offline\""
---

import { Install } from "@src/ui/Install";

You'll make an app installable, keep every page a visitor opened for when the network goes and tell an open page when a new build is ready. It takes one configuration section, one page and one island.

<Install captured="0.x" />

## Start the app

```shell-session
$ fsr new trails
$ fsr use trails/app react
```

The island at the end reads the store through React, so the app needs React.

Give it a few pages to keep. The trails live in a plain module:

```ts title="app/src/trails.ts"
export interface Trail {
  id: string;
  name: string;
  km: number;
  notes: string[];
}

export const trails: Trail[] = [
  { id: "ridge", name: "Long Ridge", km: 14.2, notes: ["The stile after the second gate is broken.", "No water between the car park and the beacon."] },
  { id: "marsh", name: "Saltmarsh Loop", km: 8.5, notes: ["The causeway floods two hours either side of high tide."] },
  { id: "beech", name: "Beech Hangers", km: 11.0, notes: ["Steep and slippery after rain on the descent."] },
];
```

```ts title="app/routes/page.loader.ts"
import { trails } from "@src/trails";

export async function load() {
  return { trails: trails.map((t) => ({ id: t.id, name: t.name, km: t.km })) };
}
```

```tsx title="app/routes/page.tsx"
import { Link } from "@snapfire/fsr/components";
import type { RootProps } from "@generated/client";

export default function Index({ trails }: RootProps) {
  return (
    <ul>
      {trails.map((t) => (
        <li key={t.id}>
          <Link href={`/trail/${t.id}`}>{t.name}</Link> {t.km} km
        </li>
      ))}
    </ul>
  );
}
```

```ts title="app/routes/trail/[id]/page.loader.ts"
import type { Ctx } from "@snapfire/fsr/host";
import { fail } from "@snapfire/fsr/host";
import { trails } from "@src/trails";

export async function load({ params }: Ctx<"/trail/{id}">) {
  const trail = trails.find((t) => t.id === params.id);
  if (trail === undefined) fail("not_found", "there is no trail with that id");
  return { trail };
}
```

```tsx title="app/routes/trail/[id]/page.tsx"
import type { TrailIdProps } from "@generated/client";

export default function Trail({ trail }: TrailIdProps) {
  return (
    <article>
      <h1>{trail.name}</h1>
      <ul>
        {trail.notes.map((note) => (
          <li key={note}>{note}</li>
        ))}
      </ul>
    </article>
  );
}
```

## Add the [pwa] section

```toml title="config/app.toml"
[pwa]
name = "Trails"
short_name = "Trails"
theme_color = "#2f5d3a"
background_color = "#f4f1ea"
offline = "/offline"
```

The host now answers `/manifest.webmanifest` from those fields. `start_url` defaults to `/` and `display` to `standalone`. Each PNG, WebP or SVG in `app/icons/` whose name carries its size becomes an icon, so put two there:

```
app/icons/android-chrome-192x192.png
app/icons/android-chrome-512x512.png
```

Every page now links the manifest and carries the theme colour. Outside development the head also names the worker:

```html
<link rel="manifest" href="/manifest.webmanifest"><meta name="theme-color" content="#2f5d3a"><meta name="sf:worker" content="/sw.js">
```

`boot()` in `app/src/main.ts` registers it. You write no worker code.

## Write the offline page

`offline` names a route. The worker fetches it at install and shows it for any page it never kept:

```tsx title="app/routes/offline/page.tsx"
import { Link } from "@snapfire/fsr/components";

export default function Offline() {
  return (
    <article>
      <h1>No signal</h1>
      <p>You never opened this page with a connection, so there is no copy of it here.</p>
      <Link href="/">The trails you opened</Link> are still readable.
    </article>
  );
}
```

## Show the connection and the update

The client keeps two store keys current. `online` follows the browser's `online` and `offline` events. `updateReady` turns true once a newer build's worker has taken over the page. An island reads them like any other key:

```tsx title="app/src/ui/Status.tsx"
import { online, updateReady } from "@snapfire/fsr/client";
import { useStore } from "@snapfire/fsr/client/react";
import { Link } from "@snapfire/fsr/components";

export default function Status() {
  const [connected] = useStore(online, true);
  const [ready] = useStore(updateReady, false);
  return (
    <div className="status">
      {connected ? null : <p className="offline">Offline: you are reading saved pages.</p>}
      {ready ? (
        <p className="update">
          A new version is ready. <Link href="/" full>Open it</Link>
        </p>
      ) : null}
    </div>
  );
}
```

`full` makes following the link a full document load, so that load runs the new build. Place the island in the layout so every page has it:

```tsx title="app/routes/layout.tsx"
import type { ReactNode } from "react";
import { Link } from "@snapfire/fsr/components";
import Status from "@src/ui/Status";

export default function Layout({ children }: { children: ReactNode }) {
  return (
    <div className="shell">
      <header className="bar">
        <Link href="/" className="brand">
          Trails
        </Link>
        <Status />
      </header>
      <main className="content">{children}</main>
    </div>
  );
}
```

## Run the production shape

`fsr dev` links the manifest and registers no worker, so a rebuild is never hidden behind a cache:

```shell-session
$ curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:3000/sw.js
404
```

To see the worker, build and serve with `RELEASE_ENV=production`. That reads `config/production.toml`, which turns `server.dev` off and asks for a session key:

```shell-session
$ RELEASE_ENV=production fsr build trails/app
$ RELEASE_ENV=production C5_SESSION__KEY=$(openssl rand -hex 32) fsr serve trails/app
```

Check that the worker is there now:

```shell-session
$ curl -sI http://127.0.0.1:3000/sw.js | head -3
HTTP/1.1 200 OK
content-type: text/javascript; charset=utf-8
cache-control: no-cache
```

The worker at `/sw.js` is written at boot from what the host serves:

| Request | Answered |
| --- | --- |
| A module or stylesheet of the build | From the build's own cache, filled at install with every hashed name |
| The client, the vendor modules, the manifest and `offline` | Fetched at install and kept |
| Any other GET on this origin | From the network. A response that is not `private` or `no-store` is kept. When the network fails the kept copy answers. A page never kept shows `offline` |
| `/_sf/`, `/__fsr/`, a POST | Never touched |

A visitor holding a session gets `private` documents, so nothing of theirs is kept.

## Read it offline

Open `/` and wait a moment for the worker to install. Then click through to Long Ridge. Stop the server, switch the browser offline in its developer tools and load each page in full:

- `/trail/ridge` reads as it did, with "Offline: you are reading saved pages." in the header.
- `/trail/marsh`, which you never opened, shows "No signal".
- `/` shows "No signal" too. You opened it before the worker was running, so the worker never saw it. A page counts once the worker is in control, which is from the second page on.

The header line follows the browser's own `online` and `offline` events. A stopped server alone doesn't change it.

## Ship a new build

Start the server again and change something the browser loads, such as the stylesheet:

```css title="app/styles/app.css"
.status .offline,
.status .update {
  margin: 8px 0 0;
  font-size: 14px;
}
```

Build again, restart `fsr serve` and load a page. The browser finds a new `/sw.js`, which installs the new build's cache beside the old one. The new worker takes over, `updateReady` turns true and the header says "A new version is ready. Open it". Follow the link and the banner is gone.

A client navigation does not check for a new worker. Clicking between trails after the deploy shows no banner until a page is loaded in full.

A change that only reaches the server, such as a loader's data, leaves every module's hash the same. The worker is the same file, so no update is announced.

Next up: [400. Test a loader](https://snapfirers.com/fsr/learn/400-test-a-loader).
