# 100. Routes, loaders and pages

The question this chapter answers: how does a directory become a route, where does its data come from, what does its page receive and what happens when the user clicks a link?

**For:** app developers.

## Directories and routes

Everything under `app/routes/` is a route when it holds a `page.tsx`. The directory's path is the pattern, with no name treated specially: `routes/` itself is `/`, `routes/cart/` is `/cart`, `routes/index/` is `/index`, `routes/product/[id]/` is `/product/{id}` with `id` a parameter; `[...rest]` at the end catches the remainder. A directory without a page is not a route, so a shared folder can live in the tree without becoming a URL. A directory named in parentheses, `routes/(marketing)/`, is a group: it adds nothing to the URL, so `routes/(marketing)/about/` is `/about`. Its `layout.tsx` wraps only the routes beneath it. Two groups give one URL space two layouts with no prefix to tell them apart. Two routes in different groups that answer the same URL are refused, naming both directories. Names derive from paths and nothing is named twice: the loader in `routes/cart/` is the source `cart` and the one in `routes/` is `$root`, a name no directory can produce; an action exported from `routes/cart/actions.ts` as `addToCart` is `cart.addToCart`.

Beside the page a route may have a `page.loader.ts`, an `actions.ts`, an `error.tsx` (plus an `error.<kind>.tsx` per failure kind) and a `loading.tsx`. The build discovers them all, so adding the file registers it. A `layout.tsx` in any directory on the way wraps the pages beneath it, with its own `layout.loader.ts`. At the top of `routes/`, beside the shared `error.tsx`, a `not-found.tsx` is the page for a path nothing matches. `fsr build --verbose` lists the pattern beside the directory:

```
routes    /                      routes
          /cart                  routes/cart
          /product/{id}          routes/product/[id]
```

A page does not have to be TSX. A directory may hold `page.tera` in place of `page.tsx` and a layout directory `layout.tera` in place of `layout.tsx`; one of each, since a directory holding both is refused naming the two files. A template is not lowered and not bundled. The stock host reads it from the file and renders it itself, with the loader's return as its context, so `page.loader.ts` and `actions.ts` sit beside it exactly as they sit beside a `page.tsx` and are lowered the same way. `fsr build --verbose` lists such a module as `template` where a TSX page is `lowered`. It needs an `fsr` built with the `tera` feature, which the published binary is; without it a `page.tera` is refused naming the feature rather than ignored. Chapter 105 has a complete application written that way.

## What a route is called

Every route has a **source id** and it is the name you see in the boot report, in a trace, in the plan and in the generated types. It is the directory's segments joined with `.`, with two markers.

`$root` is the route at `routes/` itself, since it has no segments to join. A parameter contributes `$<name>` rather than the bracketed directory: `routes/product/[id]/` is `product.$id` and `routes/docs/[...rest]/` is `docs.$rest`.

| Directory | Pattern | Source id | Props type |
| --- | --- | --- | --- |
| `routes/` | `/` | `$root` | `RootProps` |
| `routes/cart/` | `/cart` | `cart` | `CartProps` |
| `routes/product/[id]/` | `/product/{id}` | `product.$id` | `ProductIdProps` |
| `routes/admin/users/` | `/admin/users` | `admin.users` | `AdminUsersProps` |
| `routes/(shop)/cart/` | `/cart` | `(shop).cart` | `ShopCartProps` |
| `routes/030-intro/` | `/030-intro` | `030-intro` | `Route030IntroProps` |

The `$` keeps ids unique. A directory name may hold alphanumerics, `_` and `-` and nothing else, so no static segment can ever produce a `$` part, which is why `routes/a/x/` and `routes/a/[x]/` cannot collide on a name. The marker is dropped when the props type is derived, so `product.$id` is `ProductIdProps` and the `$` never reaches your TypeScript. A group keeps its parentheses in the id, which is how `(shop)/layout.tsx` and `(account)/layout.tsx` stay two layouts. The type name drops them.

