@k8ordo/framework

ルーティング

src/routes/の下のディレクトリが、そのままアプリのURLになります。ディレクトリとファイルの決まりを覚えれば、ページもリダイレクトもフィードも、ファイルを置くだけで作れます。

このページの内容

ディレクトリとURL

text
src/routes/
  layout.tsx
  page.tsx
  not-found.tsx
  products/
    page.tsx
    [id]/
      page.tsx
  (docs)/
    layout.tsx
    guide/
      page.tsx
  • page.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.tsx
import 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.tsx
import 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を渡します。渡し方はパラメータを見てください。

ルートの順番

text
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.tsx
export 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.ts
import 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.ts
import { 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.tsx
import 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つとも出力され、後のものが優先されることはありません。

リンク先の先読み

tsx
<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に入っていないためです。

Playground

先読みのデモ

このサイトは@k8ordo/frameworkのstaticモードで動いているので、リンクにポインターを合わせるとindex.rscを実際に取得し、下の一覧に表示します。

パラメータのページ
この中のリンクは先読みしませんデプロイのページ

取りに行ったペイロード

まだありません

試してみる

  1. 「パラメータのページ」にポインターを合わせると、一覧にそのページのindex.rscが加わります。
  2. 「デプロイのページ」はdata-k8ordo-prefetch="false"の中にあるので、ポインターを合わせても一覧は増えません。
  3. 「パラメータのページ」にもう一度合わせても、最初に取得してから30秒たつまでは一覧に加わりません。
  4. ヘッダーやサイドバーにある、ほかのページへのリンクに合わせても、同じように一覧に加わります。
k8ordo

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

© 2026 k8o — MIT License

組版:Noto Sans JP / M PLUS 2