Parameters
A page under a directory such as [id] receives part of its URL as a parameter. A schema types the value. Under static mode paths lists the URLs, and under server mode search declares the query.
On this page
Parameters as strings
src/routes/products/[id]/page.tsximport type { PageProps } from '@k8ordo/framework';
export default function ProductPage({
params,
}: PageProps<'/products/:id'>) {
return <h1>{params.id.toUpperCase()}</h1>;
}A page under an [id] directory receives that URL segment as params.id. A URL holds only strings, so without a declaration the value is a string.
Typing with a schema
src/routes/products/[id]/page.tsximport type { PageProps } from '@k8ordo/framework';
import * as z from 'zod/mini';
export const paramsSchema = z.object({
The page’s prop is params, so the export is paramsSchema
id: z.coerce.number().check(z.int(), z.positive()),
});
export default function ProductPage({
params,
}: PageProps<'/products/:id'>) {
return <h1>{params.id.toFixed(0)}</h1>;
}Export paramsSchema from page.tsx, and the schema validates the value before the page renders. The page receives the converted value, and params.id in PageProps<'/products/:id'> becomes a number. The type comes from the generated route table, so you never write the type of params by hand.
Anything that implements Standard Schema works: zod, zod/mini, or locales.paramsSchema from @k8ordo/i18n. The export is found by parsing the file, so a destructured export const { paramsSchema } = locales works too.
Pitfall
The schema has to validate synchronously. An asynchronous one fails with a params schema must validate synchronously.
Export paramsSchema from a file without 'use client'. A value exported from a 'use client' module reaches the server as a client reference, which cannot be read as a schema.
Layout schemas
src/routes/[locale]/layout.tsximport 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.tsximport type { PageProps } from '@k8ordo/framework';
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 layout.tsx can export paramsSchema too. Before the page renders, the schemas run from the outermost layout down, and the page’s own runs last.
Each schema replaces only the parameters among its keys. A parameter in no schema stays a string.
Here the [locale] layout validates the locale and the page validates id. The page receives both results in params.
Values the schema rejects
When the schema rejects a value, the route is treated as if it had not matched the URL. /products/shoes never becomes a page holding NaN. The next candidate in the route table is tried, and in the end not-found.tsx is rendered with a 404.
Under static mode that 404 is a single file, 404.html. Under server mode the response is a 404 status with not-found.tsx as its body, both for a document load and for a client navigation’s payload request.
A not-found.tsx rejects nothing. The params.locale that the one at /:locale/* receives can be a string such as fr that names no locale. Check it before using it.
Values not in your data
src/routes/products/[id]/page.tsximport { notFound } from '@k8ordo/framework';
import type { PageProps } from '@k8ordo/framework';
import * as z from 'zod/mini';
import { findProduct } from '../../../lib/catalog.server';
export const paramsSchema = z.object({
id: z.coerce.number().check(z.int(), z.positive()),
});
export default async function ProductPage({
params,
}: PageProps<'/products/:id'>) {
const product = await findProduct(params.id);
if (product === undefined) notFound();
return <h1>{product.name}</h1>;
}A schema checks only the shape of the value. Under server mode, for /products/999, which your data does not have, the page gives a 404 with notFound(). Under static mode a value missing from paths is never written, and 404.html answers it, unless a fallback.tsx beside the page answers it with a page rendered in the browser (see “Values the build did not write”). A page that calls notFound() for a value in paths stops the build (see “paths errors”). What notFound() does is covered in Errors and redirects.
Link types
src/routes/products/page.tsximport { href } from '@k8ordo/framework';
const ids = [1, 2, 3];
export default function ProductsPage() {
return (
<ul>
{ids.map((id) => (
<li key={id}>
<a href={href('/products/:id', { id })}>Product {id}</a>
</li>
))}
</ul>
);
}The generated route table carries each pattern’s schema output type. href() takes the same type the page receives, so href('/products/:id', { id: 42 }) takes a number. A string there is a type error.
href() writes the value into the URL as a string the schema converts back to the same value.
Layout params
A layout’s params are typed as strings even when it declares a schema.
At run time the value is a string too, as the type says. A layout receives the URL’s strings whether it wraps a page or not-found.tsx; only the page receives what the schema produced. When you need the converted value, take it in the page, or run the schema inside the layout.
URLs for paths
vite.config.tsimport { framework } from '@k8ordo/framework/vite';
import { defineConfig } from 'vite';
import { listProductIds } from './src/lib/catalog';
export default defineConfig({
plugins: [
framework({
mode: 'static',
paths: async () => {
const ids = await listProductIds();
return ids.map((id) => `/products/${String(id)}`);
},
}),
],
});Static mode only. A build cannot invent parameter values, so the URLs of routes with parameters are passed through the paths option of framework(). Under server mode the value comes from the request, and growing data needs no rebuild.
paths is a function that receives the patterns needing values and returns an array of URLs, or a promise of one. Routes without parameters come from the route table and need nothing. A mistake in the URLs it returns stops the build. The messages are listed under “paths errors”.
A URL may be escaped the way href() returns it. Under a Vite base, href() puts the base in front, so strip it before passing the URL. A redirect.ts or route.ts under a parameter is written as a file too, so it takes its values from paths as well.
Locale paths
vite.config.tsimport { framework } from '@k8ordo/framework/vite';
import { defineConfig } from 'vite';
import { locales } from './src/i18n';
export default defineConfig({
plugins: [framework({ mode: 'static', paths: locales.paths })],
});locales.paths from @k8ordo/i18n expands every pattern with /:locale once per locale. How locales is defined is described on Define locales.
This site’s vite.config.ts passes locales.paths as it is. Each URL is rendered as a request of its own, so its messages use that URL’s locale.
Partial expansion
const slugs = ['hello-world', 'second-post'];
framework({
mode: 'static',
paths: (patterns) =>
locales.paths(patterns).flatMap((pathname) =>
pathname.endsWith('/:slug')
? slugs.map((slug) => pathname.replace(':slug', slug))
: [pathname],
),
});Expanding only one of two parameters leaves paths such as /ja/blog/:slug. That is not a URL, so the build stops. Expand the remaining parameter in the same function before returning. For a page with a fallback.tsx beside it, though, such a path is where its shell goes (see “Values the build did not write”).
paths errors
The build stops when paths hits any of these. Each error message includes the pattern or URL at fault.
- A route no URL covers:
static build needs pathnames for /products/:id — supply them with the "paths" option - A URL no route matches:
the "paths" option supplied pathnames no route wants: /produtcs/2 - A path with a parameter left in it:
the "paths" option supplied pathnames that still hold a parameter, and only a page with a fallback.tsx beside it takes one: /ja/blog/:slug ([locale]/blog/[slug]/page.tsx has none) - A URL the schema rejects:
the "paths" option supplied pathnames a params schema refused: /products/shoes - A URL whose page calls
notFound():the "paths" option supplied pathnames whose page called notFound(): /products/3 - A URL with an escape that does not decode:
the "paths" option supplied a pathname with a malformed escape: /products/%zz - A URL that, decoded, points outside the output:
the "paths" option supplied a pathname that leaves the output: /products/..%2F..
Values the build did not write
src/routes/posts/[id]/fallback.tsximport { Suspense } from 'react';
import { PostFromUrl } from '../../../components/post-from-url';
export default function PostFallback() {
return (
<Suspense fallback={<p>Loading the post…</p>}>
<PostFromUrl />
</Suspense>
);
}src/components/post-from-url.tsx'use client';
import { notFound, useMatch } from '@k8ordo/framework';
import { use } from 'react';
import { postId } from '../lib/post-params';
import { readPost } from '../lib/posts';
import { PostArticle } from './post';
export function PostFromUrl() {
const match = useMatch('/posts/:id');
if (match === null) return null;
The URL moved before the next page arrived
const id = postId.safeParse(match.id);
if (!id.success) notFound();
const post = use(readPost(id.data));
if (post === null) notFound();
return <PostArticle post={post} />;
}src/lib/posts.tsimport type { Post } from '../components/post';
const API = 'https://api.example.com/posts';
const posts = new Map<number, Promise<Post | null>>();
const load = async (id: number): Promise<Post | null> => {
const response = await fetch(`${API}/${String(id)}`);
if (response.status === 404) return null;
if (!response.ok) throw new Error(response.statusText);
return (await response.json()) as Post;
};
export const readPost = (id: number): Promise<Post | null> => {
const cached = posts.get(id);
if (cached !== undefined) return cached;
const post = load(id);
posts.set(id, post);
return post;
};Static mode only. Put a fallback.tsx beside a page.tsx, and it answers every URL whose value paths did not list. It is for values the build cannot know, such as posts published after it ran. Server mode never renders it: there page.tsx renders every value per request. Its placement is checked in both modes.
The build writes a page holding the layouts with fallback.tsx inside them: the shell. The host answers /posts/3, which has no file, with the shell, and the browser reads the value from the URL and renders the post. The build writes the rewrite rules for this into _redirects; setting up the host is covered in Deployment.
postId and PostArticle are the ones the page uses. readPost reads the API from the browser, and the promise it hands use() is made once per value and reused, never once per render.
What it receives
A fallback.tsx receives no props: a shell has no value to give it. It may be a Server Component or a 'use client' one. A Server Component runs at build time, so it renders the same thing for every value.
A Client Component reads the value. Inside a shell, whatever reads useMatch or usePathname is left to the browser by the server render, so the HTML holds the fallback of the <Suspense> in fallback.tsx. useMatch returns null when the URL has moved before the next page arrived; render nothing then.
A page’s paramsSchema does not run for a shell, so check the value in the browser with the same schema. A layout’s paramsSchema does run, over the parameters the shell fills, which is why a locale schema belongs on a layout rather than a page.
Where shells go
const ids = ['1', '2'];
framework({
mode: 'static',
paths: (patterns) =>
locales.paths(patterns).flatMap((pathname) =>
pathname.endsWith('/posts/:id')
? [pathname, ...ids.map((id) => pathname.replace(':id', id))]
Keeps this path as a shell’s location
: [pathname],
),
});When a layout such as [locale]/layout.tsx receives a parameter, the shell needs that value too, since the layout is written as HTML. Return the path with only :id left as well, /ja/posts/:id, as this example does. Each such path is where a shell goes, one per locale.
When no layout above the page receives a parameter, paths returns just the values to write. The build makes the shell from the pattern and writes it at /posts/!fallback/.
A fallback.tsx answers only for the page.tsx beside it. A route below it, such as /posts/:id/comments, still takes every value from paths, unless it is a page with a fallback.tsx of its own.
Note
vite dev answers a value paths does not list with the shell too, so you can try it without building. It calls paths only when the application has a fallback.tsx, and again only when the patterns change.
notFound() in a shell
When a Client Component in the shell calls notFound() while it renders, the nearest not-found.tsx renders in the page’s place, and the URL stays. The build rendered that not-found.tsx along with the shell, so nothing more is requested. Since it is the same for every value, a not-found.tsx that would receive a parameter the shell leaves to the browser, such as one beside the fallback.tsx, is passed over for the nearest one above it. It also receives the shell’s pathname (/ja/posts/!fallback), so read the visitor’s URL in a Client Component when it shows it.
The host has already answered with the shell under a 200, though, so a value your data does not have still gets a 200. And notFound() works only during render: called from an effect or an event handler, no boundary sees it.
Shell errors
The build stops when a shell’s location or its render goes wrong:
- A pattern with no shell location while a layout above receives a parameter:
static build needs shell locations for /:locale/posts/:id — [locale]/layout.tsx receives :locale, … - A location that leaves out a parameter a layout above receives:
the "paths" option supplied shell locations that leave out a parameter a layout above their fallback.tsx receives: /:locale/posts/:id (…) - A parameter named differently from the pattern’s:
the "paths" option supplied shell locations whose parameter names are not their pattern's: /en/posts/:slug (/:locale/posts/:id) - A location a layout’s schema rejects:
the "paths" option supplied shell locations a params schema refused: /xx/posts/:id - A path with a segment named
!fallback:the "paths" option supplied pathnames with a segment named !fallback, … - A shell that would also answer URLs a route declared earlier is for:
the shell for /ja/posts/:id ([locale]/posts/[id]/fallback.tsx) would also answer … - A shell that calls
notFound()while the build renders it:the shell for /ja/posts/:id called notFound() while it rendered, … - A shell another route answered:
the shell for /ja/posts/:id (…) was answered by …
How to fix each is covered in Troubleshooting.
What a shell gives up
Compared with a page the build wrote, a value the shell answers gives up the following.
- The body is rendered in the browser. No Server Component renders it per value.
- The first HTML has no body, and no per-value
<title>or OGP either, so a crawler that runs no JavaScript sees none of it. - A value your data does not have is still answered with a
200. Render<meta name="robots" content="noindex">innot-found.tsxto keep it out of search results. - The data has to be on an API the browser can reach. If you set
csp, add the API’s origin toconnect-src. - Layouts receive the shell’s URL,
/posts/!fallback, aspathname. Render anything made from the URL, such as a canonical link orhreflang, in a Client Component. The build warns when!fallbackis left in the HTML. - It needs a host that rewrites. On GitHub Pages, a value the build did not write gets
404.html.
Reading the query
src/routes/products/page.tsximport type { PageProps } from '@k8ordo/framework';
import { ProductList } from '../../components/product-list';
import { fetchProducts } from '../../lib/catalog.server';
import { listState } from '../../lib/list-state';
export const search = listState.url;
The query schema this page reads
export default async function ProductsPage({
search,
}: PageProps<'/products'>) {
const products = await fetchProducts(search);
return <ProductList initialUrl={search} products={products} />;
ProductList hands it to useAppState as initialUrl
}Server mode only. A page declares the query it reads by exporting a schema as search, and receives the validated values as search. A layout, and a page that does not declare search, never receives the query.
A missing parameter takes its default, and a value the schema rejects resets that field alone to its default. The search export is the url schema of a definePageState from @k8ordo/state, so the application depends on @k8ordo/state. Defining one is described on URL.
Pass the values on to useAppState as initialUrl. The server render and the hydration both use the values from the URL. A navigation that changes the query loads that page again in place; nothing remounts, and the scroll position stays.