ルーティング
src/routes/の下のディレクトリが、そのままアプリのURLになります。ディレクトリとファイルの決まりを覚えれば、ページもリダイレクトもフィードも、ファイルを置くだけで作れます。
このページの内容
ディレクトリとURL
src/routes/
layout.tsx
page.tsx
not-found.tsx
products/
page.tsx
[id]/
page.tsx
(docs)/
layout.tsx
guide/
page.tsxpage.tsx:/のページです。products/page.tsx:/productsのページです。products/[id]/page.tsx:/products/:idのページです。/products/42を開くと、ページはparams.idに42を受け取ります。(docs)/guide/page.tsx:/guideのページです。
ディレクトリ名の形
ディレクトリ名の形によって、URLに足すものが変わります。
products:書いたとおりの区間を1つ足します。使える文字は英字と数字です。記号は.、_、~と-が使えます。[id]:区間を1つ受け取るパラメータです。名前は英字か_で始めます。(docs):区間を足さないグループです。routes/の一部にだけレイアウトやerror.tsxを持たせるときに使います。.で始まる名前:ディレクトリでもファイルでも、フレームワークは読みません。.DS_Storeやエディタの一時ファイルがあっても、ビルドできます。
[...rest]のように、複数の区間をまとめて受け取るパラメータはありません。
ルートのファイル
ディレクトリに置けるのは、次の9つの名前のファイルだけです。拡張子まで一致させます。page.tsのように拡張子が違うだけでも、ビルドがエラーになります。
page.tsx:そのディレクトリのURLで表示するページです。layout.tsx:その下で描かれるものをchildrenとして受け取り、包みます。not-found.tsx:その下で、どのルートにも当たらなかったURLで表示します。error.tsx:その下でエラーになったとき、代わりに描かれます。loading.tsx:その下のページを待つ間に描かれます。redirect.ts:ページの代わりに、行き先をdefault exportします。route.ts:ページの代わりにResponseを返します。guard.ts:その下のURLへのリクエストを処理する前に実行され、止めるか通すかを決めます。serverモードだけで使えます。fallback.tsx:pathsに無い値のURLに、横のpage.tsxの代わりに答えます。staticモードだけで使えます。
src/routes/に置くのは、ルートのファイルとディレクトリだけです。部品はsrc/components/に、それ以外のモジュールはsrc/lib/に置きます。Server Actionもスキーマも状態の定義も、データやヘルパーもsrc/lib/です。
ページとレイアウトのprops
src/routes/products/[id]/page.tsximport type { PageProps } from '@k8ordo/framework';
export default function ProductPage({
params,
pathname,
}: PageProps<'/products/:id'>) {
return (
<>
<h1>{params.id}</h1>
<p>{pathname}</p>
</>
);
}ページはparamsを、レイアウトはchildrenを受け取ります。どちらも、描画しているURLをpathnameで受け取ります。パラメータより上のレイアウトは、pathnameからその値を読めます。
@k8ordo/frameworkのPagePropsとLayoutPropsは、このpropsをパターンから引いた型です。生成されたルート表を読むので、型引数にはパターンの文字列だけを渡します。LayoutPropsに渡せるのはページのあるパターンだけです。products/page.tsxが無いなら、/productsのレイアウトはpropsの型を自分で書きます。
serverモードでは、ページとレイアウトはrequestも受け取ります。ヘッダーとCookieの読み方はリクエストとCookieを見てください。
ルーターのAPI
src/components/products-link.tsx'use client';
import { href, useMatch } from '@k8ordo/framework';
export function ProductsLink() {
const showing = useMatch('/products/*', { inclusive: true }) !== null;
return (
<a aria-current={showing ? 'page' : undefined} href={href('/products')}>
Products
</a>
);
}hrefやuseMatchのような、リンクと現在のURLを扱うAPIは@k8ordo/frameworkからimportします。@k8ordo/routerのAPIを名前で再exportしたもので、フックを使えるのはClient Componentの中だけです。
useParamsとuseRouteは、@k8ordo/frameworkにはありません。ブラウザはルート表を持たないためです。代わりの読み方はサーバーとブラウザの境界を見てください。
ロケールの区間
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;
}[locale]のレイアウトから、defineLocalesで作ったlocalesのparamsSchemaをexportします。このスキーマは、localesにあるロケールだけを受け付けます。受け付けたロケールがどこで読まれるかはURLにロケールを置くを見てください。
フレームワークは、ほかのparamsSchemaと同じようにこのスキーマでパラメータを検証します。受け付けたロケールで、その下のページを描きます。/にはロケールが無いので、アプリが/で言語を選び、ロケール付きのURLへリダイレクトします。選び方は最初の言語を選ぶを見てください。
staticモードでは、ロケールの数だけページを書き出すので、pathsにlocales.pathsを渡します。渡し方はパラメータを見てください。
ルートの順番
src/routes/
about/
page.tsx
(shop)/
sale/
page.tsx
[id]/
page.tsxディレクトリに順番は無いので、生成されるルート表が順番を決めます。同じ階層では、固定の区間がパラメータより先に試されます。not-found.tsxは最後です。about/のとなりに[id]/を直接置いても、/aboutはabout/に当たります。
ただし、グループの中のルートは1か所にまとまって並びます。グループをまたいで順番は入れ替わりません。上の例では(shop)に固定の区間sale/があるので、(shop)はabout/と同じ順位です。同じ順位では名前の順になり、(shop)が先に来ます。
すると(shop)/[id]/が/aboutに先に当たり、about/page.tsxは描かれません。この形があると、ビルドが"/about" can never matchで始まるエラーになります。
エラーになる構成
決まりに合わないファイルやディレクトリがあると、ビルドがエラーになります。エラー文はroutes/ is not a valid pathname space:で始まり、問題を1行ずつ並べます。最初の1つだけでなく、見つけた問題をすべて挙げます。どの行も、原因のファイルを名指しします。
- ルートのファイル名でないファイル(
products/helper.ts) - 形の正しくないディレクトリ名(
[123]や(docs、pro ducts) - 上のディレクトリと同じ名前のパラメータ(
[id]/things/[id]/) - 下にページも
redirect.tsもroute.tsも無いディレクトリ(レイアウトやerror.tsxだけがある、など) - 別々のグループに置いた、同じURLのページ(
(a)/page.tsxと(b)/page.tsx) - 同じディレクトリに並んだ
page.tsxとredirect.ts、route.tsのうちの2つ - メソッドを1つもexportしない
route.ts - グループの中のルートが先に当たって、表示されることのないページ
- 横に
page.tsxが無いfallback.tsx layout.tsxと同じディレクトリに置いたfallback.tsx- パラメータの無いページの
fallback.tsx - すべてのパラメータを上のレイアウトが受け取るページの
fallback.tsx
vite devも、起動時に問題があれば同じエラーで起動しません。起動後の変更で問題が生まれたときは、routes/<path>: <message>の行をログに出し、サーバーは動き続けます。問題が残っている間、.k8ordo/は書き直されません。
staticモードでは、サーバーが要るものもビルドのエラーになります。どれがなぜエラーになるかはモードの選び方を見てください。
読み込み中の表示
src/routes/products/loading.tsxexport default function ProductsLoading() {
return <p>Loading products…</p>;
}layout.tsxかpage.tsxの横にloading.tsxを置くと、その下のページを待つ間に代わりに描かれます。loading.tsxはpropsを受け取りません。
staticモードでは、ページを丸ごとファイルに書き出すので、HTMLにloading.tsxは出ません。ペイロードも1つのファイルでふつうは一度に受け取るので、クライアント側の遷移でもほとんど出ません。
serverモードでは、文書はページのコンポーネントを待ってから送るので、HTMLには出ません。JavaScriptを実行しない訪問者やクローラーにもページが見えます。ただし、ページの中のコンポーネントが自分の<Suspense>の外でデータを待つと、HTMLではページの代わりに出ます。クライアント側の遷移では、ペイロードを受け取り始めてから、サーバーがページを描き終えるまで出ます。
src/components/progress.tsx'use client';
import { usePendingPathname } from '@k8ordo/framework';
export function Progress() {
const pending = usePendingPathname();
if (pending === null) return null;
return <p role="status">Loading {pending}…</p>;
}すでに表示しているディレクトリの中で別のページへ移るときは、次のページを受け取るまで今のページを表示したままにします。その間に何かを出すには、usePendingPathname()で遷移中の行き先を読みます。
route.ts
src/routes/feed.xml/route.tsimport type { RouteContext } from '@k8ordo/framework';
import { renderFeed } from '../../lib/feed.server';
export async function GET({ request }: RouteContext<'/feed.xml'>) {
const { origin } = new URL(request.url);
return new Response(await renderFeed(origin), {
headers: { 'content-type': 'application/rss+xml' },
});
}RSSのフィードやJSONのように、ページでないものを返すURLにはroute.tsを置きます。扱うHTTPメソッドの名前で関数をexportし、その関数がResponseを返します。関数は{ request, params }を受け取り、型はRouteContextです。paramsには、ページと同じくスキーマで型が付きます。
feed.xmlのようにファイル名に見えるディレクトリも、普通の区間です。feed.xml/route.tsは/feed.xmlのURLになります。route.tsが返すResponseには、上のレイアウトは付きません。クライアント側の遷移でroute.tsのURLへ移ると、ページ全体を読み込み直します。
staticモード
ビルドがURLごとにGETを1回呼び、返ったResponseをそのURLのファイルとして書き出します。feed.xml/route.tsならdist/client/feed.xmlです。パラメータを持つroute.tsには、ページと同じくpathsで値を渡します。framework()にsiteを渡していれば、request.urlのoriginはその値です。
落とし穴
次のroute.tsはファイルにできないので、ビルドがエラーになります。
GET以外のメソッドをexportしたroute.ts(vite devも拒みます)GETが200以外を返したroute.ts/に置いたroute.ts- 下にページがある
route.ts
serverモード
HTTPの7つのメソッドのどれでもexportできます。exportしていないメソッドには405を返し、Allowにexportしたメソッドを並べます。HEADをexportしていなければ、GETのResponseから本文を外して返します。
上にあるguard.tsは、route.tsより先に実行されます。route.tsの中では、@k8ordo/framework/serverのcookies()とresponseHeaders()、requestHeaders()も使えます。
落とし穴
route.tsへのPOSTは、Server Actionと違って同じoriginからかどうかを確かめません。Webhookのように、別の場所から送られてくるものだからです。必要な検証は、route.tsの中に書きます。
生成される.k8ordo/
.k8ordo/routes.gen.tsimport { defineRoutes } from '@k8ordo/framework/generated';
// …
export const routes = defineRoutes({
'/': {
layout: layout satisfies Layout<'/'>,
children: {
'/': page satisfies Page<'/'>,
'/products': {
children: {
'/': products_page satisfies Page<'/products'>,
'/:id': products_id_page satisfies Page<'/products/:id'>,
},
},
},
},
});フレームワークは、routes/から作ったルート表と型を.k8ordo/に書き出します。書くのはvite devの起動時とvite buildの開始時です。開発中は、routes/の下のファイルが変わるたびに書き直します。
routes.gen.ts:ルート表です。それぞれのルートのファイルを、そのディレクトリのパターンに対してsatisfiesで検査します。register.gen.ts:ルート表をフレームワークに登録します。これでhref()やuseMatch()のパターンに型が付きます。serverモードでは、PagePropsとLayoutPropsにrequestも足します。アプリが@k8ordo/stateに依存していれば、そちらにも登録します。.gitignore:*で中身をすべて無視します。.k8ordo/はこれでgitの管理から外れるので、アプリの.gitignoreに足すものはありません。
生成物なので編集しません。中身は普通のTypeScriptで、@k8ordo/framework/generatedからimportしています。読めば、何を検査しているかが分かります。
vite buildは型を検査しないので、satisfiesの結果はtscで確かめます。cloneした直後は、tscの前に一度vite devかvite buildを実行します。
タイトルとメタデータ
src/routes/products/[id]/page.tsximport type { PageProps } from '@k8ordo/framework';
export default function ProductPage({
params,
}: PageProps<'/products/:id'>) {
return (
<>
<title>{`Product ${params.id}`}</title>
<meta content="One product" name="description" />
<h1>{params.id}</h1>
</>
);
}メタデータのためのAPIはありません。React 19は、どのコンポーネントで描いた<title>や<meta>、<link>も<head>へ移します。ページは自分のタイトルを、本文と同じ場所で描きます。
画面に出る<title>は、いつも1つにします。ルートレイアウトでは描かず、各ページとnot-found.tsxがそれぞれ1つずつ描きます。2つ描くと2つとも出力され、後のものが優先されることはありません。
リンク先の先読み
<nav data-k8ordo-prefetch="false">
<a href={href('/reports')}>Reports</a>
<a data-k8ordo-prefetch="true" href={href('/')}>
Home
</a>
</nav>リンクにポインターを合わせると、クライアントのランタイムがリンク先のペイロードを取得します。リンクにフォーカスが移ったときと、押し始めたときも同じです。設定は要りません。コンポーネントライブラリが描いた<a>も含めて、ページの中のすべての<a>が対象です。次のリンクは先読みしません。
- 別のoriginや、Viteの
baseの外へのリンク downloadが付いたリンクと、_self以外のtargetが付いたリンク- 今表示しているページへのリンク
先読みを止めるには、リンクかそれを囲む要素にdata-k8ordo-prefetch="false"を付けます。描画の重いページへのリンクに使います。いちばん近い要素の値が使われるので、止めた範囲の中のリンクに"true"を付ければ、そのリンクだけを先読みに戻せます。
先読みしたページは、そのページへの次の遷移で1回だけ使われます。使えるのは取得を始めてから30秒以内で、過ぎると遷移のときに取り直します。Server Actionの結果を受け取ったときも、先読みしたものはすべて捨てます。
メモ
ブラウザのSpeculation Rulesは使いません。Baselineに入っていないためです。
先読みのデモ
このサイトは@k8ordo/frameworkのstaticモードで動いているので、リンクにポインターを合わせるとindex.rscを実際に取得し、下の一覧に表示します。
試してみる
- 「パラメータのページ」にポインターを合わせると、一覧にそのページの
index.rscが加わります。 - 「デプロイのページ」は
data-k8ordo-prefetch="false"の中にあるので、ポインターを合わせても一覧は増えません。 - 「パラメータのページ」にもう一度合わせても、最初に取得してから30秒たつまでは一覧に加わりません。
- ヘッダーやサイドバーにある、ほかのページへのリンクに合わせても、同じように一覧に加わります。