TSKaigi2026〜アフターパーティー〜 / TSKaigi Mashup #5 any


ぼくの考えた最強の
TanStack Router ディレクトリ構成


ぶりお @burio_16

ぶりおの写真

自己紹介

  • ぶりお @burio_16
  • タコスとTottenham Hotspur FCが大好き
  • TSKaigiは初参加でした
  • 会場で5回くらいタコスの人ですか!?と言われた(???)

タコスといえば...?

  • エブリーさんが出した記事
  • 「タコス」の検索頻度が昨年比 2 倍! 🌮
  • 自分も月1くらいで家でタコス作ってます
  • (タコスについてプレスリリース出してるの面白い)
あかがわまさともさんから「ぶりおへ」と PR TIMES のリンクが共有されたチャット画面エブリーのプレスリリース「タコスの検索頻度が昨年比2倍!GWに楽しめるメキシコ料理レシピを公開」

TSKaigi2026

みなさん楽しかったですか??

Welcome to TSKaigi 2026 のスポンサーバナーTSKaigi 2026 アフターパーティーでの集合写真

初参加でしたが、とても楽しかったです!参加レポートも書きました

本題に入る前に

  • TanStack Router / Start は事例が少なく、手探りです!
  • 「これが正解」ではなく、試行錯誤の途中を共有したい
  • もし知見を持っている方は懇親会でマサカリしてください

そもそも、なぜ TanStack Router?

技術選定

  • 社内で 0 から作り直す大規模なリプレイス中、フロントの技術選定を行った
  • 候補は Next.js / React Router / TanStack Router の3案

3案の比較

  • Next.js … Vercel 以外で運用するときの考慮の多さや、Cache Components などの難解さを考えて見送り
  • React Router … 現状使用中。せっかく作り替えるのに同じ技術使っても楽しくない
  • TanStack … Next.jsほど辛くなく、React Routerよりもモダンな印象があったので採用!!

TanStack Router を採用

でも、現実は無法地帯だった

  • なんとなく作ってたら 書き方バラバラ
  • このままだと AI が クソコード生成マシン になってまう
  • ゼロから好きに設計できる絶好のチャンス

TanStack Routerの良さって?

TanStack Routerの良さ

  • 型安全なルーティング
  • 型安全な search params
  • ファイルベースルーティング
  • - prefix コロケーション

① 型安全なルーティング

tsx
// パスも params も補完が効く・存在しないパスはコンパイルエラー
<Link to="/contracts/$contractId" params={{ contractId }} />

// 受け取る側も型付き(contractId: string)
const { contractId } = Route.useParams();
  • Link / params が補完され、存在しないパスは コンパイルエラー
  • リンク切れや typo を 型で潰せる

② 型安全な search params

tsx
// URL の ?page=2&tab=archived を schema で定義
export const Route = createFileRoute("/contracts")({
  validateSearch: z.object({
    page: z.number().default(1),
    tab: z.enum(["active", "archived"]).default("active"),
  }),
});

// 読む側は完全に型付き → URL がそのまま型安全な state に
const { page, tab } = Route.useSearch();
  • 戻る / 進む / 直リンクで来た値も 検証済みで受け取れる
  • URL state のための nuqs のような追加ライブラリも要らない

③ - prefix でコロケーション

text
routes/contracts/
├── index.tsx        ← これだけが /contracts になる
└── -components/     ← `-` prefix で route tree から除外
    ├── Page.tsx
    └── fallbacks/   ← ルート専用の物を隣に置ける
  • ルートに関わる物を 同じ階層に置けて、URL も汚さない
  • この性質が、後の 構成の3案 を考える土台になる

構成、どうする?

意外と大変だった

呟いたらメンテナーが降臨した

  • 何も分からず X で「本日も TanStack Router に頭を悩ませた」と呟く
  • すると TanStack Router メンテナーが降臨
  • 悩みを伝えると → 「どっちでもええんちゃう?
  • ……たぶん悩みをうまく伝えきれてなかった説
