@k8ordo/static

routes/

src/routes/ のディレクトリ木が、そのままアプリの URL です。このページは、ファイル名とディレクトリ名の文法、照合の順序、ビルドが拒むもの、生成されるファイル、ページがタイトルを描く方法を説明します。

ディレクトリが pathname 空間

src/routes/ はアプリの pathname 空間で、それ以外のものは置けません。URL を増やすことはディレクトリを増やすことで、ある URL に答えるファイルはディレクトリ木を読めば 1 つに決まります。規約は覚えておく習慣ではなく、ビルドが検査する形です。

src/routes/
  layout.tsx
  page.tsx
  not-found.tsx
  error.tsx
  old/
    redirect.ts
  products/
    page.tsx
    [id]/
      page.tsx
  (docs)/
    layout.tsx
    guide/
      page.tsx
  _parts/
    counter.tsx
ファイルURL役割
layout.tsxすべてを包むルートレイアウト。文書そのもの
page.tsx/ルートのページ
not-found.tsx/*ほかのどれも答えなかった pathname
error.tsx下で throw されたとき、代わりに描かれる
old/redirect.ts/old訪問者を別の URL へ送る
products/page.tsx/productsリテラルの区間
products/[id]/page.tsx/products/:idパラメータ。値は params.id としてページに渡る
(docs)/layout.tsxグループの中だけを包むレイアウト
(docs)/guide/page.tsx/guideグループの中のページ。(docs) は URL に出ない
_parts/counter.tsx私物。文法から見えない

ファイル名は 5 つだけ

ディレクトリの中で文法が受け付けるファイル名は page.tsxlayout.tsxnot-found.tsxerror.tsxredirect.ts の 5 つで、拡張子まで含めて完全に一致する必要があります。page.tshelpers.ts もビルドが拒みます。それ以外のファイルは、_ で始まるディレクトリに置きます。

ファイル役割受け取る props
page.tsxそのディレクトリの URL を描く。default export がページparams, pathname
layout.tsxその下で描かれるものすべてを children で包むchildren, params, pathname
not-found.tsxそのディレクトリ以下で、ほかのどれも答えなかった pathname に答えるparams, pathname
error.tsx'use client' のファイル。下で throw されたとき、代わりに描かれるerror, reset
redirect.tsページの代わりに、行き先を default export するなし(描画しない)

このモードでは、ルートファイルに request は渡りません。ファイルを書き出すビルドには、読むべきリクエストが無いからです。not-found.tsx404.html という 1 枚のファイルに描かれるので、宣言できるのは 1 つだけです。 エラーとリダイレクト

ディレクトリ名の 4 つの形

ディレクトリの名前が、URL に何を足すか、何も足さないかを決めます。

名前URL に足すもの意味
products/products文字・数字・._~- だけの名前は、書いたとおり 1 つの区間になります。大文字も使えます。
[id]/:id1 区間を受け取るパラメータです。名前は文字か _ で始まり、文字・数字・_ が続きます。同じパスの上で同じ名前は 2 度使えません。
(docs)なし区間を足さないグループです。木の一部にだけ効くレイアウトやエラー境界を持たせるためにあります。名前には文字・数字・_- を使います。
_partsなし_. で始まる名前は、ディレクトリでもファイルでも文法から見えません。ルート専用の部品やデータはここに置きます。

可変長のパラメータ([...rest] のような形)はありません。ワイルドカードは 1 つだけで、それが not-found.tsx です。[...rest] という名前のディレクトリは、不正なパラメータディレクトリとして拒まれます。

ページは params を、レイアウトは children を受け取る

Server Component は context を読めないので、入れ子は props で渡ります。レイアウトは自分の下で描かれたものを children として受け取り、ページは自分のパターンのパラメータを params として受け取ります。どちらも pathname、つまりこの描画が対象にしている URL を受け取ります。パラメータより上にあるコンポーネントがその値を見る方法は、これしかありません。

// src/routes/products/[id]/page.tsx
import type { PageProps } from '@k8ordo/router';

export default function ProductPage({
  params,
  pathname,
}: PageProps<'/products/:id'>) {
  return (
    <>
      <h1>{params.id}</h1>
      <p>{pathname}</p>
    </>
  );
}

@k8ordo/routerPageProps<pattern>LayoutProps<pattern> は、この props をパターンで表した型です。生成された Register を読むので、ページはどちらのモードが入っているかに依存しません。props をインラインで書いても構いません。どちらの書き方でも、生成された表がコンポーネントを使う位置で satisfies によって検査し、それを報告するのは tsc です。

このサイトのルートレイアウトは、<html lang>pathname の先頭区間から決めています。ロケールは URL にしか無く、クローラや読み上げが読むのはサーバーが書いた HTML だからです。

// src/routes/layout.tsx
import type { ReactNode } from 'react';

import { locales } from '../i18n';

export default function Root({
  children,
  pathname,
}: {
  children: ReactNode;
  pathname: string;
}) {
  const locale = locales.delocalize(pathname).locale ?? locales.default;
  return (
    <html lang={locale}>
      <body>{children}</body>
    </html>
  );
}

照合の順序

ディレクトリ木そのものには順序が無いので、生成される表が順序を決めます。同じ階層ではリテラルの区間がパラメータより先に試され、not-found.tsx はその枝の最後に置かれます。about/[slug]/ が並んでいても、何も書かずに /about が届くのはこのためです。

グループだけは例外になりえます。グループは両方の種類の URL を 1 つのキーの下にまとめるので、表はその境界をまたいで並べ替えられません。次の木では、グループの中にリテラルの sale/ があるためにグループが about/ と同じ順位になり、名前の並びで先に置かれたグループの [id]//about に先に答えてしまいます。宣言されたルートが決して描かれない形はここにしか生まれず、ビルドはそれを出荷せずに報告します。

src/routes/
  page.tsx
  about/
    page.tsx
  (shop)/
    sale/
      page.tsx
    [id]/
      page.tsx
routes/ is not a valid pathname space:
  routes/about/page.tsx: "/about" can never match "/:id" ((shop)/[id]/page.tsx) is declared first and answers it

ビルドが拒むもの

問題は最初の 1 件だけでなく全部報告され、どれもファイルを名指します。ビルドは routes/ is not a valid pathname space: に続けて 1 行ずつ並べて止まります。vite dev も、起動の時点で問題があれば同じエラーで起動しません。起動した後の変更が問題を生んだときは、サーバーを止めずに routes/<path>: <message> の行をログに出します。問題があるあいだ、.k8ordo/ は書き直されません。

routes/ is not a valid pathname space:
  routes/[123]: "[123]" is not a valid param directory use [name] with a letter or underscore first
  routes/products/helper.ts: routes/ holds only page.tsx, layout.tsx, not-found.tsx, error.tsx, redirect.ts move "helper.ts" under a _-prefixed directory
routes/ にあるものエラー
products/helper.tsroutes/ holds only page.tsx, layout.tsx, not-found.tsx, error.tsx, redirect.ts — move "helper.ts" under a _-prefixed directory
[123]/page.tsx"[123]" is not a valid param directory — use [name] with a letter or underscore first
(docs/page.tsx"(docs" is not a valid route group — use (name)
pro ducts/page.tsx"pro ducts" cannot be a URL segment — use letters, digits, . _ ~ or -
[id]/things/[id]/page.tsx":id" is already taken by an ancestor — params must be unique within a path
orphan/layout.tsx だけがあり、下にページが無いhas a layout but no page.tsx below it, so it can never render
empty/error.tsx だけがあり、下にページもリダイレクトも無いdeclares no route — every directory needs a page.tsx (or redirect.ts) somewhere below it
(a)/page.tsx(b)/page.tsx"/" is already declared by (a)/page.tsx — route groups do not separate URLs
old/page.tsxold/redirect.ts"old" cannot both render page.tsx and redirect — keep one
(shop)/sale/page.tsx(shop)/[id]/page.tsx の横に about/page.tsx"/about" can never match — "/:id" ((shop)/[id]/page.tsx) is declared first and answers it
(shell)/docs/page.tsx(shell)/not-found.tsx の横に about/page.tsx"/about" can never match — "/*" ((shell)/not-found.tsx) is declared first and answers it

隠されたルートの検査は、文法の問題が 1 つも無い木にだけ走ります。文法が拒んだ名前はパターンにならないからです。そのため、文法の問題を直した次の実行で、隠されたルートが初めて報告されることがあります。

このモードのビルドは、ルートの形のほかにも次の理由で止まります。

  • パラメータ付きルートの pathname が足りない、または paths が返した値が使えない パラメータ
  • not-found.tsx が 2 つ以上ある ビルドと配信
  • 'use server' を宣言したモジュールがある Get Started
  • ビルド中に throw したコンポーネント(Server Component はいつでも、クライアントコンポーネントは上に Suspense の境界が無いとき) エラーとリダイレクト

生成されるファイル

フレームワークは .k8ordo/ を書き、ディレクトリと同期させ続けます。書かれるのは vite dev の起動時と vite build の開始時で、開発中は routes/ の下のファイルが追加・削除・変更されるたびに書き直されます。生成物なので編集するものではありませんが、ルーターの公開 API だけを使ったただのソースなので、読むものではあります。

ファイル中身
routes.gen.ts表そのもの(routes)、ページのパターンごとに走るスキーマ(paramSchemas)、リダイレクト(redirects)。各ルートファイルは satisfies で、自分のディレクトリが置かれたパターンに対して検査されます。
register.gen.ts表を @k8ordo/routerRegister に結び、アプリが @k8ordo/state に依存していればそちらにも結びます。hrefuseMatch のパターンが表に対して検査されるのはこのためです。
.gitignore中身は * です。ディレクトリが自分自身を git から外すので、アプリの .gitignore に足すものはありません。

次の木からは、下の 2 つのファイルが生成されます。どちらも抜粋で、routes.gen.tsroutes の export だけを、register.gen.ts はアプリが @k8ordo/state に依存しないときの宣言だけを示しています。実際のファイルはどちらも // Generated by … の行で始まり、どのモードのパッケージが生成したかを名乗ります。

src/routes/
  layout.tsx
  page.tsx
  not-found.tsx
  products/
    page.tsx
    [id]/
      page.tsx
// .k8ordo/routes.gen.ts
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'>,
        },
      },
      '/*': not_found satisfies Page<'/*'>,
    },
  },
});
// .k8ordo/register.gen.ts
import type { ParsedParamsMap } from '@k8ordo/router';
import type { paramSchemas, routes } from './routes.gen';

declare module '@k8ordo/router' {
  interface Register {
    routes: typeof routes;
    params: ParsedParamsMap<typeof paramSchemas>;
  }
}

tsconfig.json

型の配線を効かせるには、tsconfig.jsoninclude.k8ordo をグロブで書きます。.k8ordo はドットで始まるので、ディレクトリ名だけを書いた include は黙ってそれを飛ばします。飛ばされてもビルドは通り、href が表に対して検査されなくなるだけです。

{
  "include": ["src/**/*.ts", "src/**/*.tsx", ".k8ordo/**/*.ts"]
}

