技術ブログ一覧へ

Next.js × NestJS × Expo を TypeScript で束ねる — 4 面構成モノレポ実装記

細執筆細岡 希夢ExecutiveDirectorプロフィールを見る

Web 2 面(利用者向けフロント + 管理画面)+ モバイル(iOS/Android)+ API という 4 面構成の プロダクトを全部 TypeScript で書き切った。この記事は、その意思決定と実装の骨格を残しておくものだ。 モノレポの中身は「Next.js App Router × 2 + Expo + NestJS 11 + Prisma 7」で構成している。

「全部 TS で書く」は結論としては悪くない選択だったが、単純に一言語にまとめれば楽になる という話ではなかった。共有できる境界とできない境界の見極め、DTO の流儀、環境変数の集約、 worktree での並行開発の運用、このあたりを一段深く設計しないと逆に壊れやすくなる。

1. なぜ 4 面全部を TypeScript で書くのか

最初に「TypeScript 統一」を選んだ最大の理由は、ドメインモデルを 1 箇所に閉じ込められる ことだ。 packages/types の下にドメイン別の型定義をドメイン軸で分けて 9 ファイル程度並べる構成にした。

packages/types/src/
├── activity.ts     # 利用者のアクション履歴系
├── admin.ts        # 管理画面固有のビュー型
├── billing.ts      # サブスク・決済
├── common.ts       # 共通ユーティリティ型
├── content.ts      # 主要リソース (Item など)
├── core.ts         # User などの中核エンティティ
├── enums.ts        # ロール、状態遷移
├── error.ts        # API エラーコード
└── index.ts

これを NestJS の DTO も、Next.js の Server Component も、Expo の画面も、全部同じ import で使う。 中核エンティティの型を変えたい時に修正が必要なのは 1 ファイルだけで、他 3 面はコンパイルエラーとして 「ここも直せ」と教えてくれる。この体験は他言語構成では得られない。

対抗馬として検討したのは 3 つ:

  • Kotlin + Swift + Node: モバイルネイティブの品質は上がるが、実装コストが単純に 3 倍近くになる。 初期フェーズの少人数開発では割に合わない。
  • Go の API: Nest 側のパフォーマンスは Go の方が有利だが、packages/types を Go の struct と TS の interface で二重管理する保守コストが恒常的にのしかかる。protobuf で揃える手もあるが、 それはそれで別レイヤの学習コストになる。
  • Python API: FastAPI + Pydantic は魅力的だが、上と同じで型の二重管理が発生する。

このプロジェクトは「小さいチームで機能を早く積む」フェーズを優先しているので、全 TS を選んだ。 「言語が同じ」ではなく「型定義が物理的に同じファイル」であることが本質だ。

2. モノレポの土台 — pnpm workspace + Turborepo

構成は素直で、pnpm workspace + Turborepo + React 19 の overrides という 3 点セットだ。

ルート package.json:

{
  "name": "myapp",
  "private": true,
  "packageManager": "pnpm@10.8.1",
  "scripts": {
    "build": "turbo run build",
    "dev": "turbo run dev",
    "lint": "turbo run lint",
    "test": "turbo run test"
  },
  "pnpm": {
    "overrides": {
      "react": "19.2.5",
      "react-dom": "19.2.5",
      "react-test-renderer": "19.2.5"
    }
  }
}

overrides は Expo と Next.js が引き込む React のバージョンを強制的に一致させる目的で入れている。 これがないと片方が 18.3 系、もう片方が 19 系、みたいな状態になり、packages/ui の共有コンポーネントが peer dep 警告で埋まる。overrides を書いた瞬間からは一切気にせず済む。

turbo.json はほぼデフォルト、dev だけ persistent にしてある:

