Parameters
A parameter arrives as a string, and becomes a typed value once a schema says what it expects. In this mode the value arrives with the request: no list is needed, and an unknown value is a real 404.
A parameter arrives as a string
A URL carries nothing but strings, so without a declaration every value in params is one: a page under [id] receives params.id: string.
// src/routes/products/[id]/page.tsx
import type { PageProps } from '@k8ordo/router';
export default function ProductPage({ params }: PageProps<'/products/:id'>) {
return <h1>{params.id.toUpperCase()}</h1>;
}Saying what it expects with a schema
A page.tsx or a layout.tsx may export paramsSchema to say what it expects; a layout's applies to every page below it. It is not called params because the page's own prop is, and a module-level binding of the same name would shadow it.
// src/routes/products/[id]/page.tsx
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 <h1>{params.id.toFixed(0)}</h1>;
}Any library that implements Standard Schema works — zod, zod/mini, another library, or @k8ordo/i18n's locales.paramsSchema. The specification is linked here. Standard Schema
The export is found by parsing the file, so its spelling does not matter: export const paramsSchema = …, export { paramsSchema } and a destructured export const { paramsSchema } = locales all count, while the same word inside a string or a comment, or an export type, does not. Only page.tsx and layout.tsx are read, and a file that does not parse declares nothing.
The schemas along a stack run in order
Before a page renders, the schemas declared along its stack run — every layout above it that declared one, outermost first, then its own. Each replaces the strings it names with what it produced, and a parameter no schema names stays a string.
Below, the [locale] layout validates the locale, the page validates id, and the page receives both results.
// src/i18n.ts
import { defineLocales } from '@k8ordo/i18n';
export const locales = defineLocales(['ja', 'en']);// src/routes/[locale]/layout.tsx
import type { ReactNode } from 'react';
import { locales } from '../../i18n';
export const { paramsSchema } = locales;
export default function LocaleLayout({ children }: { children: ReactNode }) {
return <>{children}</>;
}// src/routes/[locale]/products/[id]/page.tsx
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<'/:locale/products/:id'>) {
return <h1 lang={params.locale}>{params.id.toFixed(0)}</h1>;
}A refused value is a pathname the pattern does not answer
When a schema refuses, /products/shoes does not become a page rendering NaN. The walk goes on as if the pattern had never matched, to whatever the table declares next — in the end not-found.tsx, under a 404.
In this mode a refused value is answered with a genuine 404 status and not-found.tsx as the body — for a document load and for a client navigation's payload alike.
A catch-all's own params are never validated: it answers what nothing else did, and a 404 is already what a refusal means. The params.locale a not-found.tsx at /:locale/* receives can be any string.
The schema is synchronous
Which pattern answers a pathname is decided before anything renders, and that decision cannot wait. A schema that validates asynchronously is refused with the error below. Whether a value has the right shape is the schema's question; whether it exists in your data is the page's.
TypeError: a params schema must validate synchronously — which pattern answers a pathname is decided before anything rendersThe schema lives in a Server Component file
A value exported from a 'use client' module reaches the RSC side as a client reference, not a schema, and the handler cannot run it. A layout that has to be a client component keeps its schema in a Server Component layout.tsx that renders the client shell — which is how this site's [locale] layout is built.
// src/routes/[locale]/layout.tsx
import type { ReactNode } from 'react';
import { locales } from '../../i18n';
import { LocaleShell } from './_parts/locale-shell';
export const { paramsSchema } = locales;
export default function LocaleLayout({
params,
children,
}: {
params: { locale: string };
children: ReactNode;
}) {
return <LocaleShell locale={params.locale}>{children}</LocaleShell>;
}How the shell side is written is covered here. Boundaries
The page and its links take the output
The generated Register carries each pattern's schema output type. params.id in PageProps<'/products/:id'> is a number, and href('/products/:id', { id: 42 }) takes the number too, spelling it the one way the schema will read back; a string or an object there is a type error.
// src/routes/products/page.tsx
import { href } from '@k8ordo/router';
export default function ProductsPage() {
return (
<ul>
{[1, 2, 3].map((id) => (
<li key={id}>
<a href={href('/products/:id', { id })}>
{`product ${String(id)}`}
</a>
</li>
))}
</ul>
);
}The generated table checks each schema with satisfies ParamsSchemaFor<pattern>, and only loosely: on a pattern that has params, a schema naming none of them is an error tsc reports in .k8ordo/routes.gen.ts, while one naming a real param beside a key the pattern lacks passes. Such a key is not harmless — a schema that requires a param its pattern does not have refuses every pathname, so that page never answers. vite build does not type-check, so even the error above surfaces only under tsc.
A layout's params are typed as strings
A layout's params are typed as strings — in LayoutProps and in the generated Layout — even when it declared a schema, because the same layout also renders around not-found.tsx, where nothing is validated. The run-time value does not follow that type: around a page the layout receives the page's parsed params (a number where a schema coerced one), and around not-found.tsx the raw strings. Do not call a string method on a param because the type says string; a layout that needs one form converts the value itself (String(params.id)), or leaves the typed value to the pages below.
// src/routes/products/[id]/layout.tsx
import type { LayoutProps } from '@k8ordo/router';
export default function ProductLayout({
params,
children,
}: LayoutProps<'/products/:id'>) {
return <section data-product={params.id}>{children}</section>;
}LayoutProps<pattern> takes only a pattern the table has a page for, since its constraint is the page patterns. A layout with no page at its own prefix — a shop/layout.tsx whose pages all sit under shop/[id]/, say — declares its props inline.
No list of values
A parameter's value arrives with the request, so there is nothing to enumerate ahead of time, and a catalogue that changes needs no rebuild. The page is a Server Component: it takes the value and reads its data.
// src/routes/_data/catalog.server.ts
import 'server-only';
export type Product = { id: number; name: string };
const CATALOG: readonly Product[] = [
{ id: 1, name: 'first product' },
{ id: 2, name: 'second product' },
];
export const findProduct = (id: number): Product | undefined =>
CATALOG.find((product) => product.id === id);// src/routes/products/[id]/page.tsx
import type { PageProps } from '@k8ordo/router';
import * as z from 'zod/mini';
import { findProduct } from '../../_data/catalog.server';
export const paramsSchema = z.object({
id: z.coerce.number().check(z.int(), z.positive()),
});
export default function ProductPage({ params }: PageProps<'/products/:id'>) {
const product = findProduct(params.id);
const name = product?.name ?? 'unknown product';
return (
<>
<title>{name}</title>
<h1>{name}</h1>
</>
);
}The schema checks the shape; the page, existence
A value the schema accepts need not exist in your data. /products/999 passes the schema, so the page renders under a 200. A page has no API that sets the status, so a missing product is something the page renders. The schema is synchronous, so it cannot make a query that has to wait.