X で TanStack Router の構成について悩みを伝えているスクリーンショットメンテナー Manuel Schiller さんが matter of taste と返信し Virtual File Routes を案内しているスクリーンショット

3案を考えた

  1. features に寄せる
  2. routes に全部押し込む
  3. ハイブリッド

→ 結果的に 3つ目(ハイブリッド) を採用

案① features に寄せる

text
src/
├── features/<domain>/   ← api / hooks / components / Page まで集約
└── routes/<route>/
    └── index.tsx         ← features を読むだけの薄い wrapper
  • Page まで features に集約、routes は読むだけの薄い wrapper
  • シンプルだが、- コロケーションなど TanStack Router の機能を活かしきれない

案② routes に全部押し込む

text
src/routes/<route>/
├── -api/         ← queryOptions / mutationOptions
├── -hooks/       ← hook
├── -components/  ← View 断片 / fallbacks / Page
└── index.tsx     ← loader / validateSearch
  • - prefix で全部 routes に詰め込める → 機能はフル活用
  • ただしルートが肥大化して ぐちゃぐちゃになりがち

案③ ハイブリッド(採用)

text
src/
├── features/<domain>/   ← ロジックの実体(Router 非依存)
│   └── api / hooks / components
└── routes/<route>/      ← TanStack Router 依存部分だけ
    ├── -components/  ← fallbacks / Page
    └── index.tsx     ← loader / validateSearch
  • TanStack Router 依存部分だけ routes、ロジックの実体は features
  • 機能はフル活用しつつ、Router 依存を routes に閉じ込められるいいとこ取り

こんな感じにしました!

ディレクトリ構成

text
apps/<appname>/src/
├── features/
│   └── <domain>/
│       ├── api/        ← データ取得・更新 hook(queryOptions + useSuspenseQuery / useMutation)
│       ├── hooks/      ← form / フローなど画面ロジックの hook(Router 非依存)
│       ├── components/ ← View 断片(Router 非依存)
│       └── その他もろもろ
└── routes/
    └── <route>/
        ├── -components/
        │   ├── fallbacks/  ← 必要なルートのみ(pending / error の中身)
        │   └── Page.tsx    ← Page 本体(components/を使用して画面を構築、Router 依存を features に注入)
        └── index.tsx       ← validateSearch / loader / アダプタの宣言のみ
  • Router 依存の宣言は routes、ドメインの実体は features に
  • routes と features の分担が ディレクトリ構造で見える形に

なぜ fallbacks が要る?

  • loader で ensureQueryData、component は useSuspenseQuery で読む
  • loader が一定時間 pending になると pendingComponent、失敗すると errorComponent が出る
    • ensureQueryData で先に埋めておくので、Page 側は「取得済み前提」で書ける
  • -components/fallbacks/ に中身を用意する
  • prefetch するルートだけなので 「必要なルートのみ」

コードで見ると

tsx
// routes/contracts/index.tsx — 宣言だけ
export const Route = createFileRoute("/contracts")({
  loader: ({ context }) => context.queryClient.ensureQueryData(contractsQueryOptions),
  pendingComponent: ContractsPending, // -components/fallbacks/
  errorComponent: ContractsError, //    -components/fallbacks/
  component: Page,
});

// -components/Page.tsx — データは取得済みとして読む
function Page() {
  const { data } = useSuspenseQuery(contractsQueryOptions);
  return <ContractList items={data} />;
}
  • index は宣言だけ、Page は「取得済み前提」で useSuspenseQuery
  • loader の ensureQueryDataroutes と features の繋ぎ目

採用してどうなった

  • features のベストプラクティスと TanStack Router の機能、両方を活かせる形
  • Router の変更影響は routes に閉じる → features は Router 非依存のまま
  • テストの境界も明確に
    • features は依存を mock した単体 / コンポーネントテスト
    • routes は backend だけ mock した e2e ライクなテスト
  • 反面、component の置き場が 3 つに → AI が迷わない整備が重要に

しかし!
突如として
server 処理が必要になった

