routes/
The directory tree under src/routes/ is the application's URL space. This page covers the grammar of file and directory names, the order patterns are tried in, what the build refuses, the generated files, and how a page renders its title.
The directories are the pathname space
src/routes/ is the application's pathname space, and it holds nothing else. Adding a URL means adding a directory, and the file that answers a URL is found by reading the tree. The convention is not a habit to keep: it is a shape the build checks.
src/routes/
layout.tsx
page.tsx
not-found.tsx
error.tsx
old/
redirect.ts
products/
page.tsx
[id]/
page.tsx
(docs)/
layout.tsx
guide/
page.tsx
_parts/
counter.tsx| File | URL | What it is |
|---|---|---|
layout.tsx | — | The root layout around everything — the document itself |
page.tsx | / | The root page |
not-found.tsx | /* | Any pathname nothing else answered |
error.tsx | — | Shown in place of what is below it when that throws |
old/redirect.ts | /old | Sends the visitor to another URL |
products/page.tsx | /products | A literal segment |
products/[id]/page.tsx | /products/:id | A parameter, handed to the page as params.id |
(docs)/layout.tsx | — | A layout wrapping only what is inside the group |
(docs)/guide/page.tsx | /guide | A page inside the group; (docs) never appears in the URL |
_parts/counter.tsx | — | Private; invisible to the grammar |
Five filenames
Inside a directory the grammar accepts five filenames — page.tsx, layout.tsx, not-found.tsx, error.tsx and redirect.ts — matched exactly, extension included, so page.ts fails the build as surely as helpers.ts does. Everything else goes under a directory whose name starts with _.
| File | What it does | Props it receives |
|---|---|---|
page.tsx | Renders its directory's URL; the default export is the page | params, pathname, request |
layout.tsx | Wraps everything rendered below it, through children | children, params, pathname, request |
not-found.tsx | Answers any pathname below its directory that nothing else did | params, pathname, request |
error.tsx | A 'use client' file, shown in place of what is below it when that throws | error, reset |
redirect.ts | Default-exports where to send the visitor, instead of a page | None — nothing renders |
In this mode page.tsx, layout.tsx and not-found.tsx also receive the request's headers and cookies as request. Each directory may declare its own not-found.tsx, and its answer is a real 404. Actions & requests
Four kinds of directory name
A directory's name decides what it adds to the URL, if anything.
| Name | Adds to the URL | Meaning |
|---|---|---|
products | /products | A name of letters, digits, ., _, ~ and - is one URL segment, exactly as written. Upper-case letters are allowed. |
[id] | /:id | A parameter taking one segment. Its name starts with a letter or _, followed by letters, digits or _, and a name may appear only once along a path. |
(docs) | Nothing | A group, adding no segment. It exists to give part of the tree its own layout or error boundary. Its name uses letters, digits, _ and -. |
_parts | Nothing | A name starting with _ or . — directory or file — is invisible to the grammar. Components and data private to a route live here. |
There is no variable-length parameter such as [...rest]. The grammar has one wildcard, and it is not-found.tsx; a directory named [...rest] is refused as an invalid param directory.
A page receives params; a layout receives children
Server Components cannot read context, so nesting travels by prop: a layout receives what renders below it as children, and a page receives its pattern's parameters as params. Both receive pathname, the URL this render is for — the only way a component above a parameter can see the value that parameter names.
// src/routes/products/[id]/page.tsx
import type { PageProps } from '@k8ordo/router';
export default function ProductPage({
params,
pathname,
}: PageProps<'/products/:id'>) {
return (
<>
<h1>{params.id}</h1>
<p>{pathname}</p>
</>
);
}PageProps<pattern> and LayoutProps<pattern> from @k8ordo/router are those props by pattern. They read the generated Register, so nothing in the page depends on which mode is installed. Declaring the props inline works as well: either way the generated table checks them where it uses the component (satisfies), and tsc is what reports it.
This site's root layout decides <html lang> from the first segment of pathname: the locale lives only in the URL, and crawlers and screen readers read the HTML the server wrote.
// src/routes/layout.tsx
import type { ReactNode } from 'react';
import { locales } from '../i18n';
export default function Root({
children,
pathname,
}: {
children: ReactNode;
pathname: string;
}) {
const locale = locales.delocalize(pathname).locale ?? locales.default;
return (
<html lang={locale}>
<body>{children}</body>
</html>
);
}The order patterns are tried in
A directory tree has no order of its own, so the generated table chooses one: at each level a literal segment is tried before a parameter, and not-found.tsx comes last in its branch. That is why about/ beside [slug]/ reaches /about without anything being said.
A group is the one exception. It holds URLs of both kinds under one key, and the table cannot interleave across it. In the tree below, the literal sale/ inside the group ranks it level with about/, the group's name sorts first, and its [id]/ then answers /about before about/ is tried. This is the one shape where a declared route can never render, so the build reports it rather than shipping it.
src/routes/
page.tsx
about/
page.tsx
(shop)/
sale/
page.tsx
[id]/
page.tsxroutes/ is not a valid pathname space:
routes/about/page.tsx: "/about" can never match — "/:id" ((shop)/[id]/page.tsx) is declared first and answers itWhat the build refuses
Every problem is reported, not just the first, and each names its file. The build stops with routes/ is not a valid pathname space: followed by one line per problem, and vite dev refuses to start with the same error when the problem is there at startup. A problem introduced while vite dev is running is logged as routes/<path>: <message>, and the server keeps running. While there are problems, .k8ordo/ is not rewritten.
routes/ is not a valid pathname space:
routes/[123]: "[123]" is not a valid param directory — use [name] with a letter or underscore first
routes/products/helper.ts: routes/ holds only page.tsx, layout.tsx, not-found.tsx, error.tsx, redirect.ts — move "helper.ts" under a _-prefixed directory| routes/ contains | Error |
|---|---|
products/helper.ts | routes/ holds only page.tsx, layout.tsx, not-found.tsx, error.tsx, redirect.ts — move "helper.ts" under a _-prefixed directory |
[123]/page.tsx | "[123]" is not a valid param directory — use [name] with a letter or underscore first |
(docs/page.tsx | "(docs" is not a valid route group — use (name) |
pro ducts/page.tsx | "pro ducts" cannot be a URL segment — use letters, digits, . _ ~ or - |
[id]/things/[id]/page.tsx | ":id" is already taken by an ancestor — params must be unique within a path |
orphan/layout.tsx and no page below it | has a layout but no page.tsx below it, so it can never render |
empty/error.tsx and no page or redirect below it | declares no route — every directory needs a page.tsx (or redirect.ts) somewhere below it |
(a)/page.tsx and (b)/page.tsx | "/" is already declared by (a)/page.tsx — route groups do not separate URLs |
old/page.tsx and old/redirect.ts | "old" cannot both render page.tsx and redirect — keep one |
(shop)/sale/page.tsx and (shop)/[id]/page.tsx beside about/page.tsx | "/about" can never match — "/:id" ((shop)/[id]/page.tsx) is declared first and answers it |
(shell)/docs/page.tsx and (shell)/not-found.tsx beside about/page.tsx | "/about" can never match — "/*" ((shell)/not-found.tsx) is declared first and answers it |
The shadowing check runs only on a tree with no grammar problems, because a name the grammar refused is not a pattern. Fixing the grammar problems can therefore surface a shadowed route on the next run.
In this mode the table above and shadowed routes are the only reasons the shape of routes/ stops the build: parameter values arrive with the request, so nothing asks for them to be listed.
The generated files
The framework writes .k8ordo/ and keeps it in step with the directories: when vite dev starts and when vite build begins, and again during development whenever a file under routes/ is added, removed or changed. It is generated and not yours to edit — but it is ordinary source using only the router's public API, so it is yours to read.
| File | What it holds |
|---|---|
routes.gen.ts | The table itself (routes), the schemas that run per page pattern (paramSchemas), and the redirects (redirects). Each route file is checked with satisfies against the pattern its directory puts it under. |
register.gen.ts | Wires the table into @k8ordo/router's Register, and into @k8ordo/state's when the application depends on it — which is why the patterns href and useMatch take are checked against the table. |
.gitignore | Contains *: the directory ignores itself, so there is nothing to add to the application's own .gitignore. |
This tree produces the two files below, both excerpted: routes.gen.ts down to its routes export, and register.gen.ts to its declaration for an application that does not depend on @k8ordo/state. The real files begin with a // Generated by … line naming the mode package that wrote them.
src/routes/
layout.tsx
page.tsx
not-found.tsx
products/
page.tsx
[id]/
page.tsx// .k8ordo/routes.gen.ts
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'>,
},
},
'/*': not_found satisfies Page<'/*'>,
},
},
});// .k8ordo/register.gen.ts
import type { ParsedParamsMap } from '@k8ordo/router';
import type { RouteRequest } from '@k8ordo/server/runtime';
import type { paramSchemas, routes } from './routes.gen';
declare module '@k8ordo/router' {
interface Register {
routes: typeof routes;
params: ParsedParamsMap<typeof paramSchemas>;
request: RouteRequest;
}
}tsconfig.json
For the type wiring to apply, list .k8ordo in tsconfig.json's include with a glob. The name starts with a dot, and a bare directory entry silently skips it — the build still works, and href simply stops being checked against the table.
{
"include": ["src/**/*.ts", "src/**/*.tsx", ".k8ordo/**/*.ts"]
}vite build does not type-check; the satisfies checks report through tsc. .k8ordo/ is not in git, so on a fresh checkout run vite dev or vite build once before tsc to write it.
Titles and metadata
There is no metadata API: React 19 hoists a <title>, <meta> or <link> rendered anywhere in the tree into <head>, so a page renders its own title where it renders everything else.
// src/routes/products/[id]/page.tsx
import type { PageProps } from '@k8ordo/router';
export default function ProductPage({ params }: PageProps<'/products/:id'>) {
return (
<>
<title>{`Product ${params.id}`}</title>
<meta content="One product from the catalog" name="description" />
<h1>{params.id}</h1>
</>
);
}Keep one <title> on screen at a time: the root layout renders none, and each page and not-found.tsx renders its own. Two titles are two titles, not a fallback chain — React renders both. This site renders each page's through a PageTitle component.
Links are checked against the table
Navigation in the browser is @k8ordo/router's. Under the Navigation API a plain <a> is already a client navigation, and the pattern and params given to href() are checked against the generated table.
// src/routes/products/page.tsx
import { href } from '@k8ordo/router';
export default function ProductsPage() {
return (
<ul>
<li>
<a href={href('/products/:id', { id: 1 })}>first product</a>
</li>
<li>
<a href={href('/guide')}>guide</a>
</li>
</ul>
);
}How the router behaves under the framework is covered here. Links & location · Under the framework