@k8ordo/server

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 renders

The 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.