Actions and slots extend the same id. An `addToCart` exported from `routes/cart/actions.ts` is `cart.addToCart` and a parallel slot beside a layout is `layout.<name>`. Under a site prefix the full id carries the prefix, so the billing site's root is `billing:$root`.

## Loaders

A loader exports `load` and receives the context: `params` from the pattern, `query` from the query string, `session`, `identity` and `services`. What it returns is the page's full set of props and data has no other way to reach a page. A page is a function of what its loader returned, which lets the server render it, cache it and reason about it.

```ts
export async function load({ params, session, services }: Ctx<"/product/{id}">) {
  const id = BigInt(params.id);
  const product = await services.shopping.getProduct({ id });
  const stock = await services.inventory.getStock({ product_id: id });
  return { product, stock, inCart: session.cart[params.id] ?? 0n, cartCount: ... };
}
```

The two awaits do not depend on each other, so the interpreter runs them together: the loader is written in sequence but runs in parallel, because the plan can see that the calls are independent. `params.id` is a string, since every parameter is, so the body converts it before the contract sees it; a wrong conversion is a build-time type error rather than a runtime surprise.

The generated `Ctx<"/product/{id}">` knows the route's parameters, so a loader cannot read a parameter its pattern does not have. `query` is a plain record of strings, one value per key; it is how the catalog's search and category filter reach the loader: `query.q` and `query.category` are read like any other field and the recogniser treats them as request reads.

## Metadata

Metadata comes from the route's data. There is no head component and nothing is rendered to find the title. A loader module may export `meta`, a function of the data `load` returned and the document's `<title>` and description meta come from it, over the title in `app.toml`.

```ts
export const meta = ({ data }: MetaCtx<DataOf<typeof load>>) => ({
  title: `${data.product.name} · Shopping`,
  description: `${data.product.name} for $${(Number(data.product.price_cents) / 100).toFixed(2)}`,
});
```

`[document] title_template` in `app.toml` writes a route's title around the application's, `"{page} · {title}"`, so the `meta` above returns `data.product.name` alone and every route stops repeating the suffix. A route whose title should stand alone, a sign-in page, returns `absolute: true` beside it. Both the title and the template take a message key with a literal fallback, `title = { key = "document.title", default = "Shopping" }`, read from the catalog of the request's locale, so a route with no `meta` is titled in the reader's language.

The innermost route with a `meta` wins, so a layout's loader can set a default a page's overrides. A client-side navigation retitles the document from the payload and a page behind `loading.tsx` retitles it the moment its data arrives, since the meta rides with the streamed segment. Reading `params`, `query`, `session` or `now` inside `meta` is allowed and marks the route dynamic the way it would in `load`.

## Seeding the store

Every island on a page is its own React root, so islands share no context and no lifted state. They do share a store, one keyed map for the document. A loader fills it the same way it titles the document. Export `store` beside `load`, a function of the data the loader returned:

```ts
export const store = ({ data }: { data: { cartCount: bigint } }) => ({ "cart/count": Number(data.cartCount) });
```

Every segment on the route may export one. The store is the merge of what each segment seeded, by position: a page wins a key its layout also sets and two parallel slots at one level settle a shared key by slot name, the later name winning, so the result is the same on every load whichever slot's loader finished first. `[store] slot_order` in `config/app.toml` overrides that tie: a list of slot names, `content` for the page, the later winning, with any slot it leaves out losing to the ones it names. A shell's list governs every site it mounts, since the browser has one store. A segment's seed leaves with the segment: a page that seeds nothing inherits nothing from the page before it and an intercept's seed goes when the intercept closes. The seed reaches the browser in the document, so a component reading a key is rendered from the same value on the server; a navigation carries it in the payload and a streamed segment carries its own ahead of its markup when it resolves. A `store` export names its keys as literal strings, since it runs before any component and has no imports to follow.

