> ## Documentation Index
> Fetch the complete documentation index at: https://docs.pylonsync.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Offline apps

> Make an SSR app installable on a phone's home screen and able to open with no network. Pages read from the local replica; writes wait in a queue until the network returns.

Pylon keeps synced rows in IndexedDB and queues writes made with no network.
With `offline` set in the manifest, the app also opens with no network: Pylon
serves a web app manifest, a service worker, and the pages to keep on the
device.

## Turn it on

Add `offline` to `buildManifest`:

```ts theme={null}
// app.ts
export default buildManifest({
  name: "field-sales",
  version: "1.0.0",
  entities: [Lead],
  routes: await discoverAppRoutes(),
  offline: {
    name: "Veritas Field Sales",
    shortName: "Field Sales",
    icons: [
      { src: "/icons/192.png", sizes: "192x192", type: "image/png" },
      { src: "/icons/512.png", sizes: "512x512", type: "image/png" },
    ],
    themeColor: "#0b2545",
    backgroundColor: "#ffffff",
    startUrl: "/leads",
    routes: ["/leads", "/leads/[id]", "/map"],
  },
});
```

| Field | Default | What it does |
| - | - | - |
| `name` | (required) | The app name on the install prompt and the splash screen. |
| `shortName` | `name` | The name under the home-screen icon. |
| `icons` | none | Home-screen icons. Put the files in `public/`. Give a 192x192 and a 512x512 PNG. |
| `themeColor` | none | The color of the browser UI around the app. |
| `backgroundColor` | none | The splash screen color. |
| `startUrl` | `"/"` | The page the installed app opens on. |
| `display` | `"standalone"` | `"standalone"`, `"fullscreen"`, `"minimal-ui"`, or `"browser"`. |
| `routes` | the start URL | The route patterns that open with no network. The start URL is always one of them. |

Pylon then does these things:

* It serves `/manifest.webmanifest` from this config. A `public/manifest.webmanifest` or `app/manifest.webmanifest` file replaces it.
* It adds the manifest link, the theme color, and the home-screen icon tags to every page.
* It writes a service worker for each build and serves it at `/pylon-sw.js`.
* It registers the worker in production. `pylon dev` does not register it, and removes one that an earlier build left.

## What the worker keeps

When the worker installs, it downloads these files:

* The build files that the pages of `routes` load: scripts, chunks, stylesheets, and fonts.
* The web app manifest and the icons.
* The HTML of each route in `routes` that has no dynamic segment.

Build files are content-hashed, so the worker serves them from its cache first. It never caches `/api/*`, `/auth/*`, the sync connection, or the dev endpoints. Data comes from the sync engine, not from the HTTP cache.

When a page load fails because there is no network, the worker answers with the kept HTML for that route. A route with a dynamic segment, such as `/leads/[id]`, uses the start URL's HTML. The client then renders the real URL from the local replica. A load of a route that is not in `routes` opens the start URL.

On an offline route, a slow network gets 3 seconds. Then the worker shows the kept page.

## Render rule for offline routes

An offline route renders with no server. Follow these rules on the page and its layouts:

1. Read data with `db.useQuery` or `db.useQueryOne`. These read the local replica.
2. Do not read `serverData`. There is no server to answer it.
3. Do not read `props.auth`, `props.headers`, or `props.cookies` during the render. Use `props.session.exists` for a signed-in check, and `useSession()` for the user.

The worker keeps a page only when its HTML is the same for every signed-in user. When it asks for a route's HTML, the runtime checks the render. A page that breaks rule 2 or 3 is refused, and the browser console shows the reason:

```
[pylon offline] /leads is not kept for offline use: refused; the page read props.auth
```

`pylon lint` warns about an offline route that reads `serverData` (PYL008), and about an offline route that names no page (PYL009).

The kept HTML carries one bit of the session: signed in or not. When that bit changes, the page tells the worker, and the worker gets the HTML again.

## Unsynced and failed writes

Writes made with no network wait in a queue on the device and are sent when the network returns. Show the user what is waiting and what the server refused with `useOutbox()`:

```tsx theme={null}
import { useOutbox } from "@pylonsync/react";

export function OutboxBar() {
  const { pending, failed, retry, dismiss } = useOutbox();
  if (pending.length === 0 && failed.length === 0) return null;
  return (
    <div>
      {pending.length > 0 && <p>{pending.length} not synced</p>}
      {failed.map((w) => (
        <div key={w.id}>
          <p>Not saved: {w.error}</p>
          <button onClick={() => retry(w.id)}>Retry</button>
          <button onClick={() => dismiss(w.id)}>Dismiss</button>
        </div>
      ))}
    </div>
  );
}
```

To fix a value the server rejected, pass the corrected fields: `retry(w.id, { door: "14B" })`. A failed write is sent again only as the user who made it.

Rows written offline stay on screen after the app restarts. The sync engine applies the queued writes to the local replica again when it starts.

When two people edit the same field of one row offline, the server keeps the edit that arrives last. Both writes succeed, so neither person is told. Edits to different fields of the row are both kept.

## Photos taken offline

`uploadFile` needs the network. `queueUpload` keeps the file on the device and returns at once. The leader tab uploads queued files one at a time when there is a network, then sets the file on its record.

