The application model
A directory is a route
A directory under app/routes/ is a route when, and only when, it holds a page.tsx. The path is the directory path.
| Directory | URL |
|---|---|
routes/ | / |
routes/cart/ | /cart |
routes/product/[id]/ | /product/{id} |
routes/files/[...rest]/ | everything under /files/ |
A directory with no page.tsx is not a URL. It is just a place to put files.
The files beside a page
routes/product/[id]/
page.tsx the component
page.loader.ts its only input
actions.ts the mutations it may call
error.tsx what renders when the loader fails
loading.tsx its presence makes the segment streamAdding the file is the whole of registering it. There is no route table to update and no manifest to edit.
At the top of routes/ two more files apply everywhere: error.tsx for any route without its own, and not-found.tsx for a path nothing matches.
The loader is the page's only input
import type { Ctx } from "@snapfire/fsr";
export async function load({ params, services }: Ctx<"/product/{id}">) {
const product = await services.catalog.get({ id: params.id });
return { name: product.name, price: product.price };
}Three exports are recognised. load produces the page's props. meta produces the title, the description and any head elements. store seeds keys the browser can read.
The build infers what load returns and writes that type into generated/client.ts as the page's props. The page imports it:
import type { ProductIdProps } from "@generated/client";
export default function Product({ name, price }: ProductIdProps) {
return <h1>{name}</h1>;
}There is no props declaration to keep in sync, because the props type is a projection of the loader. A page that reads a field the loader stopped returning fails the build, not the request.
What a loader may say
A loader is lowered into IR, so it is a description of how to get data rather than an arbitrary program. It may await services, read params, query, session, identity, locale and now, branch, build objects and arrays, and use the array methods the lowerer knows. It may not reach for the filesystem, the network directly, a random number or the ambient clock.
When it reaches for something the lowerer cannot hold, the build stops and names the line. That leftover is called residue, and a refused build is the feature: nothing silently falls back to a slow engine.
A layout wraps what is beneath it
layout.tsx in any routes directory renders around every page under it, with children where the page goes. It has its own layout.loader.ts, independent of the page's.
The line between them is firm: a page cannot read layout data and a layout cannot read page data. That is what lets a layout keep its DOM and its state across a navigation while the page under it swaps.
A layout is keyed by its module and by the route parameters its loader reads. A layout whose loader reads no parameters stays put across every page beneath it, which is exactly what you want for a header, a sidebar or a nav rail.
The lab
Give a route a loading.tsx and watch the document arrive with the loading module in the page's slot, the real page streaming in behind it. Nothing in the page or the loader changed: streaming is a property of the plan, declared by the file existing.