A `store` seed is authoritative: an action's answer revalidates the route, every seeded key is seeded again and a value an island wrote in the browser is replaced. A key the visitor owns, a quantity they stepped or a size they picked, goes under `defaults` instead. It has the same shape as `store` and the server renders the first paint from it the same way, but in the browser it sits under an island's write, so the visitor's choice survives the route seeding again and the server's value takes the key only while nothing in the browser holds it:

```ts
export const store = ({ data }: { data: Order }) => ({ "order/name": data.name });
export const defaults = ({ data }: { data: Order }) => ({ "order/qty": data.qty, "order/size": data.sizes[0].label });
```

A `store` value wins a key a `defaults` of any segment also names.

A streamed page behind `loading.tsx` is rendered after its layout, so a key the page also seeds is not known when the layout paints. The first wave says which keys each deferred segment will seed, which the build infers from the type its `store` export returns. A key is pending while such a promise outranks the value the store holds. An island in the layout that reads a pending key is held: the server writes none of its markup, only its children. The browser mounts it once the page's seed lands, so the first paint never shows the layout's value about to be replaced. An island whose module the server cannot render waits the same way, by the `useStore` keys the build read from its source; where the build cannot read a key, a React root suspends on it while a Vue one mounts showing what the store holds. An island already mounted when a client navigation streams a new page in keeps showing what the page before it seeded until the new seed lands. A `store` export whose return the build cannot type as an object, keys computed at runtime for one, promises nothing. An island reading its keys shows the layout's value first and moves when the page lands, which is a change of value and never a hydration error. The same holds for a key another island writes before a `when="visible"` island scrolls into view.

The storefront's root layout seeds the cart's total and the header shows it. No prop passes it down: the header is a component inside the layout's island, the buy button is in the page's and both read the same key. [Chapter 102](https://snapfirers.com/fsr/the_guide/102-components-the-server-renders.md) is how a component reads and writes it.

## Prerender paths

A route with a parameter renders per request, since nothing says which values the parameter takes. When the set is known at build, the page loader says so with a `paths` export, a function returning one object per path:

```ts
export async function load({ params }: Ctx<"/blog/{slug}">) {
  return { post: POSTS.find((p) => p.slug === params.slug) };
}

export const paths = () => POSTS.map((p) => ({ slug: p.slug }));
```

`fsr prerender` runs it once per locale and writes the route at each path, so every post is a file and a slug outside the set still renders live. The body may read the locale and call a service, since a set can come from a backend as well as a constant; reading the request is refused at build. A `paths` belongs to a page loader on a route with a parameter and nowhere else. [Chapter 300](https://snapfirers.com/fsr/the_guide/300-the-build-and-the-dev-loop.md) has what prerendering covers.

## Locale

With a `[locales]` section in the configuration, every request has a locale before anything runs: the path prefix, `/fr_FR/about`, then the cookie, then `Accept-Language`, then the default, which serves unprefixed. The prefix is stripped before the route matches, so no route carries a locale segment and the loader reads `locale` beside `params`. A component reads `useLocale()`, which the build lowers, so the server renders what the browser hydrates against. A link is served as written: `/fr_FR/help` is French, `/help` is whatever the request resolves to.

```ts
export async function load({ locale, services }: Ctx) {
  return { copy: await services.content.page({ slug: "help", locale }) };
}
```

The ops console picks its language from the settings drawer, two document loads with a prefix each and the host remembers the choice in a cookie.

## Host and origin

`ctx.host` is the host the request named, for a deployment that answers on more than one and has to tell them apart: an absolute URL in a `rel=canonical`, a tenant read off the domain. It is `string | null`. It is null until `[server] hosts` lists the hosts, which chapter 200 covers along with what the server in front has to do for the value to mean anything.

`ctx.origin` is `document.origin` as the host checked it at boot, `string | null`. It is one value for the whole deployment rather than one per request, so a body reading it still prerenders where a body reading `ctx.host` does not. It is what an absolute href is built from when the application writes the href itself:

```ts
export const meta = ({ data }: MetaCtx<Data>) => ({
  head: [canonical(`${data.origin ?? ""}${data.path}`)],
});
```

A `canonical()` returning a path needs none of this, since the host makes it absolute against the same value. Reach for `ctx.origin` when the URL goes somewhere the host does not rewrite: a JSON-LD block, a feed, an absolute link in a body.

```ts
export const meta = ({ data }: MetaCtx<Data>) => ({
  title: data.title,
  head: [canonical(`https://${data.host}/docs/${data.slug}`)],
});
```

A source reading `ctx.host` is never prerendered, since its value depends on which configured host the request named.

## Deployment values

`ctx.config` is `[public]` from the configuration: the values a deployment sets and a body needs, an analytics id, a feature switch, a support address. `app.toml` declares each key with the value development runs under and an overlay sets the deployment's own. The declaration types the read: a string is `string`, an integer `bigint`, a float `number`, a boolean `boolean`. A key the section does not declare is not a field of `ctx.config`, so a misspelling is a type error at build rather than a null at runtime. `fsr doctor` reports a plan reading a key the deployment's configuration lacks.

```toml
[public]
analytics_id = ""
```

```ts
export async function load({ config }: Ctx) {
  return { analyticsId: config.analytics_id };
}

