Under the framework
@k8ordo/static and @k8ordo/server are built on this router. The table is generated from src/routes/, and pages render on the server. This page covers which parts of the router an application uses under the framework and which it does not, the types of a route file’s props, and where what the application writes ends and what is generated begins.
The browser holds no table
Under the framework pages render on the server, and the browser receives a tree instead of building one from a table. There is no route table in the client bundle at all, and layouts nest through children instead of <Outlet />.
Navigation is still this router’s. The framework’s runtime is built on useInterceptedNavigation: it claims same-origin URLs, fetches the next page’s RSC payload in load, and renders it in the background. finished meaning on screen, state changes not being page changes, scrolling, transition types and aborting superseded navigations all carry over unchanged. Navigation
| API | An application that mounts <Router> | @k8ordo/static / @k8ordo/server |
|---|---|---|
defineRoutes | Written by hand | Generated from src/routes/ into .k8ordo/routes.gen.ts |
<Router> / <Outlet /> | You use them | Not used: the runtime renders, and layouts nest through children |
href / navigateTo / bindParams | You use them | The same, with params typed by the schemas |
usePathname / useMatch / matchPath / normalizePathname | You use them | The same |
useParams / useRoute | Used; params are always strings | Not available: a page receives params as a prop |
Register | Written by hand in types/ | Generated into .k8ordo/register.gen.ts |
ErrorComponent | An error key on a branch | error.tsx becomes the table’s error |
PathnameProvider / NavigationGeneration / useInterceptedNavigation | Mounted by <Router> | Mounted by the mode’s runtime |
PageProps / LayoutProps | — | The types of a route file’s props |
usePathname and useMatch are the location hooks
usePathname and useMatch read the platform rather than a table, so they work unchanged under the framework. useRoute and useParams read the match from context, and there is no match in the browser to read, so they throw.
// src/routes/_parts/section-nav.tsx
'use client';
import { href, useMatch, usePathname } from '@k8ordo/router';
export function SectionNav() {
const pathname = usePathname();
const inProducts = useMatch('/products/*', { inclusive: true }) !== null;
return (
<nav>
<a aria-current={pathname === '/' ? 'page' : undefined} href={href('/')}>
Home
</a>
<a
aria-current={inProducts ? 'true' : undefined}
href={href('/products')}
>
Products
</a>
</nav>
);
}Hooks work only in client components. A page or layout that is a Server Component receives the pathname of its render as a prop.
This site’s own sidebar is decided the same way: the locale shell, a client component, asks useMatch('/:locale/ui/components/*') whether a component page is showing. The pattern comes from the generated table, so renaming the section fails to compile. Links & location
PageProps and LayoutProps
The props a route file receives are typed by the pattern its directory stands for: src/routes/products/[id]/page.tsx is /products/:id.
PageProps<P> is { params, pathname }. params has the types the schemas along the page’s stack produced, and a param no schema covers stays a string. pathname is the pathname this render is for.
// src/routes/products/[id]/page.tsx
import { href } from '@k8ordo/router';
import type { PageProps } from '@k8ordo/router';
import * as z from 'zod/mini';
export const paramsSchema = z.object({
id: z.coerce.number().check(z.int(), z.positive()),
});
export default function ProductPage({ params }: PageProps<'/products/:id'>) {
return (
<article>
<h1>Product {params.id}</h1>
<a href={href('/products/:id', { id: params.id + 1 })}>Next product</a>
</article>
);
}LayoutProps<P> adds children (the example below assumes src/routes/products/page.tsx exists too). LayoutProps types a layout’s params as strings (ParamsOf<P>) whatever the schemas declare, because nothing is validated under not-found.tsx. At run time, though, a layout rendered for a validated page receives the schemas’ output, so do not rely on the values being strings.
// src/routes/products/layout.tsx
import type { LayoutProps } from '@k8ordo/router';
export default function ProductsLayout({ children }: LayoutProps<'/products'>) {
return (
<section>
<h1>Products</h1>
{children}
</section>
);
}Under @k8ordo/server the generated Register carries the request too, so both types gain request. A build into files has no request, so a page that reads it fails to type-check under @k8ordo/static.
// src/routes/page.tsx
import type { PageProps } from '@k8ordo/router';
export default function HomePage({ request }: PageProps<'/'>) {
return <p>{request.headers.get('accept-language') ?? '-'}</p>;
}P must be a pattern the generated table has a page at, so a layout with no page of its own at that prefix declares its props inline. Inline props are checked by the generated table at the import all the same.
paramsSchema and typed params
A page or layout can export a paramsSchema to say how its params are read. The router itself never runs a schema — the framework does — and this package types what comes out.
The generated Register carries, for each pattern a schema covers, the schema output as params, so href and navigateTo take a param as the page receives it and spell it the one way the schema reads back: an :id the schema made a number takes { id: 42 }, and a string there is a type error. On such a pattern, a param the schemas leave alone takes a string, as the page receives it. Before the file is generated, or for a pattern no schema along its stack covers, any value with one spelling is accepted.
import { href } from '@k8ordo/router';
href('/products/:id', { id: 42 });
// @ts-expect-error
href('/products/:id', { id: '42' });The generated file looks like this, taken from this site:
// .k8ordo/register.gen.ts
import type { ParsedParamsMap } from '@k8ordo/router';
import type { paramSchemas, routes } from './routes.gen';
declare module '@k8ordo/router' {
interface Register {
routes: typeof routes;
params: ParsedParamsMap<typeof paramSchemas>;
}
}
declare module '@k8ordo/state' {
interface Register {
routes: typeof routes;
}
}| Type | Meaning |
|---|---|
ParsedParams<Pattern, Schemas> | A pattern’s params after a list of schemas — outer layouts first, the page last — each replacing the strings it names with what it produced |
ParsedParamsMap<Schemas> | { pattern: schemas } turned into { pattern: ParsedParams }: the params of the generated Register |
ParamsSchemaFor<Pattern> | What may serve as a schema for the pattern: its output’s keys are a subset of the pattern’s params |
StandardSchemaLike<Output> | The Standard Schema shape (types.output under ~standard); any library that implements it — zod, zod/mini or another |
SchemaOutput<Schema> | What a schema produces |
RegisteredPageParams<P> | The params a page under the pattern receives: PageProps’s params |
These are mostly for the generated code; what an application writes by hand is PageProps and LayoutProps. paramsSchema in depth, in @k8ordo/static
PathnameProvider is never the application’s
A client component’s first render happens where there is no Navigation API — on the server, and again during hydration — so usePathname’s value has to arrive from the renderer that knew it, through <PathnameProvider pathname>. <Router> mounts one itself, and both mode runtimes mount one; an application never writes it. Only a host building its own seam out of useInterceptedNavigation does.
Calling usePathname on the server or during hydration with none of them above it throws:
usePathname needs <Router> above it, or a page rendered by @k8ordo/static or @k8ordo/serverHydration renders with the server’s pathname and then switches to the browser’s; when the two differ, that is one re-render, not a mismatch. This site’s single 404.html answers for every locale, and this re-render is how it switches to the locale of the URL it is shown at.
What the application writes, and what is generated
Under the framework, much of what touches the router is generated or lives in the runtime.
The application writes
- The route files under
src/routes/(page.tsx,layout.tsx,error.tsx,not-found.tsx,redirect.ts) and theirparamsSchema - Links with
href/navigateTo, and abindParamsmodule for params every link shares usePathname/useMatchin client components- A
<ViewTransition>around a layout’schildren, to animate page changes
Generated, or part of the runtime
.k8ordo/routes.gen.ts: thedefineRoutestable and each pattern’s list of schemas.k8ordo/register.gen.ts: this router’sRegister— and@k8ordo/state’s, when the application depends on it- The runtime:
useInterceptedNavigation,<NavigationGeneration>and<PathnameProvider>
The application never writes
<Router>,<Outlet />,useRoute,useParams, or a hand-writtenRegister— a second answer to one already generated
For the generated types to apply, put the glob .k8ordo/**/*.ts in include in tsconfig.json. .k8ordo starts with a dot, and a bare directory entry silently skips it — href simply stops being checked against the table.
{
"include": ["src/**/*.ts", "src/**/*.tsx", ".k8ordo/**/*.ts"]
}Animating page changes
A layout wraps its children in <ViewTransition>. React sends a <ViewTransition> through the RSC payload as it is, so a Server Component layout can render it directly; this site keeps it in a client component only because its shell uses hooks.
// src/routes/layout.tsx
import type { LayoutProps } from '@k8ordo/router';
import { ViewTransition } from 'react';
import { SectionNav } from './_parts/section-nav';
export default function RootLayout({ children }: LayoutProps<'/'>) {
return (
<html lang="en">
<body>
<SectionNav />
<ViewTransition
default="none"
update={{ navigation: 'auto', default: 'none' }}
>
{children}
</ViewTransition>
</body>
</html>
);
}