Routing
The directories under src/routes/ are the application’s URLs. Once you know the rules for directories and files, a page, a redirect or a feed is one file in the right place.
On this page
Directories and URLs
src/routes/
layout.tsx
page.tsx
not-found.tsx
products/
page.tsx
[id]/
page.tsx
(docs)/
layout.tsx
guide/
page.tsxpage.tsx: the page at/.products/page.tsx: the page at/products.products/[id]/page.tsx: the page at/products/:id. Open/products/42, and the page receives42asparams.id.(docs)/guide/page.tsx: the page at/guide.
Directory name shapes
The shape of a directory name decides what it adds to the URL.
products: adds one segment, exactly as written. Letters, digits,.,_,~and-are allowed.[id]: a parameter that takes one segment. Its name starts with a letter or_.(docs): a group, which adds no segment. Use it to give part ofroutes/a layout or anerror.tsxof its own.- A name starting with
.: never read, directory or file. A.DS_Storeor an editor’s temporary file does not break the build.
There is no parameter that takes several segments, such as [...rest].
Route files
A directory may hold only files with these nine names, extension included. Even a page.ts, with only the extension wrong, fails the build.
page.tsx: the page shown at the directory’s URL.layout.tsx: wraps whatever renders below it, received aschildren.not-found.tsx: shown for any URL below it that no route matched.error.tsx: rendered in place of what is below it when that throws.loading.tsx: rendered while the page below it loads.redirect.ts: default-exports a destination, instead of a page.route.ts: returns aResponseinstead of a page.guard.ts: runs before any request below it is handled, and stops it or lets it through. Server mode only.fallback.tsx: answers a URL whose valuepathsdid not list, in place of thepage.tsxbeside it. Static mode only.
src/routes/ holds route files and directories, nothing else. Components go in src/components/, and every other module — Server Actions, schemas, state definitions, data, helpers — in src/lib/.
Page and layout props
src/routes/products/[id]/page.tsximport type { PageProps } from '@k8ordo/framework';
export default function ProductPage({
params,
pathname,
}: PageProps<'/products/:id'>) {
return (
<>
<h1>{params.id}</h1>
<p>{pathname}</p>
</>
);
}A page receives params, and a layout receives children. Both receive pathname, the URL being rendered. A layout above a parameter reads the value from pathname.
PageProps and LayoutProps from @k8ordo/framework are these props looked up by pattern. They read the generated route table, so the pattern string is the only type argument. LayoutProps takes only a pattern that has a page: without products/page.tsx, the layout for /products writes its props type itself.
Under server mode a page and a layout also receive request. Reading its headers and cookies is covered in Request and cookies.
Router API
src/components/products-link.tsx'use client';
import { href, useMatch } from '@k8ordo/framework';
export function ProductsLink() {
const showing = useMatch('/products/*', { inclusive: true }) !== null;
return (
<a aria-current={showing ? 'page' : undefined} href={href('/products')}>
Products
</a>
);
}The APIs for links and the current URL, such as href and useMatch, are imported from @k8ordo/framework, which re-exports them from @k8ordo/router by name. The hooks work only in Client Components.
useParams and useRoute are not in @k8ordo/framework: the browser holds no route table, so they would have no route to read. What to use instead is covered in Server and browser.
The locale segment
src/routes/[locale]/layout.tsximport type { ReactNode } from 'react';
import { locales } from '../../i18n';
export const { paramsSchema } = locales;
export default function LocaleLayout({
children,
}: {
children: ReactNode;
}) {
return children;
}From the [locale] layout, export the paramsSchema of the locales you defined with defineLocales. The schema accepts only the locales in the set. Where the accepted locale is read is covered in Put the locale in the URL.
The framework validates the parameter with this schema as it does with any paramsSchema, and renders the pages below in the accepted locale. / has no locale, so the application itself picks a language at / and redirects to a URL that has one. Picking it is covered in Choose the first language.
Under static mode a page is written per locale, so paths takes locales.paths. Passing it is covered in Parameters.
Route order
src/routes/
about/
page.tsx
(shop)/
sale/
page.tsx
[id]/
page.tsxDirectories have no order of their own, so the generated route table picks one. At each level a fixed segment is tried before a parameter, and not-found.tsx comes last. Put [id]/ directly beside about/, and /about still reaches about/.
A group, though, keeps its routes together in one place. The table does not reorder across groups. In the example above, (shop) holds the fixed segment sale/, so it ranks level with about/. At the same rank routes sort by name, which puts (shop) first.
So (shop)/[id]/ answers /about first, and about/page.tsx never renders. This shape fails the build with an error that begins "/about" can never match.
Invalid structure
When a file or directory breaks the rules, the build fails. The error begins routes/ is not a valid pathname space: and lists one line per problem. It lists every problem it found, not just the first, and each line names the file.
- A file that is not a route file (
products/helper.ts) - A malformed directory name (
[123],(docs,pro ducts) - A parameter named the same as one above it (
[id]/things/[id]/) - A directory with no page,
redirect.tsorroute.tsanywhere below it (only a layout or anerror.tsx, say) - Pages at the same URL in different groups (
(a)/page.tsxand(b)/page.tsx) - Any two of
page.tsx,redirect.tsandroute.tsin one directory - A
route.tsthat exports no method - A page that never shows because a route inside a group answers first
- A
fallback.tsxwith nopage.tsxbeside it - A
fallback.tsxin the same directory as alayout.tsx - A
fallback.tsxfor a page with no parameter - A
fallback.tsxfor a page whose every parameter a layout above receives
vite dev refuses to start with the same error when a problem exists at startup. A problem introduced while it runs is logged as routes/<path>: <message>, and the server keeps going. While a problem remains, .k8ordo/ is not rewritten.
Under static mode the build also fails on what needs a server. Which ones, and why, is covered in Choosing a mode.
Loading state
src/routes/products/loading.tsxexport default function ProductsLoading() {
return <p>Loading products…</p>;
}A loading.tsx beside a layout.tsx or a page.tsx is rendered while the page below it loads. It receives no props.
Under static mode a page is written whole into a file, so its HTML never shows a loading.tsx. A client navigation rarely shows one either: the payload is a file too, and usually arrives in one piece.
Under server mode a document waits for the page’s own component before it is sent, so its HTML does not show it, and a visitor without JavaScript, or a crawler, sees the page. The exception is a component inside the page that awaits data outside a <Suspense> of its own: the HTML then shows it in the page’s place. During a client navigation it shows from when the payload starts arriving until the server has finished rendering the page.
src/components/progress.tsx'use client';
import { usePendingPathname } from '@k8ordo/framework';
export function Progress() {
const pending = usePendingPathname();
if (pending === null) return null;
return <p role="status">Loading {pending}…</p>;
}Moving to another page within a directory already on screen keeps the current page showing until the next one arrives. To show something meanwhile, read where the navigation is going with usePendingPathname().
route.ts
src/routes/feed.xml/route.tsimport type { RouteContext } from '@k8ordo/framework';
import { renderFeed } from '../../lib/feed.server';
export async function GET({ request }: RouteContext<'/feed.xml'>) {
const { origin } = new URL(request.url);
return new Response(await renderFeed(origin), {
headers: { 'content-type': 'application/rss+xml' },
});
}A URL that returns something other than a page, such as an RSS feed or JSON, gets a route.ts. It exports a function named after each HTTP method it handles, and that function returns a Response. The function receives { request, params }, typed RouteContext. Its params are typed by the schemas, as a page’s are.
A directory named like a file, such as feed.xml, is an ordinary segment, so feed.xml/route.ts is the URL /feed.xml. The Response a route.ts returns gets none of the layouts above it. A client navigation to its URL reloads the whole page.
Static mode
The build calls GET once per URL and writes the Response it returns as the file at that URL: feed.xml/route.ts becomes dist/client/feed.xml. A route.ts with parameters takes its values from paths, as a page does. With site given to framework(), the origin of request.url is that value.
Pitfall
These route.ts files cannot become files, so they fail the build:
- A
route.tsexporting a method other thanGET(vite devrefuses it too) - A
route.tswhoseGETreturns anything but200 - A
route.tsat/ - A
route.tswith pages below it
Server mode
Any of the seven HTTP methods may be exported. A method it does not export gets a 405, with the exported ones listed in Allow. Without a HEAD export, the GET response is returned without its body.
The guard.ts files above it run first. cookies(), responseHeaders() and requestHeaders() from @k8ordo/framework/server work inside a route.ts too.
Pitfall
A POST to a route.ts is not checked for the same origin, unlike a Server Action: what posts there, such as a webhook, comes from elsewhere. Write the checks you need inside the route.ts.
The generated .k8ordo/
.k8ordo/routes.gen.tsimport { defineRoutes } from '@k8ordo/framework/generated';
// …
export const routes = defineRoutes({
'/': {
layout: layout satisfies Layout<'/'>,
children: {
'/': page satisfies Page<'/'>,
'/products': {
children: {
'/': products_page satisfies Page<'/products'>,
'/:id': products_id_page satisfies Page<'/products/:id'>,
},
},
},
},
});The framework writes a route table and its types, made from routes/, into .k8ordo/. It writes when vite dev starts and when vite build begins, and during development whenever a file under routes/ changes.
routes.gen.ts: the route table itself. It checks each route file against its directory’s pattern withsatisfies.register.gen.ts: registers the table with the framework, which types the patternshref()anduseMatch()take. Under server mode it also addsrequesttoPagePropsandLayoutProps. When the application depends on@k8ordo/state, it registers the table there too..gitignore: ignores everything in it with*. That keeps.k8ordo/out of git, so nothing goes into the application’s own.gitignore.
It is generated, so do not edit it. It is plain TypeScript importing from @k8ordo/framework/generated, and reading it shows what is checked.
vite build does not type-check, so run tsc to see the satisfies checks. In a fresh clone, run vite dev or vite build once before tsc.
Titles and metadata
src/routes/products/[id]/page.tsximport type { PageProps } from '@k8ordo/framework';
export default function ProductPage({
params,
}: PageProps<'/products/:id'>) {
return (
<>
<title>{`Product ${params.id}`}</title>
<meta content="One product" name="description" />
<h1>{params.id}</h1>
</>
);
}There is no metadata API. React 19 moves a <title>, <meta> or <link> rendered in any component into <head>. A page renders its title where it renders everything else.
Keep one <title> on screen at a time. The root layout renders none, and each page and not-found.tsx renders its own. Render two and both are output; the later one does not replace the earlier.
Prefetching links
<nav data-k8ordo-prefetch="false">
<a href={href('/reports')}>Reports</a>
<a data-k8ordo-prefetch="true" href={href('/')}>
Home
</a>
</nav>When a pointer moves onto a link, the client runtime fetches the payload of the page it points to. Focusing the link or starting a press on it does the same. There is nothing to set up: every <a> on the page counts, including the ones a component library renders. These links are not prefetched:
- one to another origin, or outside Vite’s
base - one with
download, or with atargetother than_self - one to the page on screen
To stop prefetching, mark the link or any element around it data-k8ordo-prefetch="false". Use it for links to pages that are expensive to render. The nearest element carrying the attribute decides, so "true" brings one link back inside a region that opted out.
A prefetched page is used once, by the next navigation to it, and only within 30 seconds of the fetch starting. After that the navigation fetches it again. A Server Action’s result drops everything prefetched too.
Note
The browser’s Speculation Rules are not used, since they are not Baseline.
Prefetch demo
This site runs on @k8ordo/framework in static mode, so pointing at a link really fetches its index.rsc and shows it in the list below.
Payloads fetched
None yet
Try it
- Point at “The parameters page”. Its
index.rscjoins the list. - “The deploy page” sits inside
data-k8ordo-prefetch="false", so pointing at it adds nothing. - Point at “The parameters page” again. Nothing is added until 30 seconds after the first fetch.
- Point at a link to another page in the header or the sidebar. It joins the list the same way.