export const store = ({ data }: { data: { analyticsId: string } }) => ({ "site/analytics": data.analyticsId });
```

The values are the same on every request, so a source reading only them still prerenders. They are public: a loader returns them, a page renders them and a store seeds the browser with them, so a secret does not belong there. Chapter 200 covers the section and its overlays.

## Page props

The build infers each loader's return type and writes it to `generated/client.ts` under the route's name, so the page imports `RootProps` and receives exactly what `load` produced, typed, with the value model's shapes preserved: a contract `integer` is `bigint`, a `number` is `number`, an optional field is `| null`. There is no separate props declaration to keep in sync, because the props type is a projection of the loader.

A page is an island: it is mounted in the browser and, when the build could lower it, rendered on the server first. [Chapter 102](https://snapfirers.com/fsr/the_guide/102-components-the-server-renders.md) is what a page may say for that to work. Either way, the page's job is to render its props; anything that changes them goes through an action and comes back through the loader.

## Errors and loading

An `error.tsx` beside a route (or `routes/error.tsx` for all of them) receives `{ error: string, kind: string }` when the loader fails: a service that is down, a response the contract rejected, a `fail` the body raised. `kind` is the failure kind, one per HTTP error status: `not_found`, `unauthorized`, `forbidden`, `too_many_requests`, `unavailable` and the rest, so one boundary can say "no page here" for one and "something broke" for the rest. The document still renders around it, so an error page is a page with a message rather than a blank tab. The status follows the page: when the route's own page loader fails, the document answers with the kind's status, `404` for `fail("not_found", ...)`, `403` for `forbidden`, `503` for `unavailable`, since a crawler, a cache and a browser's history read the status and not the markup. A layout's loader or a slot's failing degrades that segment and leaves the status at `200`, because the page itself still rendered. A client navigation to such a route gets the same status with the payload, which the navigator applies as it would any other. `fsr prerender` writes no file for a path whose loader failed.

`error.<kind>.tsx` beside `error.tsx` is the boundary for one kind on its own: `error.not-found.tsx` renders when the loader failed `not_found` and `error.tsx` answers every other kind. Every kind may have one, spelled with hyphens rather than the underscore the kind itself uses: `error.forbidden.tsx` for a 403, `error.too-many-requests.tsx` for a 429. A route that declares any boundary owns every kind, so its own `error.tsx` answers a `not_found` rather than pairing with `routes/error.not-found.tsx` above it; a route that declares none takes the whole set from `routes/`.

This is not `not-found.tsx`, which answers a path no route matched. A loader failing `not_found` means the route matched and the record behind it does not exist. There the route's parameters are what the page has to work with.

A `loading.tsx` marks the route deferred: the document ships with the loading module in the page's slot and the real page streams in when the loader finishes, filling the slot in place. Streaming is declared in the plan by the file's presence and the page and the loader do nothing for it.

A `routes/not-found.tsx` answers a path no route matches. The host renders it like any page, inside the shell with its islands, with status 404 and `params.path` carrying the path asked for; without one the answer is a line of text. It is not a route, so it has no loader and no pattern and a link to it from a page is a full load rather than a client navigation.

## Layouts

`layout.tsx` in a routes directory renders around every page under it, the page appearing where the layout puts `children`. Its props come from `layout.loader.ts` beside it, independent of the page's loader: the storefront's header takes the cart count from the root layout's loader. Layouts nest by directory.

A layout is composition like a page: the server renders it and the browser never renders it again. A navigation between two pages swaps the page's region and leaves the layout's DOM alone, so the header, an island inside the layout, keeps its state: text typed in its search box survives the click. When an action revalidates, the header takes its new props in place, so the cart count follows the mutation without the box losing its text. A layout is keyed by its module and the route parameters its loader reads, which is why a layout that reads none stays put across every page.

The page cannot read the layout's data and the layout cannot read the page's. Only the session, the actions and the store pass between them, so the islands in a page and the islands in its layout need not share a framework.

A value the layout and the page both show goes in the store rather than in React context, since no framework root spans the two:

```tsx
// src/ui/ModeSwitch.tsx, placed in the layout
import { useStore } from "@snapfire/fsr-client/react";
import { mode } from "@src/store";

