パラメータ
[id]のようなディレクトリの下のページは、URLの一部をパラメータとして受け取ります。スキーマで型を付け、staticモードではpathsでURLの一覧を、serverモードではsearchでクエリを宣言します。
このページの内容
文字列のパラメータ
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>;
}[id]のディレクトリの下のページは、URLのその区間をparams.idとして受け取ります。URLは文字列しか表せないので、何も宣言しなければ値は文字列です。
スキーマによる型付け
src/routes/products/[id]/page.tsximport type { PageProps } from '@k8ordo/framework';
import * as z from 'zod/mini';
export const paramsSchema = z.object({
ページのpropがparamsなので、exportの名前は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>;
}page.tsxからparamsSchemaをexportすると、ページを描画する前にスキーマが値を検証します。ページは変換後の値を受け取り、PageProps<'/products/:id'>のparams.idはnumberになります。型は生成されたルート表から引くので、paramsの型を手で書く必要はありません。
スキーマには、Standard Schemaを実装したものを使えます。zodやzod/miniのほか、@k8ordo/i18nのlocales.paramsSchemaも使えます。exportはファイルの構文解析で見つけるので、export const { paramsSchema } = localesのような分割代入でも構いません。
落とし穴
スキーマは同期的に検証するものにします。非同期のスキーマはa params schema must validate synchronouslyというエラーになります。
paramsSchemaは'use client'の無いファイルからexportします。'use client'のモジュールからexportした値は、サーバー側にはクライアントの参照として渡り、スキーマとして読めません。
レイアウトのスキーマ
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>;
}layout.tsxもparamsSchemaをexportできます。ページを描画する前に、上のレイアウトから順にスキーマが走り、最後にページ自身のスキーマが走ります。
それぞれのスキーマは、自分のキーにあるパラメータだけを置き換えます。どのスキーマのキーにも無いパラメータは、文字列のまま残ります。
この例では、[locale]のレイアウトがロケールを、ページがidを検証します。ページは両方の結果をparamsで受け取ります。
スキーマに合わない値
スキーマに合わない値は、URLがそのルートに一致しなかったものとして扱われます。/products/shoesがNaNを持つページになることはありません。ルート表の次の候補が試され、最後はnot-found.tsxを404で返します。
staticモードでは、その404は404.htmlという1つのファイルです。serverモードでは、404のステータスでnot-found.tsxを返します。文書を読み込むときも、クライアント側の遷移でペイロードを要求するときも同じです。
not-found.tsx自身はどんな値も拒みません。/:locale/*のnot-found.tsxが受け取るparams.localeは、frのように、どのロケールとも一致しない文字列のこともあります。使う前に確かめてください。
データに無い値
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>;
}スキーマが確かめるのは値の形だけです。serverモードでは、データに無い/products/999をページがnotFound()で404にします。staticモードでは、pathsに無い値のページは書き出されず、404.htmlが返ります。横にfallback.tsxを置けば、ブラウザで描くページがその値に答えます(「ビルドしていない値」の節)。pathsに入れた値のページがnotFound()を呼ぶと、ビルドできません(「pathsのエラー」の節)。notFound()の動きはエラーとリダイレクトを見てください。
リンクの型
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>
);
}生成されたルート表は、パターンごとにスキーマの出力の型を持ちます。href()もページと同じ型の値を受け取るので、href('/products/:id', { id: 42 })には数値を渡します。文字列を渡すと型エラーです。
href()は値を文字列にしてURLに書きます。その文字列は、スキーマで元の値に戻せる形です。
レイアウトのparams
レイアウトが受け取るparamsは、スキーマを宣言していても文字列として型が付きます。
実行時の値も型のとおり文字列です。レイアウトは、ページを包むときもnot-found.tsxを包むときも、URLの文字列をそのまま受け取ります。スキーマが変換した値を受け取るのはページだけです。変換した値が要るときは、ページで受け取るか、レイアウトの中でスキーマを通してください。
pathsで渡すURL
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モードだけで使えます。ビルドはパラメータの値を自分では決められないので、パラメータを持つルートのURLはframework()のpathsで渡します。serverモードでは値をリクエストから受け取ります。データが増えても再ビルドは要りません。
pathsは値の要るパターンの一覧を受け取り、URLの配列かそのPromiseを返す関数です。パラメータの無いルートはルート表から分かるので、渡す必要はありません。pathsが返すURLに誤りがあるとビルドできません。エラー文は「pathsのエラー」の節にまとめています。
URLはhref()が返すエスケープ済みの形でも受け付けます。Viteのbaseを設定しているときは、href()の返す値にもbaseが付いているので、それを除いて渡します。パラメータの下にあるredirect.tsとroute.tsも、ファイルとして書き出すので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 })],
});@k8ordo/i18nのlocales.pathsは、/:localeを持つパターンをロケールの数だけ展開する関数です。localesの定義はロケールを定義するを見てください。
このサイトのvite.config.tsも、locales.pathsをそのまま渡しています。書き出すURLは1つずつ別のリクエストとして描画されるので、文言はそのURLのロケールになります。
一部だけの展開
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],
),
});パラメータを2つ持つパターンで片方だけを展開すると、/ja/blog/:slugのようなパスが残ります。これはURLではないので、ビルドがエラーになります。残ったパラメータも、同じ関数の中で展開してから返します。ただし、横にfallback.tsxのあるページなら、このパスはシェルの場所になります(「ビルドしていない値」の節)。
pathsのエラー
pathsが次のどれかに当たると、ビルドできません。エラー文には、原因のパターンかURLが入ります。
- 当てはまるURLが無いルート:
static build needs pathnames for /products/:id — supply them with the "paths" option - どのルートとも一致しないURL:
the "paths" option supplied pathnames no route wants: /produtcs/2 - パラメータの残ったパス:
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) - スキーマに合わないURL:
the "paths" option supplied pathnames a params schema refused: /products/shoes - ページが
notFound()を呼ぶURL:the "paths" option supplied pathnames whose page called notFound(): /products/3 - デコードできないエスケープを含むURL:
the "paths" option supplied a pathname with a malformed escape: /products/%zz - デコードすると出力の外を指すURL:
the "paths" option supplied a pathname that leaves the output: /products/..%2F..
ビルドしていない値
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;
次のページより先にURLが変わったとき
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モードだけで使えます。page.tsxの横にfallback.tsxを置くと、pathsに無い値のURLにはこれが答えます。ビルドの後に増えた記事のように、ビルドの時点で並べられない値に使います。serverモードでは描かれず、page.tsxがどの値もリクエストごとに描きます。置き場所の検査は、どちらのモードでも行います。
ビルドは、レイアウトの中にfallback.tsxを描いたページを書きます。これをシェルと呼びます。ホスティングはファイルの無い/posts/3にシェルを返し、ブラウザがURLの値を読んで本文を描きます。そのための書き換えのルールは、ビルドが_redirectsに書きます。ホスティングの設定はデプロイを見てください。
postIdとPostArticleは、ページと同じものを使います。readPostはブラウザからAPIを読みます。use()に渡すPromiseは描画のたびに作らず、値ごとに1つを使い回します。
受け取るもの
fallback.tsxはpropsを受け取りません。シェルは値を持たないためです。Server Componentにも'use client'のコンポーネントにもできます。Server Componentはビルドのときに動くので、どの値にも同じものを描きます。
値を読むのはClient Componentです。シェルの中では、useMatchとusePathnameを読む部分をサーバーで描かず、ブラウザに任せます。そのためHTMLには、fallback.tsxの<Suspense>のfallbackが入ります。useMatchがnullを返すのは、次のページを受け取る前にURLだけが変わったときです。そのときは何も描きません。
ページのparamsSchemaは、シェルでは動きません。値は、ブラウザで同じスキーマを通して確かめます。一方でレイアウトのparamsSchemaは、シェルが埋めたパラメータに対して動きます。そのためロケールのスキーマは、ページでなくレイアウトに置きます。
シェルの場所
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))]
このパスをシェルの場所として残す
: [pathname],
),
});[locale]/layout.tsxのようにパラメータを受け取るレイアウトがあると、シェルでもその値が要ります。レイアウトをHTMLとして書くためです。この例のように、:idだけを残した/ja/posts/:idの形のパスも返します。これがシェルの場所になり、ロケールごとにシェルが書かれます。
上にパラメータを受け取るレイアウトが無ければ、pathsは書き出す値だけを返せば足ります。シェルはビルドがパターンから作り、/posts/!fallback/に書きます。
fallback.tsxが答えるのは、横のpage.tsxのURLだけです。/posts/:id/commentsのように下にあるルートは、これまでどおり値をpathsに並べます。下のページにもfallback.tsxを置けば、そのページのシェルが書かれます。
メモ
vite devも、pathsに無い値にはシェルで答えます。ビルドしなくても、手元で試せます。vite devがpathsを呼ぶのはfallback.tsxのあるアプリだけで、パターンが変わったときに読み直します。
シェルでのnotFound()
シェルの中のClient Componentが描画中にnotFound()を呼ぶと、いちばん近いnot-found.tsxがページの代わりに描かれます。URLはそのままです。そのnot-found.tsxはビルドがシェルと一緒に描いているので、新たなリクエストはしません。どの値にも同じものを出すので、シェルがブラウザに任せるパラメータを受け取るnot-found.tsxは使いません。fallback.tsxの横のものなどは飛ばして、その上でいちばん近いものを使います。受け取るpathnameもシェルのもの(/ja/posts/!fallback)なので、訪問者のURLを出すときはClient Componentで読みます。
ただし、ホスティングはシェルを200で返しています。データに無い値でも、ステータスは200のままです。またnotFound()が効くのは、描画の中で呼んだときだけです。effectやイベントハンドラーから呼んでも、どの境界も受け止めません。
シェルのエラー
シェルの場所か描画に誤りがあると、ビルドできません。
- 上のレイアウトがパラメータを受け取るのに、シェルの場所が無いパターン:
static build needs shell locations for /:locale/posts/:id — [locale]/layout.tsx receives :locale, … - 上のレイアウトが受け取るパラメータを残した場所:
the "paths" option supplied shell locations that leave out a parameter a layout above their fallback.tsx receives: /:locale/posts/:id (…) - パターンと名前の違うパラメータ:
the "paths" option supplied shell locations whose parameter names are not their pattern's: /en/posts/:slug (/:locale/posts/:id) - レイアウトのスキーマが拒んだ場所:
the "paths" option supplied shell locations a params schema refused: /xx/posts/:id !fallbackという区間を含むパス:the "paths" option supplied pathnames with a segment named !fallback, …- 先に宣言したルートのURLにも答えるシェル:
the shell for /ja/posts/:id ([locale]/posts/[id]/fallback.tsx) would also answer … - ビルドの描画で
notFound()を呼んだシェル:the shell for /ja/posts/:id called notFound() while it rendered, … - 別のルートが答えたシェル:
the shell for /ja/posts/:id (…) was answered by …
それぞれの直し方はトラブルシューティングを見てください。
シェルの制約
シェルが答える値には、書き出したページと比べて次の制約があります。
- 本文はブラウザで描きます。値ごとの本文を、Server Componentでは描けません。
- 最初のHTMLには本文がありません。値ごとの
<title>やOGPも無いので、JavaScriptを動かさないクローラーには読めません。 - データに無い値にも、ホスティングは
200で答えます。not-found.tsxで<meta name="robots" content="noindex">を描くと、検索結果に載りません。 - データは、ブラウザから読めるAPIに置きます。
cspを書いているなら、connect-srcにAPIのoriginを足します。 - レイアウトは、
pathnameとしてシェルのURL(/posts/!fallback)を受け取ります。canonicalやhreflangのようにURLから作るものは、Client Componentで描きます。HTMLに!fallbackが残ると、ビルドが警告します。 - 書き換えのルールを読めないホスティングでは使えません。GitHub Pagesでは、ビルドしていない値に
404.htmlが返ります。
クエリの読み取り
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;
このページが読むクエリのスキーマ
export default async function ProductsPage({
search,
}: PageProps<'/products'>) {
const products = await fetchProducts(search);
return <ProductList initialUrl={search} products={products} />;
ProductListがuseAppStateにinitialUrlとして渡す
}serverモードだけで使えます。ページが読むクエリは、searchのexportでスキーマとして宣言します。宣言したページは、検証済みの値をsearchとして受け取ります。レイアウトと、searchを宣言していないページはクエリを受け取りません。
欠けたパラメータには既定値が入り、スキーマに合わない値はそのフィールドだけが既定値に戻ります。searchとしてexportするのは、@k8ordo/stateのdefinePageStateで定義したurlのスキーマです。アプリの依存に@k8ordo/stateが要ります。定義の仕方はURLを見てください。
受け取った値は、initialUrlとしてuseAppStateに渡します。サーバーでの描画もハイドレーションも、URLの値を使います。クエリが変わる遷移では、そのページがその場で読み込み直されます。再マウントもスクロールの移動も起きません。