ReactでTanStack Routerを使い、型の付いたルーティングを書く方法
TanStack Router では、ルートの定義をもとに、Link に書けるパス、パスパラメーター、search パラメーターの型が作られます。ルーターを Register に登録しておけば、存在しないパスへのリンクや、パラメーターの渡し忘れが tsc の型チェックで止まります。
検証環境でトップ、猫の一覧、猫の詳細の3画面を作り、わざと間違えたリンクを6通り書いて、どんなエラーになるかを記録しました。あわせて、ブラウザでリンクをたどったときの URL と、URL を手で書き換えたときの動きを確かめています。
検証環境は Ubuntu 24.04、Node.js 22.23.4 です。使ったのは @tanstack/react-router 1.170.46(中で使われる @tanstack/router-core は 1.171.39)、react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2、esbuild 0.28.2 です。.tsx を tsc で型チェックしてから esbuild で本番用にビルドし、Chrome for Testing 153(Playwright 1.56.1 で操作)で動かしました。StrictMode では包んでいません。
公式ドキュメントがすすめるファイルベースのルーティング(ファイルの置き方からルートの定義をプラグインが自動で作る方式)、Vite との組み合わせ、loader でのデータ取得、Zod などの検証ライブラリとの組み合わせ、コード分割の5つは、この記事では試していません。ここではルートをコードで書く方式だけを扱います。
ルートを書いて、ルーターを Register に登録する
ルートをコードで書く今回の形では、ルーター用に入れたパッケージは @tanstack/react-router だけで、プラグインなどを足さずに動きました。ルートは createRootRoute で一番上のルートを作り、その下に createRoute でつないでいきます。子のルートには getParentRoute で親を教えます。型安全についてのガイドには、これを書かないと親のルートの型が子に伝わらないと書かれています。
検証に使ったルートは、トップ(/)、猫の一覧(/cats)、猫の詳細(/cats/$catId)の3つです。パスの中の $catId がパスパラメーターになります。
const rootRoute = createRootRoute({
component: () => (
<>
<nav>
<Link to="/">トップ</Link>{' '}
<Link to="/cats" search={{ sort: 'name' }}>猫の一覧</Link>
</nav>
<Outlet />
</>
),
})
const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/',
component: () => <h1>トップ</h1>,
})
type CatsSearch = { sort: 'name' | 'age' }
const catsRoute = createRoute({
getParentRoute: () => rootRoute,
path: 'cats',
validateSearch: (search: Record<string, unknown>): CatsSearch => ({
sort: search.sort === 'age' ? 'age' : 'name',
}),
component: CatsPage,
})
const catRoute = createRoute({
getParentRoute: () => rootRoute,
path: 'cats/$catId',
params: {
parse: ({ catId }) => ({ catId: Number(catId) }),
stringify: ({ catId }) => ({ catId: String(catId) }),
},
component: CatPage,
})
const routeTree = rootRoute.addChildren([indexRoute, catsRoute, catRoute])
export const router = createRouter({ routeTree })
declare module '@tanstack/react-router' {
interface Register {
router: typeof router
}
}
最後の declare module が、ルーターの登録です。Link や useNavigate は、どのルーターと一緒に使われるかを自分では知りません。TypeScript の宣言のマージで Register に router の型を入れると、ライブラリから読み込んだ Link などが、このルーターの型をもとに型チェックされるようになります。
あとは RouterProvider に router を渡して描画するだけです。
createRoot(document.getElementById('root')!).render(
<RouterProvider router={router} />,
)
CatsPage と CatPage の中身は、あとの節で見ていきます。
わざと間違えたリンクは、6つとも tsc で止まった
上のルートと同じプロジェクトに、ルーターを import しない別のファイルを足して、間違えたリンクを6つ書き、tsc にかけました。Link と useNavigate は、ライブラリから読み込んだだけです。
export function Mistakes() {
const navigate = useNavigate()
return (
<>
{/* 1. パスの書き間違い(cats を cat と書いた) */}
<Link to="/cat">一覧</Link>
{/* 2. パスパラメーターを渡し忘れた */}
<Link to="/cats/$catId">たま</Link>
{/* 3. catId に文字列を渡した(parse で number にしている) */}
<Link to="/cats/$catId" params={{ catId: '1' }}>たま</Link>
{/* 4. sort に決めていない値を渡した */}
<Link to="/cats" search={{ sort: 'weight' }}>体重順</Link>
{/* 5. search を渡し忘れた */}
<Link to="/cats">一覧</Link>
{/* 6. navigate でパラメーターの名前を間違えた */}
<button onClick={() => navigate({ to: '/cats/$catId', params: { id: 1 } })}>
移動
</button>
</>
)
}
6つとも、それぞれの行でエラーになりました。型の名前が長い3つのエラーは、途中を「…」で省いています。
$ npx tsc -p tsconfig.mistakes.json --pretty false; echo "exit=$?"
mistakes/mistakes.tsx(8,13): error TS2820: Type '"/cat"' is not assignable to type '"." | ".." | "/" | "/cats" | "/cats/$catId"'. Did you mean '"/cats"'?
mistakes/mistakes.tsx(10,8): error TS2741: Property 'params' is missing in type '{ to: "/cats/$catId"; children: string; }' but required in type 'MakeRequiredPathParams<…'.
mistakes/mistakes.tsx(12,41): error TS2322: Type 'string' is not assignable to type 'number'.
mistakes/mistakes.tsx(14,34): error TS2322: Type '"weight"' is not assignable to type '"age" | "name"'.
mistakes/mistakes.tsx(16,8): error TS2741: Property 'search' is missing in type '{ to: "/cats"; children: string; }' but required in type 'MakeRequiredSearchParams<…'.
mistakes/mistakes.tsx(18,71): error TS2353: Object literal may only specify known properties, and 'id' does not exist in type 'ParamsReducerFn<…'.
exit=1
1つ目のエラーを見ると、to に書けるのは "/"、"/cats"、"/cats/$catId" と、"."、".." だけになっています。定義した3つのルートのパスが、そのまま並んでいます。"/cat" に対しては「"/cats" のことでは?」と候補まで出ました。
2つ目と5つ目は、params や search を書き忘れたものです。パスパラメーターのあるルートや、search パラメーターが必ず要るルートへのリンクでは、それらを省くと型エラーになります。
Register を書かないと、間違ったパスも通る
declare module を書かずに、同じように "/cat" へのリンクを書いて試しました。ルートは /cats しかありません。
const rootRoute = createRootRoute()
const catsRoute = createRoute({
getParentRoute: () => rootRoute,
path: 'cats',
})
const routeTree = rootRoute.addChildren([catsRoute])
export const router = createRouter({ routeTree })
export function Nav() {
// パスを書き間違えても、型チェックを通ってしまう
return <Link to="/cat">一覧</Link>
}
$ npx tsc -p tsconfig.noreg.json --pretty false; echo "exit=$?"
exit=0
エラーは1つも出ませんでした。ルーターを作っただけでは Link の型は絞られないので、Register の宣言は忘れずに書く必要があります。
パスパラメーターは params.parse で数値にできる
URL から取り出したパスパラメーターは、そのままでは文字列です。パスパラメーターのガイドの例でも、/blog/123 を開いたときの params は { postId: '123' } になっています。
検証では、ルートの params に parse と stringify を書き、catId を数値として扱えるようにしました。ルートのオプションの API リファレンスによると、parse はルートが URL に当てはまったときに呼ばれ、戻り値の型がルーターのほかの部分にも伝わります。stringify は、移動先の URL を組み立てるときに呼ばれます。詳細のコンポーネントでは、ルートの useParams で値を受け取ります。
function CatPage() {
const { catId } = catRoute.useParams() // catId の型は number
const cat = cats.find((c) => c.id === catId)
if (!cat) return <h1>見つかりません</h1>
return (
<>
<h1>{cat.name}</h1>
<p>{cat.age}歳(catId: {catId}、型: {typeof catId})</p>
</>
)
}
リンクを作る側も数値を渡します。一覧では params={{ catId: cat.id }} と書いており、わざと間違えたリンクの3つ目のように、文字列の '1' を渡すと型エラーになりました。
ブラウザで /cats/3 を開くと、画面には「catId: 3、型: number」と出ました。一方で /cats/abc のように数字でない値を直接開くと、Number('abc') は NaN になるため、猫のデータ(cats)の中に見つからず「見つかりません」と表示されました。型が number でも、中身が正しい数値とは限りません。API リファレンスには、parse で false を返すと、そのルートを飛ばして別のルートを探す書き方も載っています(試験的な機能とされています)。
search パラメーターの型は validateSearch で決まる
?sort=age のような search パラメーターの型は、ルートの validateSearch が返す値の型になります。search パラメーターのガイドでは、validateSearch が受け取るのは検証前の値(Record<string, unknown>)で、おかしな値には、代わりに使う妥当な値を返すのがよいとされています。検証では、sort が 'age' のときだけ 'age'、それ以外はすべて 'name' にしました。
一覧のコンポーネントでは、ルートの useSearch で値を受け取ります。sort の型は 'name' | 'age' になります。
function CatsPage() {
const { sort } = catsRoute.useSearch() // sort の型は 'name' | 'age'
const list = [...cats].sort((a, b) =>
sort === 'age' ? a.age - b.age : a.name.localeCompare(b.name, 'ja'),
)
return (
<>
<h1>猫の一覧({sort === 'age' ? '年齢順' : '名前順'})</h1>
<Link to="/cats" search={{ sort: 'name' }}>名前順</Link>{' '}
<Link to="/cats" search={{ sort: 'age' }}>年齢順</Link>
<ul>
{list.map((cat) => (
<li key={cat.id}>
<Link to="/cats/$catId" params={{ catId: cat.id }}>
{cat.name}
</Link>
</li>
))}
</ul>
</>
)
}
この書き方だと、CatsSearch の sort に ? が付いていないので、/cats へのリンクには毎回 search が要ります。わざと間違えたリンクの5つ目のエラーがそれです。リンクのたびに書くのが面倒なら、型を { sort?: 'name' | 'age' } にし、validateSearch も 'age' 以外は undefined を返すようにします。この形では、名前順を sort を付けない状態で表します。
type CatsSearch = { sort?: 'name' | 'age' }
const catsRoute = createRoute({
getParentRoute: () => rootRoute,
path: 'cats',
validateSearch: (search: Record<string, unknown>): CatsSearch => ({
sort: search.sort === 'age' ? 'age' : undefined,
}),
})
別のファイルでこの形に変えて試すと、search のない <Link to="/cats"> は通り、sort: 'weight' だけがエラーになりました。
$ npx tsc -p tsconfig.optional.json --pretty false; echo "exit=$?"
optional/router.tsx(27,34): error TS2322: Type '"weight"' is not assignable to type '"age" | "name" | undefined'.
exit=1
ブラウザでリンクをたどると、URL はこう変わる
本番用にビルドしたアプリを Chrome for Testing で開き、「猫の一覧」「年齢順」「ミケ」の順にリンクをクリックしました。下の枠の URL は、撮影用のスクリプトが Playwright でページの URL を読んで表示したものです。

search={{ sort: 'name' }} を付けたリンクの href は /cats?sort=name になりました。ミケのリンク(params={{ catId: cat.id }} で、catId は 3)をたどると /cats/3 になりました。
URL を手で書き換えたときの動きも見ました。/cats?sort=weight を直接開くと、画面は名前順になり、URL も /cats?sort=name に書き換わりました。search パラメーターのない /cats を開いたときも同じく /cats?sort=name になりました。validateSearch で直した値が、画面だけでなく URL にも反映されています。
/cats?sort=weight を直接開く
URL: /cats?sort=name
h1: 猫の一覧(名前順)
li: クロ, たま, ミケ
/cats を search なしで直接開く
URL: /cats?sort=name
h1: 猫の一覧(名前順)
li: クロ, たま, ミケ
型が守ってくれるのは、コードの中で書くリンクまでです。アドレスバーに打ち込まれる URL は、型チェックの対象になりません。そのため、validateSearch や params.parse で、おかしな値が来たときの扱いを決めておくことになります。