export function ModeSwitch() {
  const [current, setMode] = useStore(mode, "light");
  return <button onClick={() => setMode(current === "light" ? "dark" : "light")}>{current}</button>;
}
```

Any island in the page reads `useStore(mode, "light")` and follows every write. State a layout holds itself, a `useState` in its own body, moves into an island beside it at build time the way a page's does; a layout whose state reaches one of its slots cannot be split and stays a React root, which the report names under `kept`. `export default tree(Layout)` stops the build and says to export the layout itself. The conference example's two layouts are plain composition and a click from one talk to another keeps the masthead and the crumbs.

## Slots and intercepts

Three rules cover what Next calls parallel and intercepting routes: a directory maps to a URL, a layout declares its slots in code and a slot that is a route of its own lives under `slots/` beside the layout.

A **parallel slot** is a segment beside the page with its own loader, loading and error boundary, rendered into a region the layout places. It lives under `slots/<name>/` beside the `layout.tsx`, holding the ordinary route files: `page.tsx`, `page.loader.ts`, `loading.tsx`, `error.tsx`. The layout places it as a prop of that name or as `<Slot name>`; either way the region is `<sf-s data-sf-name>` in the markup, which the navigator fills and empties. Children of `<Slot>` are the fallback the region shows while nothing fills it and takes back when a navigation empties it. The prop form has no fallback to offer: the `slots/` directory that makes it a slot also puts a page on every plan under the layout, so the prop is always filled and a `{promo ?? …}` right-hand side never runs. Write `{promo}` for placement and use `<Slot name>` with children for a region that can genuinely be empty. Its props type is `Layout<Name>Props` and its source id is `layout.<name>`. It is keyed, cached and kept across navigation like any segment. The storefront's `routes/slots/promo/` shows snacks under the header on every page, loaded once per document by its own loader and stays put when the page under it changes.

The samples from here on are the storefront's, which is a React application: a project started with `fsr new <dir> --with react` or given React later with `fsr use app react`. A plain `fsr new` writes a bare application with no framework. There the same placements come from `@snapfire/fsr-authoring/template` with `Children` where a React layout writes `ReactNode`, as chapter 104 shows.

```tsx
import { Slot } from "@snapfire/fsr-client/react";