```tsx theme={null}
import { db, queueUpload, useFileUrl } from "@pylonsync/react";

async function addPhoto(leadId: string, file: File) {
  const photoId = await db.insert("Photo", { leadId, fileId: null, url: null });
  await queueUpload(file, {
    attach: { entity: "Photo", id: photoId, field: "fileId", urlField: "url" },
  });
}

function PhotoThumb({ photo }: { photo: { url: string | null } }) {
  const src = useFileUrl(photo.url);
  return src ? <img src={src} alt="" /> : <div className="placeholder" />;
}
```

1. `queueUpload` stores the file in IndexedDB.
2. It sets `field` and `urlField` on the record to `local:<localId>` with a queued write. `useFileUrl` shows a `local:` value from the copy on the device.
3. When the upload finishes, a second queued write sets `field` to the file id and `urlField` to the file URL.

Both changes to the record are ordinary writes, so the entity's policies apply. Insert one row per photo to keep several photos on one record.

The rules:

* A file is uploaded only as the user who queued it. When another user signs in, the queued file is discarded.
* A file the server refuses (type, size, policy) stays in the outbox as a failed write with `kind: "upload"`. It is not sent again until you retry it. Retry and dismiss it like any failed write.
* A dropped connection leaves the file queued. It is sent again on the next reconnect.
* The queue holds at most 200 MB (`maxQueuedUploadBytes` on the sync engine). Past that, `queueUpload` throws `UploadQueueFullError`.
* Another device sees `local:<localId>` until the upload finishes. `useFileUrl` returns `null` for it.

`<FileUpload offline attach={...} />` in `@pylonsync/client` does the same for a file picker. Give `attach` as a function to insert a row per file:

```tsx theme={null}
<FileUpload
  offline
  accept="image/*"
  attach={async () => ({
    entity: "Photo",
    id: await db.insert("Photo", { leadId, fileId: null, url: null }),
    field: "fileId",
    urlField: "url",
  })}
/>
```

## Instant navigation

A navigation to an offline route renders from the device, with no page fetch, also when the network is good. A tap on a tab bar paints in the same frame. To get the same for a route that is not in `routes`, export `navigation = "client"` from its page. See [Instant navigation](https://docs.pylonsync.com/ssr/link.md#instant-navigation).

## New deploys

Each build gets a new worker with its own caches. The new worker installs in the background and waits. It takes over when every tab of the app is closed, or when the page tells it to. Then it deletes the caches of the old build. A deploy never changes the code of a page that is open by itself.

An app on the iOS home screen can stay open in the background for days. Show the user that a new version is available with `useAppUpdate()`:

```tsx theme={null}
import { useAppUpdate } from "@pylonsync/react";

export function UpdateNotice() {
  const { updateReady, applying, waitingForSync, apply } = useAppUpdate();
  if (!updateReady) return null;
  return (
    <div role="status">
      <p>A new version of the app is available.</p>
      <button onClick={apply} disabled={applying}>
        {waitingForSync ? "Saving changes" : "Update"}
      </button>
    </div>
  );
}
```

| Field | What it is |
| - | - |
| `version` | The build this page runs. A hash of the build. |
| `builtAt` | When this build was made (ISO 8601). |
| `latestVersion`, `latestBuiltAt` | The latest build the server reported. `null` until the first check. |
| `updateReady` | A newer build exists. |
| `applying` | `apply()` is running. |
| `waitingForSync` | `apply()` waits for pending writes to reach the server. |
| `reinstallRecommended` | The app is on the iOS home screen, and its name, icons, display mode, or start URL changed since the user added it. |
| `apply()` | Loads the newest build. See below. |
| `check()` | Asks the server for the latest build now. |

The hook checks `/_pylon/version` when the page becomes visible, and every five minutes while it is visible. It also learns of a new worker when the worker finishes installing.

`apply()` does these things in sequence:

1. It waits until the outbox has no pending writes. A write made offline waits for the network, so `apply()` waits too. Failed writes do not block it.
2. It waits for the new worker to finish installing, for up to 30 seconds.
3. It tells the new worker to take over, and reloads the page once when it does.

When a reload does not change the build (for example, a CDN serves old HTML), a second `apply()` in the next 60 seconds does not reload. Thus a page that calls `apply()` automatically cannot reload in a loop.

`/_pylon/version` returns the latest build and is never cached:

```json theme={null}
{ "version": "3f2a9c1d0b7e4a56", "builtAt": "2026-10-22T14:03:11.204Z", "install": "9c0d4e1f2a3b5c6d" }
```

`<AppUpdateBar />` shows a minimal notice with an Update button, and renders nothing when there is no update. It has no styles. Give it a `className`, or style `[data-pylon-app-update]`. To use your own components, use the hook.

Under `pylon dev`, in an app without `offline`, and during the server render, `updateReady` is `false` and `apply()` does nothing.

### Changes to the name or the icon

iOS reads the app's name, icons, display mode, and start URL only when the user adds the app to the home screen. A later deploy does not change them. When one of these changes, `reinstallRecommended` becomes `true` on an iOS home-screen app. Tell the user to remove the app from the home screen and add it again. Android and desktop browsers update an installed app from the manifest, so `reinstallRecommended` stays `false` there.

The `install` field of `/_pylon/version` is a hash of these values, and of the icon files in `public/`.

## Install the app

* iOS Safari: tap Share, then Add to Home Screen.
* Android Chrome: tap the menu, then Install app.

The installed app opens on `startUrl`, with no browser UI when `display` is `"standalone"`.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.