思い切って Start を採用

  • PoC を進めるなか、認証周りなど server で処理する必要性 が出てきた
  • React Router の SSR モードに乗り換える / Hono などで BFF を立てる
  • すでに TanStack Router 採用済み → 既存設計を活かしたまま server function / SSR を足せる
    • 思い切って TanStack Start に踏み切った(PoC なので「失敗してもええやろ」の精神)

とはいえ何も分からん!!!

TSKaigi のこの 2 本が刺さった

2 本から学んだこと

serverFn、どこに置く?

また3案を考えた

  1. src/server に寄せる
  2. routes にハイブリッド
  3. features に寄せる

案① src/server に集約

text
src/
├── features/<domain>/   ← api / hooks / components
├── routes/<route>/      ← ルーティング宣言のみ
└── server/<entity>/     ← ★ serverFn を全部ここへ集約
    ├── <entity>.functions.ts      wrapper
    ├── <entity>-schema.ts         schema
    └── <verb>-<entity>.server.ts  logic
  • serverFn を横断の src/server に全部集約
  • 分かりやすいが、管理する場所が 3 つ目に増える

案② routes にハイブリッド

text
src/
├── features/<domain>/
│   ├── <entity>-schema.ts         schema
│   └── <verb>-<entity>.server.ts  logic(実体)
└── routes/<route>/
    └── -api/
        └── <entity>.functions.ts  ★ wrapper だけ routes 側
  • wrapper だけ routes、schema / logic は features
  • serverFn は useServerFncomponent(features 側)から呼べる
  • → wrapper を routes に置くと features が routes に依存する逆転構造になる

案③ features に集約

text
src/features/<domain>/
└── server/                        ← ★ wrapper も含めて全部 features
    ├── <entity>.functions.ts      wrapper
    ├── <entity>-schema.ts         schema
    └── <verb>-<entity>.server.ts  logic
  • wrapper も含めて features/<domain>/server に全部
  • 管理対象が増えず、「ロジックの処理」という性質とも合う

features 寄せを採用

  • 判断基準は同じ → Router 依存なら routes、ドメインに関係するなら features
  • createServerFn の wrapper は Page のような薄い層ではなく、ドメインに関係していた
  • routes に切り出すと、ドメインが routes と features の 2 箇所に分散 する
  • → serverFn は 2つ目(routes ハイブリッド)を蹴って、3つ目(features 寄せ) を採用

そしてこんな形にした!

ディレクトリ構成(+ server)

text
apps/<appname>/src/
├── features/
│   └── <domain>/
│       ├── api/        ← データ取得・更新 hook(queryOptions + useSuspenseQuery / useMutation)
│       ├── server/     ← ★ Server Function
│       │   ├── <entity>.functions.ts  ← createServerFn のラッパー
│       │   ├── <entity>-schema.ts     ← 入出力の schema
│       │   └── <verb>-<entity>.server.ts ← ロジック
│       ├── hooks/      ← form / フローなど画面ロジックの hook(Router 非依存)
│       ├── components/ ← View 断片(Router 非依存)
│       └── その他諸々
└── routes/
    └── <route>/
        ├── -components/ ← fallbacks / Page.tsx
        └── index.tsx    ← validateSearch / loader / アダプタの宣言のみ
  • features に server/(schema / function / logic)を追加しただけ
  • Routerの依存はroutes配下に、軸はブレていない

TSKaigi が学びになった

  • 何も分からなかった server 周りを、TSKaigi のセッションのおかげで前に進められた
  • createServerFn は薄く保つ」という共通の指針が、構成を決める軸になった
  • カンファレンスで得た知見を、 設計に落とし込めた
  • TSKaigi最高!!!

まとめ

  • TanStack Router / Start は事例が少なく、決まり事がないと機能を活かしきれない
  • TanStack Router の機能を活かしつつ、Router 依存は routes、ドメインの実体は features と線引きした
  • TSKaigiでの学びを活かし、serverFn の置き場もまとめて決められた
  • 成熟した Vite のエコシステムに乗れるので、Next より採用されてほしい

ご清聴ありがとうございました