export default function Layout({ cartCount, children, promo }: LayoutProps & { children: ReactNode; promo: ReactNode }) {
  return (
    <>
      <Header cartCount={cartCount} />
      {promo}
      {children}
      <Slot name="modal" />
    </>
  );
}
```

An **intercept** is a second rendering of a URL, used when it is reached by a click rather than a document load. It is `page.<slot>.tsx` beside the route's `page.tsx`, sharing its loader. It renders into the slot of that name on the nearest layout above it that declares one, the page under that layout staying exactly as the browser has it. The storefront's `routes/product/[id]/page.modal.tsx` is a quick look: a click on a product from the catalog opens it over the catalog, the URL becomes `/product/1`, back closes it and a document load of the same URL, a shared link or a reload, is the full product page. A `loading.<slot>.tsx` beside it streams the variant behind a fallback of its own; without one it waits for its data.

The server decides whether to intercept. The navigator sends the document's path with every soft request as `x-sf-from`; the host matches the target's intercept and applies it when the origin's route shares the layout declaring the slot. The payload then carries the layouts down to that one with the slot filled and the page marked kept and the browser writes the region without touching anything else. Those layouts belong to the page beneath, so their loaders run under the document's request and their segment keys match what the browser holds: `ctx.query` still holds the filter the list was loaded with while a peek is open over it. A loader that wants the overlay's own request reads `ctx.address`, its `path`, `params` and `query`, which is null outside an intercept. A link opts out with `full` and names a slot outright with `into`, through `Link` from the React adapter or the `data-sf-full` and `data-sf-into` attributes on any anchor:

```tsx
import { Link } from "@snapfire/fsr-client/react";

<Link href={`/product/${id}`} full>Full details</Link>
```

`fsr build --verbose` lists each slot under `slots` by its source id and each intercept under `intercepts` as the pattern and the slot it opens in. A route may carry a variant per slot, `page.modal.tsx` and `page.drawer.tsx` side by side and the one that opens is whichever comes first in file order with a slot the live layout declares, unless a link's `into` names one. A `slots/` directory anywhere but beside a `layout.tsx`, a slot without a `page.tsx` or with routes beneath it and a variant naming a slot no layout above declares each stop the build.

## The current page in a nav

A `<Link>` whose `href` is the path the request matched carries `aria-current="page"`, written by the server and so present before any script runs. A section link asks for `match="prefix"` and is marked `aria-current="true"` while a page under it is showing, which is a different value so that a screen reader hears one current page rather than two. `match="none"` opts out and an `aria-current` written by hand stands.

```tsx
<nav>
  <Link href="/billing" match="prefix">Billing</Link>
  <Link href="/billing/overdue">Overdue</Link>