vite build は型を検査しません。satisfies による検査の結果を出すのは tsc です。.k8ordo/ は git に入らないので、新しいチェックアウトでは tsc の前に一度 vite devvite build を走らせて書かせます。

タイトルとメタデータ

メタデータの API はありません。React 19 は木のどこで描かれた <title><meta><link><head> へ持ち上げるので、ページは自分のタイトルを、ほかのものと同じ場所で描きます。

// src/routes/products/[id]/page.tsx
import type { PageProps } from '@k8ordo/router';

export default function ProductPage({ params }: PageProps<'/products/:id'>) {
  return (
    <>
      <title>{`Product ${params.id}`}</title>
      <meta content="One product from the catalog" name="description" />
      <h1>{params.id}</h1>
    </>
  );
}

画面上の <title> は常に 1 つにします。ルートレイアウトは何も描かず、各ページと not-found.tsx が 1 つずつ描きます。2 つ描けば 2 つとも描かれるだけで、後のものが勝つ仕組みはありません。このサイトは、各ページのタイトルを PageTitle というコンポーネントを通して描いています。

リンクは表に対して型が付く

ブラウザでのナビゲーションは @k8ordo/router が担います。Navigation API の下では素の <a> がそのままクライアント遷移になり、href() のパターンと params は生成された表に対して検査されます。

// src/routes/products/page.tsx
import { href } from '@k8ordo/router';

export default function ProductsPage() {
  return (
    <ul>
      <li>
        <a href={href('/products/:id', { id: 1 })}>first product</a>
      </li>
      <li>
        <a href={href('/guide')}>guide</a>
      </li>
    </ul>
  );
}

フレームワークの下でルーターがどう振る舞うかは、リンク先にあります。 リンクと現在地 · フレームワーク配下