@k8ordo/static

Parameters

A parameter arrives as a string, and becomes a typed value once a schema says what it expects. In this mode the build has to know every value ahead of time, which is what paths supplies.

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 that 404 is one file, 404.html. A pathname supplied through paths that a schema refuses stops the build instead, since it would write a 404 page under a URL the site claims to have (see "When the build stops" below).

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.

paths: handing the build its parameter values

Rendering ahead of time cannot invent parameter values, so the build asks for them. The paths option of framework() is a function that receives the patterns still needing values and returns the concrete pathnames — an array, or a promise of one.

// vite.config.ts
import { readFile } from 'node:fs/promises';

import { framework } from '@k8ordo/static';
import { defineConfig } from 'vite';

type Product = { id: number };

export default defineConfig({
  plugins: [
    framework({
      paths: async () => {
        const products = JSON.parse(
          await readFile('data/products.json', 'utf8'),
        ) as Product[];
        return products.map(
          (product) => `/products/${String(product.id)}`,
        );
      },
    }),
  ],
});

Routes without parameters are taken from the table and need no declaration; supplying one anyway is redundant, not wrong. A trailing slash is ignored, as the router ignores it, and the escaped pathname href() returns is accepted as is — /products/caf%C3%A9 is written to products/café/index.html.

A redirect.ts under a parameter — [locale]/legacy/redirect.ts, say — is among the patterns handed in too: a redirect is a URL the site has, written as a file. The pattern of a not-found.tsx is not.

A parameter that takes the same values everywhere

Because the patterns are handed in, a parameter that takes the same values everywhere — a locale segment — is expanded rather than listed once per page. The example below looks segment by segment and replaces :locale alone; a pattern without one is handed back as it is, and if that pattern still has a parameter, the build names it and asks for values.

// vite.config.ts
import { framework } from '@k8ordo/static';
import { defineConfig } from 'vite';

export default defineConfig({
  plugins: [
    framework({
      paths: (patterns) =>
        patterns.flatMap((pattern) => {
          const segments = pattern.split('/');
          if (!segments.includes(':locale')) return [pattern];
          return ['ja', 'en'].map((locale) =>
            segments
              .map((segment) => (segment === ':locale' ? locale : segment))
              .join('/'),
          );
        }),
    }),
  ],
});

This site's vite.config.ts passes @k8ordo/i18n's locales.paths as is: a function that expands every pattern with a /:locale segment once per locale and leaves any other parameter in place.

// vite.config.ts
import { framework } from '@k8ordo/static';
import tailwindcss from '@tailwindcss/vite';
import { defineConfig } from 'vite';

import { locales } from './src/i18n';

export default defineConfig({
  plugins: [
    framework({
      site: 'https://ordo.k8o.me',
      paths: locales.paths,
    }),
    tailwindcss(),
  ],
});

Expanding only one of two parameters leaves values like /ja/blog/:slug behind. That is not a pathname, so it is refused, and /:locale/blog/:slug stays uncovered — expand the remaining parameter yourself before returning.

// vite.config.ts
import { framework } from '@k8ordo/static';
import { defineConfig } from 'vite';

import { locales } from './src/i18n';

const slugs = ['hello-world', 'second-post'];

export default defineConfig({
  plugins: [
    framework({
      paths: (patterns) =>
        locales
          .paths(patterns)
          .flatMap((pathname) => {
            const segments = pathname.split('/');
            if (!segments.includes(':slug')) return [pathname];
            return slugs.map((slug) =>
              segments
                .map((segment) => (segment === ':slug' ? slug : segment))
                .join('/'),
            );
          }),
    }),
  ],
});

When the build stops

A site quietly missing half its pages is worse than a build that stopped. Around paths, the build stops in these cases. The first three name every pattern or pathname involved; the two about escapes name the first one found.

WhenError
No supplied pathname covers a parameterised patternstatic build needs pathnames for /products/:id — supply them with the "paths" option
A supplied pathname no pattern matches — a typo, or a value still holding a parameterthe "paths" option supplied pathnames no route wants: /produtcs/2
A supplied pathname its page's schema refusesthe "paths" option supplied pathnames a params schema refused: /products/shoes
A pathname with an escape that cannot be decodedthe "paths" option supplied a pathname with a malformed escape: /products/%zz
A pathname that, decoded, points outside the output directorythe "paths" option supplied a pathname that leaves the output: /products/..%2F..