最近、長期再利用できる TypeScript のプロジェクトテンプレートを整理している。
自分のプロジェクトは主に AI ツール、管理画面、ワークフロー、エディター、デスクトップ向けの補助アプリだ。技術スタックは React、TypeScript、Vite を中心とし、バックエンドでは Node.js、PostgreSQL、Drizzle を使う。
最初は React のフルスタックフレームワークをどれか一つ選び、ルーティング、SSR、データ取得、API、デプロイを統一しようと考えた。重点的に見たのは Next.js、React Router Framework Mode、TanStack Start で、ついでに Remix 3 もチェックした。
しばらく調べた結果、フレームワークの機能が多いほど適しているとは限らないだと気づいた。インタラクション重視のアプリでは、SSR やサーバー・クライアントで同じコードを実行するモデルが余計な概念をかなり持ち込むが、それらがそのままプロダクト価値に直結するとは限らない。
最終的に決めた方針は次のとおり。
公开站点使用 SSR 或 SSG
登录后的产品使用 SPA
业务能力通过统一 HTTP API 提供
具体的な判断の経緯を記事として残しておく。
TanStack Start の吸引力
TanStack Start は、現代的な TypeScript フレームワークに対する私の期待にかなり合っていた。
TanStack Router と Vite の上に構築され、型安全なルーティング、Search Params、Loader、Server Function、Middleware、SSR を提供する。TanStack Query、Form、Table と組み合わせれば、フロントエンドアプリ開発の一式になる。
簡単なサーバー側クエリは次のように書ける:
ルーティングから直接呼び出せる:
コンポーネントはルーティングから結果を取得する:
この書き方なら、HTTP Client と戻り値の型を手動で管理する必要がない。Server Function はクライアント側でも普通の非同期関数のように振る舞うため、入出力の型はそのまま推論できる。
シンプルなページなら、このモデルは滞りなく動く。
問題は、ページ内のインタラクションが複雑になり始めると現れる。
たとえば、一覧を手動で更新したい場合は、次のようにする:
一覧を TanStack Query で管理しているなら、こう書く:
レコードを一件追加したら、ルーティングの Loader を再実行できる:
Query Cache を無効化する方法もある:
こうなると、プロジェクトに 2 つのデータライフサイクルが同時に存在する場合がある:
Route Loader 管理路由数据
TanStack Query 管理客户端缓存
さらに Query Options、Mutation、TanStack Form、TanStack Table、Zod を追加していくと、シンプルな Feature でも多くのファイルに分散しがちだ:
project.server.ts
project.functions.ts
project.queries.ts
project.mutations.ts
project.schema.ts
project.form.ts
project.columns.tsx
各モジュールを単独で見れば、それぞれ妥当な用途がある。しかし組み合わせると、シンプルなビジネスロジックでもフレームワーク起因のコード量が大きくなりやすい。
TanStack エコシステム自体が、この書き方を要求しているわけではない。問題なのは、提供される機能が非常に多く、開発者がプロジェクトの初期段階から全機能を盛り込んだ構成を採用しやすいことだ。
複雑な Dashboard、リアルタイムデータ、無限リスト、楽観的更新では、これらの抽象が役に立つ。一方、通常の CRUD には、やや重すぎるかもしれない。
Next.js の複雑さは実行境界にある
Next.js のコードは通常、短くなる。
Server Component から直接データベースを問い合わせられる:
更新処理は Server Action として書ける:
ページから直接送信できる:
このモデルは、コンテンツサイト、EC サイト、SSR を軸にしたページに向いている。ページ、データ取得、更新ロジックを近くにまとめられるため、プロジェクト初期のコード量もそこまで多くならない。
クライアント側の操作が増えると、コードには次第に次のようなものが加わる:
Server Component
Client Component
Server Action
Route Handler
Suspense
缓存失效
序列化边界
Next.js の追加コストは通常、大量のラッパー関数を書くことではなく、ランタイムモデルとフレームワークのルールに現れる。
エディター、ワークフロー、管理画面では、ローカル状態、ポーリング、リアルタイムデータ、モーダル、複数領域をまたぐ操作などをページに多数実装する。その分、Server Component と Client Component の境界を常に維持する必要がある。
Next.js は依然として成熟した本番向けソリューションだ。ただし、アプリケーションの組み立ては Server Component とページレンダリングを中心に設計する傾向があり、現在のプロダクトの形とは完全には合わない。
React Router のデータモデルは比較的統一されている
React Router Framework Mode では Loader と Action を使ってルーティングデータを整理する。
そのモデルは従来の Web 開発に近い:
读取数据使用 loader
修改数据使用 action
页面提交使用 Form 或 fetcher
Action が完了すると React Router が該当する Loader を再検証するため、通常のフォーム利用では Query Cache を別途管理する必要がない。
この方式はフォームやルーティング駆動のアプリに向いている。Request、Response、Cookie、Session、アップロードはすべて標準 Web API を使い、デバッグの経路も比較的明確だ。
ページ内の操作が多い場合、Action は intent に応じて異なる処理に分岐させる必要があるかもしれない:
独立した Resource Route に分けることもできる。
React Router のコード量は通常、Next.js と完全な TanStack 構成の中間くらいになる。従来の Web データフローにはよく合うが、複雑なクライアントキャッシュや複数のコンポーネントで共有するリモート状態が必要なら、最終的に TanStack Query を導入することになり得る。
SPA のデータフローはプロダクト管理画面に向いている
いくつかのフルスタックフレームワークを並べて比較したあと、一般的な SPA をもう一度見直した。
SPA プロジェクトのリモートデータフローは直接的だ:
React Component
↓
TanStack Query
↓
HTTP API
↓
业务逻辑
↓
数据库
クエリ:
手動更新:
追加後に一覧を更新:
このモデルでは、リモートデータの状態は 1 つだけになる。
查询使用 useQuery
修改使用 useMutation
缓存刷新使用 invalidateQueries
页面参数使用 Router Search Params
本地状态使用 useState 或 Jotai
ログイン後の管理系统なら、このモデルは十分だ。
管理画面、AI ツール、ワークフローアプリは通常、検索エンジンが内部ページをインデックスする必要はない。ユーザーがログインした状態で長く滞在し、主要な操作はブラウザ内で行われる。
これらのページでは SSR の恩恵は限られる。一方、Hydration、サーバーとクライアントのモジュール境界、Loader のライフサイクル、キャッシュ同期などの問題が増える。
そこで、プロダクト全体を SSR モデルに統一することはしないことにした。
SSR と SPA をルーティング領域ごとに分ける
最終的なプロダクトのルートは 3 つの領域に分けられる:
公開領域では SSR または SSG を使い、次を担当する:
- SEO
- 初回表示の内容
- Open Graph
- ブログとドキュメント
- プロダクト紹介
- 公開ツールページ
/app 領域では SPA を使い、次を担当する:
- Dashboard
- ワークフロー
- エディター
- AI 生成
- リアルタイムデータ
- 複雑なテーブル
- 複数パネルにまたがる操作
API は次を担当する:
- 認証
- 権限
- ビジネスロジック
- データベースアクセス
- ファイルアップロード
- Webhook
- Desktop とその他のクライアント
これで、各領域では主要なデータモデルを 1 つだけ維持すればよい。
最終的な Monorepo 構成
現時点では、次の構成が自分に合っていそうだ:
各アプリの責務は次のとおり:
共有パッケージは控えめに保つ。
packages/ui には基本コンポーネント、スタイル Token、アニメーションプリセットを入れる。
packages/contracts には、複数のクライアントで本当に共有する必要がある Zod Schema、エラー構造、API 型だけを置く。
packages/db には Drizzle Schema、Migration、データベースクライアントを入れる。
ビジネスコードは原則個別の App に置き、プロジェクトの開始時から packages/core へ抽出することはしない。
API には Hono を使う
API レイヤーには Hono を採用した。
そのルーティングの書き方は比較的軽い:
クライアントでは Hono RPC を使える:
TanStack Query から呼ぶ場合は次のようになる:
呼び出しチェーン全体は次の通り:
Component
↓
TanStack Query
↓
Hono RPC Client
↓
Hono Route
↓
Feature Function
↓
Drizzle
この API は Web、Desktop、ブラウザ拡張、Agent、Webhook へ同時に提供できる。
フレームワーク内部専用の Server Function に比べて、HTTP API の方が适用范围は広い。
バックエンドは Feature 単位で整理する
フロントエンドとバックエンドを分けるとしても、バックエンドまで Java 式のレイヤードアーキテクチャを写す必要はない。
Won't adopt这种全局目录:
controllers/
services/
repositories/
mappers/
dto/
API は引き続き Feature 単位で整理する:
シンプルな Feature では必要なファイルだけを残す:
project.routes.ts
project.schema.ts
データベースのインスタンスは直接 import できる:
実装が複数ある場合、テストが複雑な場合、実行時に差し替える必要がある場合だけ、引数またはコンストラクタを使って注入する。
アーキテクチャの形を整えるためだけに、各テーブルごとに Repository、Service、Mapper を作ることはしない。
ツールチェーンは攻め、実行レイヤーは安定させる
プロジェクトのツールチェーンには比較的新しいものを使う:
Vite
Rolldown
Oxc
Oxlint
Oxfmt
Vite+
Vitest
Playwright
これらのツールが本番環境の業務データに直接触れることはない。特定のバージョンで互換性の問題が起きても、対応する低レベルのコマンドにフォールバックできる。
アプリケーションレイヤーでは次を使う:
React
TanStack Router
TanStack Query
TanStack Form
TanStack Table
Jotai
Tailwind CSS
shadcn/ui
Base UI
Motion
Lucide
ただし TanStack Form、Table、Jotai は必要なときだけ使い、各ページの固定依存にはしない。
データとセキュリティのレイヤーは比較的堅実にする:
Node.js LTS
PostgreSQL
Drizzle ORM
Better Auth 稳定版本
Zod
正式数据库 Migration
Cookie Session
ツールのアップグレードが失敗しても影響するのは開発効率だけだが、認証やデータベースのアップグレードが失敗すれば、ユーザーやデータに影響し得る。両方に同じアップグレード方針を使うべきではない。
デプロイ方法
ソースコードを複数の App に分けても、本番環境で大量のサービスを運用する必要があるわけではない。
同じドメインでまとめられる:
example.com/
→ Site
example.com/app/*
→ SPA
example.com/api/*
→ API
ゲートウェイでパスごとに分岐する:
/ → Site
/app/* → Web
/api/* → API
認証は同じオリジンの Cookie で行う。SPA から /api を呼ぶ場合、クロスオリジンの Token を処理する必要はない。Desktop などの外部クライアントには別の認証方式を使う。
個人プロジェクトの初期段階なら、Hono で API と SPA の静的ファイルを同時に配信し、Docker イメージも Node プロセスも 1 つずつにまとめられる。
結論
今回の技術選定では、すべての機能を 1 つのフルスタックフレームワークで一元管理するのではなく、ページの種類ごとに、より適した実行モデルへ配置し直した。
公开内容使用 SSR 或 SSG
登录后的产品使用 SPA
通用业务能力使用 HTTP API
最終的な技術スタックはおおむね次の通り:
この構成なら、現代的な TypeScript ツールチェーンの利点を保ちながら、業務管理画面でも従来どおりの SPA データフローを続けられる。
フレームワークの機能は、プロジェクトの要件に応じて追加すればよい。ツール中心のプロダクトでは、各ページで SSR、Loader、Server Function、Query を同時に組み合わせるより、クライアント、API、データの境界を明確に保つほうが、保守しやすい。