@k8ordo/framework

パラメータ

[id]のようなディレクトリの下のページは、URLの一部をパラメータとして受け取ります。スキーマで型を付け、staticモードではpathsでURLの一覧を、serverモードではsearchでクエリを宣言します。

このページの内容

文字列のパラメータ

src/routes/products/[id]/page.tsx
import 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.tsx
import 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.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/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.tsx
import { 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.tsx
import { 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.ts
import { 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.ts
import { 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のロケールになります。

一部だけの展開

ts
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.tsx
import { 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.ts
import 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は、シェルが埋めたパラメータに対して動きます。そのためロケールのスキーマは、ページでなくレイアウトに置きます。

シェルの場所

ts
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.tsx
import 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の値を使います。クエリが変わる遷移では、そのページがその場で読み込み直されます。再マウントもスクロールの移動も起きません。

k8ordo

Baselineに入った機能を、制限なく使うReactのライブラリ群。

© 2026 k8o — MIT License

組版:Noto Sans JP / M PLUS 2