</nav>
```

```css
nav a[aria-current] { background: #eef3fc; }
nav a[aria-current="page"] { font-weight: 600; }
```

Nothing is passed down for this: a nav in a layout needs no prop and no state. A navigation re-renders the page segment and leaves the layout holding the nav exactly as it was, so the navigator re-reads the marked links itself once the page has changed. The portal marks `/billing` while a billing page is showing and the billing site, mounted under it from its own artifact, marks its own nav from the same request path.

An intercept opens a drawer or a modal over the page and puts the target's URL in the address bar. A link is judged by that address, so the modal's own link to its full page is marked while it is open and a nav item for a section the target falls outside loses its mark. A nav that describes the page beneath asks to be judged by the document instead:

```tsx
<Link href="/agents" match="prefix" current="document">Agents</Link>
```

The two differ only while an intercept is open. The console's header does this: the settings drawer puts `/settings` in the address bar and `Agents` keeps its mark, while the gear that opened it, judged by the address, takes one.

## Route handlers

A directory may hold a `route.ts` instead of a page. Its exports named `GET`, `POST`, `PUT`, `PATCH` or `DELETE` are handlers: the host matches the method and the pattern before any page, runs the body with the same context a loader gets plus the request body as `input` and answers with the returned value as JSON. A `fail` sets the status by its kind. Written as an `action<T>`, a handler has its input checked against `T` before the body runs; written as a plain function, `input` is whatever the request carried.

```ts
export async function GET({ session }: Ctx<"/api/cart">) {
  return { lines: session.cart };
}

export const POST = action(async ({ input, session }: ActionCtx<AddToCart>) => { ... });
```

This is the API route: the same session, identity and services as a page, reached by anything that speaks HTTP. A body test imports a method from the route file the way it imports `load` from a loader. A page spec can `fetch` it. A directory holds a page or a handler but not both.

## Middleware

`middleware.ts` at the top of the app exports a function that runs before every request that is not a static file: the action route, the handlers and the pages alike. It reads `request.method` and `request.path` beside the session, the identity and the services. What it returns decides the request. Returning nothing lets the request continue. `redirect` answers with a location. `rewrite` serves another path under the same URL. `status` with a `body` answers outright. `headers` join whatever response follows.

```ts
export async function middleware({ request, identity }: MiddlewareCtx): Promise<MiddlewareResult> {
  if (request.path.startsWith("/account") && !identity?.subject) return { redirect: "/login" };
  return { headers: { "x-frame-options": "DENY" } };
}
```

The storefront's middleware sends the old `/basket` to `/cart`, serves `/shop` as the catalog and stamps every response. Under `fsr test`, a body test runs it with a `request` in its context, a spec's `fetch` goes through it and `load` follows the redirect and reports where it landed.

## Client navigation

Links are ordinary anchors. With navigation enabled, a same-origin click fetches the destination's payload rather than a new document. Every region of a page carries a segment key, the module plus the parameters and query that produced it and a digest of what it rendered; the client walks the old and new payloads together, keeping every region whose digest is unchanged and replacing the rest. A region that did not change keeps its DOM and its island state, whatever its key became, which is what lets a four-pane page change one pane on a click. The shell survives every click, since its module and inputs never change; a page's own region is replaced when its module or its parameters do, which is what a click from the catalog into a product asks for.

The key includes the query string, which is why `/` and `/?q=filament` are different segments: a search that changed the results must render the grid again. A navigation that changes only the query morphs the region instead of replacing it. The new markup is patched in: an element that stands where it stood keeps its DOM and every island it places again keeps its DOM and its state and takes its new props, so a page island keeps what its reader was doing while the results change around it. A change of path replaces the region. For one navigation, `navigate(href, true, { keep })` decides it either way, as does a `Link` with `keep` or an anchor with `data-sf-keep`. `replace: true` puts the target in place of the current history entry and `scroll: false` leaves the window where it is. Together they suit a control that navigates on every change.

The payload is usually already there when the click lands. Moving the pointer over a link, focusing it or touching it fetches its payload and holds it for thirty seconds, so the click applies what is held and the loader ran while the user was still deciding; a back or forward inside that window makes no request either. An action that revalidates drops everything held, so nothing from before the mutation is shown after it. A link whose loader should not run speculatively says `data-sf-prefetch="none"`.

To animate a navigation, pass `transitions: true` to `enableNavigation` in `src/main.ts`. The swap then runs inside the browser's view transition, which crossfades the old page into the new one unless your CSS says otherwise; the conference app gives its header a `view-transition-name` so it holds still while the page changes. A link opts out with `data-sf-transition="none"`. The navigator skips the animation for a visitor whose system asks for reduced motion.

Pass `activity: true` as well and the page a visitor leaves is held out of the document. Going back to it, by the back button or a link, puts it back as it was left: what was typed in a field, a video's position, a custom element's state and, through history, the scroll. A React island on React 19.2 or later keeps its state too, paused under React's `<Activity>`. A Vue island and a React island on an earlier React would keep running while held, so they are unmounted and mount afresh when the page returns. The conference app turns both on; type a note on `/venue`, tick an item on its checklist, go to the schedule and press back.

## The lab

Add a route: `routes/deals/page.tsx` with a component that returns a paragraph. Run `fsr check --verbose app` and read the report: `/deals` is listed, with no source since there is no loader. Add a `page.loader.ts` that returns `{ when: ctx.now }`, check again and `deals` appears under sources as `lowered`. The page can now import `DealsProps` and it has a `when: bigint`.

Then add a `loading.tsx` beside it and start the server. Load `/deals` with the network tab open: the document arrives before the loader finishes and the page fills its slot when it does.