{
  "$schema": "https://turborepo.dev/schema.json",
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**", ".next/**", "!.next/cache/**"]
    },
    "dev": { "cache": false, "persistent": true, "passThroughEnv": ["*"] },
    "lint": {},
    "test": { "dependsOn": ["^build"] },
    "test:e2e": { "dependsOn": ["^build"] }
  }
}

pnpm-workspace.yaml はシンプルに:

packages:
  - "apps/*"
  - "packages/*"

packages/config に ESLint Flat Config と TSConfig を集約している。 各アプリの eslint.config.mjs は 3〜4 行で、共通設定を import して固有ルールだけ足すだけになる。 「アプリごとに ESLint 設定が微妙にズレて lint 結果が変わる」問題を、この 1 パッケージが吸収する。

3. 共有パッケージの設計 — Zod で境界を固める

4 面全 TS の一番おいしい部分がここだ。packages/types と packages/api-client の 2 パッケージが Web / Mobile / API を結ぶ神経系になっている。

DTO の流儀は「手書き DTO + Zod」

REST API の DTO 定義には tRPC・OpenAPI 生成・手書きの 3 択があるが、このプロジェクトでは Zod スキーマを 書いて z.infer で型を導出し、それを packages/types に置く 方式を採っている。

// packages/types/src/content.ts
import { z } from "zod";

export const ItemSchema = z.object({
  id: z.string().uuid(),
  ownerId: z.string().uuid(),
  title: z.string().min(1).max(120),
  body: z.string().max(10_000),
  createdAt: z.string().datetime(),
});

export type Item = z.infer<typeof ItemSchema>;

export const CreateItemInputSchema = ItemSchema.omit({ id: true, createdAt: true });
export type CreateItemInput = z.infer<typeof CreateItemInputSchema>;

この 1 ファイルで型定義とバリデータが同時に手に入る。

  • NestJS 側では ZodValidationPipe を書いて CreateItemInputSchema で入口を殴る
  • Next.js の Server Action は同じスキーマで form 入力を検証する
  • Expo は fetch レスポンスに ItemSchema.parse() を当てて実行時の型ずれを防ぐ

なぜ tRPC・OpenAPI 生成を採らなかったか。tRPC は Nest との組み合わせで得られる旨みが薄い (Nest 側で改めて Controller を書くことになる)。OpenAPI 生成は生成ステップが CI に増えて、 モノレポの中で完結する構成には過剰投資に感じた。「型を一箇所で書いて全面で使う」ことが目的なら、 Zod で十分に達成できる。

なお NestJS 内部の DTO クラスバリデーションには class-validator を併用している。 Zod は境界(Controller の入口・fetch の出口)担当、class-validator は Nest の DI ライフサイクルに 乗る内部 pipe 担当、という棲み分けだ。両方入れているのは重複ではなく、責任分離のためだ。

packages/api-client は client + hooks + errors の 3 本柱

packages/api-client/src/
├── client.ts    # fetch ラッパー、認証ヘッダ付与、エラー正規化
├── hooks.ts    # React Query ベースの useXxx フック群
├── errors.ts    # ApiError クラスと型ガード
└── index.ts

client.ts は素の関数、hooks.ts はその関数を React Query でラップしたもの、errors.ts は サーバ側の error コードと対応する型を持つ。Web も RN も同じ import で同じフックを叩ける ので、 「モバイルとウェブで API 呼び出しの流儀が微妙に違う」問題が起きない。

4. API 側 — NestJS 11 + Prisma 7 の中身

apps/api/package.json の依存関係を抜粋する:

{
  "dependencies": {
    "@nestjs/common": "^11.1.18",
    "@nestjs/core": "^11.1.18",
    "@nestjs/passport": "^11.0.5",
    "@nestjs/throttler": "^6.4.0",
    "@prisma/adapter-pg": "^7.7.0",
    "@prisma/client": "^7.7.0",
    "passport-jwt": "^4.0.1",
    "passport-google-oauth20": "^2.0.0",
    "apple-signin-auth": "^2.0.0",
    "class-validator": "^0.15.1",
    "stripe": "^22.0.1",
    "zod": "^4.3.6",
    "@sentry/node": "^10.49.0",
    "winston": "^3.17.0"
  }
}

ポイントは 4 つ。

認証は Web / iOS / Android の 3 面同時対応。passport-jwt を基盤にして、 Google は passport-google-oauth20、Apple Sign-In は apple-signin-auth を Passport の Strategy として 自作でラップしている。3 面全部で認証をサポートするなら、この構成は避けられない。 Cookie ベースか Bearer トークンかは面ごとに切り替えていて、 Web は httpOnly Cookie、モバイルは Keychain + Bearer にしている。

Prisma 7 は @prisma/adapter-pg で pg driver を差している。 Prisma 7 は Rust エンジンから driver adapter 方式へ移行中で、 Postgres 用の pg アダプタを明示的に指定する構成になる。

Stripe Webhook は Stripe CLI で開発時に forward する。 本番の endpoint_secret はサーバに置きっぱなしにできるが、 ローカル開発は毎回 stripe listen の secret が変わるので、Makefile ターゲットで自動化している (詳細は 6 章)。

Throttler と Sentry と Winston でオペレーション面を先に固める。 @nestjs/throttler で軽い rate limit、@sentry/node で例外通知、 winston-daily-rotate-file でログ日次ローテーション。 「後で困るやつ」を最初に入れておくと、機能追加でバタバタしている時に助けられる。

5. Web と Mobile での共有戦術 — どこまで揃えて、どこで諦めるか

4 面 TS 化で一番判断が難しいのが「UI をどこまで共有するか」だ。このプロジェクトでは以下の線を引いた。

共有する:

  • packages/types の型定義(全面で import)
  • packages/api-client のクライアント関数と React Query フック
  • packages/utils の純ロジック(日付計算、金額フォーマット、URL 生成)

共有しない:

  • UI コンポーネントの実装本体(Web は Tailwind、RN は StyleSheet で書き分け)
  • ルーティング(Next.js App Router と Expo Router は別物)
  • フォーム制御(Web は react-hook-form、RN は Formik 系で分岐)

React Native Web で全部揃える選択肢は最初から取らなかった。RN Web は「モバイルアプリを Web でも動かす」 文脈では強いが、面ごとに UI 要件が大きく異なる(管理画面と利用者画面はほぼ別物になる) プロダクトでは、抽象化コストが利益を上回る。

その代わり、api-client 経由で 同じフックを Web でも RN でも叩ける のは非常に効いた。

// Web (Next.js の Client Component)
"use client";
import { useItems } from "@myapp/api-client";

export function ItemList({ ownerId }: { ownerId: string }) {
  const { data, isLoading } = useItems({ ownerId });
  // ...
}

// Mobile (Expo)
import { useItems } from "@myapp/api-client";

export function ItemListScreen({ ownerId }: { ownerId: string }) {
  const { data, isLoading } = useItems({ ownerId });
  // ...
}

同じ import、同じシグネチャ、同じエラー型。UI レイヤは違っても、 データレイヤの体験を統一 できることが、モノレポ全 TS の実利だ。

6. direnv と worktree slot で「複数機能を同時に触る」を可能にする

最後の章は運用面。このプロジェクトでは 1 つの機能を作っている間に別 issue の hotfix も 並行して進めたいことが多く、そのために git worktree + slot 番号 で環境を分離している。

  • main の作業ディレクトリは slot 0
  • feature/xxx を worktree で切ると slot 1、2、3 と自動割当
  • slot ごとに Postgres の port、Docker Compose の project 名、API port を分離

これを支えているのが direnv だ。ルートに .envrc を置いて、そこから slot 番号を計算して 各種環境変数を export する。アプリ側の .env ファイルは基本使わず、 環境変数は .envrc を単一 SoT にしている。

# .envrc の骨子
export PROJECT_SLOT=${PROJECT_SLOT:-0}
export DB_PORT=$((5432 + PROJECT_SLOT))
export API_PORT=$((3001 + PROJECT_SLOT * 10))
export WEB_PORT=$((3000 + PROJECT_SLOT * 10))
export COMPOSE_PROJECT_NAME="myapp-slot${PROJECT_SLOT}"
export DATABASE_URL="postgresql://postgres:postgres@localhost:${DB_PORT}/myapp"

# ローカル固有の secret は gitignored なファイルに退避
source_env_if_exists .envrc.local

make wt-new ISSUE=42 NAME=auth を叩くと空いている slot 番号を見つけて worktree を作り、 新しいディレクトリの .envrc に slot 番号を書き込む。cd した瞬間に direnv が その worktree 専用のポート・DB・Compose project を export してくれる。

Stripe の Webhook secret も slot ごとに違うので、これも Makefile ターゲットで自動化した:

.PHONY: stripe-listen
stripe-listen: ## Stripe Webhook を当 slot の API へ forward
	@SECRET=$$(stripe listen --print-secret) && \
		touch .envrc.local && \
		grep -q '^export STRIPE_WEBHOOK_SECRET=' .envrc.local \
			&& sed -i.bak "s|^export STRIPE_WEBHOOK_SECRET=.*|export STRIPE_WEBHOOK_SECRET=$$SECRET|" .envrc.local \
			|| echo "export STRIPE_WEBHOOK_SECRET=$$SECRET" >> .envrc.local; \
		rm -f .envrc.local.bak

stripe listen --print-secret で取れる secret を .envrc.local に書き込む (あれば置換、なければ追加)。direnv の reload で API プロセスに新しい secret が渡る。 「Stripe Webhook のテストを始めたら secret 書き換えるのを忘れて延々 401」みたいな事故が消える。

まとめ

4 面全 TS 化のうまみは「言語が揃う」ことより、型定義と API クライアントを物理的に共有できる 点にある。packages/types に Zod スキーマを置いて z.infer で型を配り、 packages/api-client で fetch フックまで揃えれば、Web も Mobile も同じインタフェースで サーバを叩ける。

一方で、UI やルーティングは面ごとに違う要件を素直に受け入れて書き分けたほうが早い。 「モノレポで全部揃える」の意味を データレイヤに絞る ことが、この構成を成立させる肝だと思う。

そして運用面では、direnv + worktree slot の仕組みで「複数 issue を並行して触る」ことを 物理的に安全にできる。ここまで揃えて初めて「全 TS モノレポ」の楽しさが出てくる。

もし同じ規模のプロダクトを新規に始めるなら、この構成は再度採ると思う。 Kotlin + Swift ネイティブや Go API を選ぶ理由が明確にある場合を除いて、 「4 面 TS モノレポ」は現時点で最も投資対効果が高い選択肢の一つだ。