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?
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.
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.
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:
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:
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 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:
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 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.
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:
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.
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.
[public]
analytics_id = ""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 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:
// 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.
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:
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.
<nav>
<Link href="/billing" match="prefix">Billing</Link>
<Link href="/billing/overdue">Overdue</Link>
</nav>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:
<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.
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.
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.