Next.js × React 実践パターン集 — App Router 時代のアーキテクチャと設計テクニック

Next.js 15 (App Router) + React + Hono を使ったプロダクト開発で実際に使われているパターンやテクニックを、具体的なコード例とともにまとめました。

1. Next.js App Router のルーティングパターン

Dynamic Segments の種類

App Router では、ディレクトリ名の [] 記法でルーティングを制御します。

パターン例マッチするパスparams の値
[id]/users/[id]/users/123{ id: "123" }
[...slug]/docs/[...slug]/docs/a/b/c{ slug: ["a","b","c"] }
[[...slug]]/api/[[...slug]]/api, /api/a/bundefined, ["a","b"]

[...slug] vs [[...slug]] — Catch-all と Optional Catch-all

名前の通り、[[...slug]](二重括弧)はルート自体にもマッチする点が異なります。

#///#///aaaaaa[ppp[ppp.iii[iii.//.//.ccc.ccc]hhh.hhhaaa]aaattt]ttt→////ma→maCe/e/asbOsbts/ps/cactachgig-eoeasnslall(1✅✅C✅✅セ❌a✅グ→→t→→メ→c→ンsshssトllマ-lls以uuッauul上ggチlgguがしlg必==な(==須い0=)[[セ[[""グ""umaメmane"ンe"ds,トs,esでsfa"もa"igbOgbne"Ke"es,)s,d""]"]"cc""]]

...(スプレッド構文)の意味

... は JavaScript のスプレッド/レスト構文 (...args) からの借用です。単なる慣習ではなく、**Next.js の構文として「複数のパスセグメントを配列で受け取る」**という意味を持ちます。... がなければ1セグメントしかマッチしません。

Route Groups: (authenticated) パターン

括弧 () で囲んだディレクトリは Route Group と呼ばれ、URL パスには影響しません。

a├││└p──p──/a(├└hpa──eiu──a/tlhuotesrhned/treisrc/sa/ted)/##U認R証L不要に反映されない。認証middlewareを適用するグループ

/api/users と /api/orders には認証 middleware が適用され、/api/health には適用されない、という整理が可能です。


2. Hono を Next.js に統合するブリッジパターン

なぜ Hono を使うのか

Next.js の Route Handler だけでもAPIは作れますが、Hono を導入すると以下の利点があります。

  • ミドルウェアの柔軟な組み合わせ
  • バリデーション(Zod 連携)の統一的な記法
  • Next.js に依存しないルーティングロジック(テスタビリティ向上)

実装パターン

// app/api/(authenticated)/chat/[[...route]]/route.ts
import app from "@/features/chat/api/hono/chat.router";
import { handle } from "hono/vercel";

export const GET = handle(app);
export const POST = handle(app);

[[...route]](Optional Catch-all)が必須な理由は、Hono 側で定義した全てのサブルートをキャッチするためです。

///aaapppiii///ccchhhaaattt//mheissstaogreys/123→→→HHHooonnnoooががが"""///"mheiとssしstてao処gr理eys/"12と3し"て処と理して処理

Next.js 側の route.ts は「薄いアダプター」に徹し、ビジネスロジックは一切持ちません。実質的な責務は以下の2つだけです。

  1. Next.js ↔ Hono のブリッジ: handle(app) でリクエストを委譲
  2. HTTP メソッドの宣言: export const GET/POST/PUT/DELETE

3. API Route からの型 re-export — フロントとバックの契約

パターン

// app/api/(authenticated)/chat/[[...route]]/route.ts
import app from "@/features/chat/api/hono/chat.router";
import { handle } from "hono/vercel";

// 型の re-export
export type {
  ConversationResponse,
  MessageResponse,
} from "@/features/chat/common/types";

export const GET = handle(app);
export const POST = handle(app);
// フロントエンド側(hooks)
import type { MessageResponse } from "@/app/api/(authenticated)/chat/[[...route]]/route";

なぜ直接 features から import しないのか

型の定義元は features/chat/common/types.ts ですが、API route ファイルを経由してインポートすることで、**「この型は API のレスポンス仕様(契約)である」**ということを明示できます。

ただし、[[...route]] を含むパスをインポートに書くと可読性が下がるという面もあり、チームのルール次第です。


4. バレルファイル — 公開APIの明示

バレルファイルとは

ディレクトリ内のモジュールをまとめて re-export する index.ts のことです。

// components/Chat/index.ts
export { ChatProvider } from "./ChatProvider";
export { FloatingButton } from "./FloatingButton";
export { ChatPanel } from "./ChatPanel";

効果

1. インポートの簡潔化

// Before
import { ChatProvider } from "@/components/Chat/ChatProvider";
import { FloatingButton } from "@/components/Chat/FloatingButton";
import { ChatPanel } from "@/components/Chat/ChatPanel";

// After
import { ChatProvider, FloatingButton, ChatPanel } from "@/components/Chat";

2. 公開 / 非公開の境界を明示

バレルファイルに含まれないコンポーネント(例: MessageBubble, InputArea)は「内部実装」であることが分かります。外部からは index.ts に export されたものだけを使うべき、という設計意図が伝わります。


5. React の Context + Provider パターン

問題: Props のバケツリレー

A└p─p─L└a─y─ouP└└ta──n──elHB└eo─ad─dyeLrist←←icsoOnpveenr,saotniColnoIsdeがが必必要要

すべての中間コンポーネントに props を渡し続けるのは煩雑です。

解決: Context API

// ChatProvider.tsx
"use client";

import { createContext, useContext, useState, useCallback, ReactNode } from "react";

type ChatContextValue = {
  isOpen: boolean;
  conversationId: string | null;
  open: () => void;
  close: () => void;
  setConversationId: (id: string | null) => void;
};

const ChatContext = createContext<ChatContextValue | null>(null);

// カスタムフック(ガード付き)
export function useChat() {
  const context = useContext(ChatContext);
  if (!context) {
    throw new Error("useChat must be used within ChatProvider");
  }
  return context;
}

export function ChatProvider({ children }: { children: ReactNode }) {
  const [isOpen, setIsOpen] = useState(false);
  const [conversationId, setConversationId] = useState<string | null>(null);

  const open = useCallback(() => setIsOpen(true), []);
  const close = useCallback(() => setIsOpen(false), []);

  return (
    <ChatContext.Provider value={{ isOpen, conversationId, open, close, setConversationId }}>
      {children}
    </ChatContext.Provider>
  );
}

ポイント解説

"use client" ディレクティブ

Next.js App Router ではコンポーネントはデフォルトで Server Component です。useState や useContext などの React hooks を使うコンポーネントは Client Component として "use client" を宣言する必要があります。

ガード付きカスタムフック

if (!context) throw new Error("useChat must be used within ChatProvider");

Provider の外で誤ってフックを使った場合に、原因が分かりやすいエラーメッセージを出す定番パターンです。null チェックにより、戻り値の型から null が除外され、利用側で非 null アサーションが不要になる利点もあります。

useCallback によるメモ化

const open = useCallback(() => setIsOpen(true), []);

Provider が再レンダリングしても関数の参照が変わらないようにします。これにより、open を受け取る子コンポーネントが不要に再レンダリングされることを防ぎます。依存配列 [] は「この関数は初回のみ生成し、以後は同じ参照を返す」ことを意味します。


6. React コンポーネント設計の基本パターン

プレゼンテーショナルコンポーネント

"use client";

import { useChat } from "./ChatProvider";

export function ChatHeader() {
  const { close, startNewConversation, toggleHistory, showHistory } = useChat();

  return (
    <div className="flex items-center justify-between px-4 py-3">
      <span className="font-semibold">AI Chat</span>
      <div className="flex items-center gap-1">
        <button onClick={startNewConversation}>New</button>
        <button
          className={showHistory ? "bg-gray-600" : ""}
          onClick={toggleHistory}
        >
          History
        </button>
        <button onClick={close}>×</button>
      </div>
    </div>
  );
}

このコンポーネントの特徴:

  • 状態を持たない (stateless): useState がない
  • Context から受け取った関数を紐づけるだけ: ロジックは Provider に集約
  • 条件付きクラス: テンプレートリテラルで表示状態を切り替える
className={showHistory ? "bg-gray-600" : ""}

useRef + useEffect で前回値を検知

const prevIdRef = useRef<string | undefined>(contextId);

useEffect(() => {
  if (prevIdRef.current !== contextId) {
    prevIdRef.current = contextId;
    setConversationId(null); // コンテキストが変わったらリセット
  }
}, [contextId]);

React には「前回のレンダリング時の値」を自動で保持する仕組みがないため、useRef で手動管理します。useRef は再レンダリングを引き起こさないため、値の保持に適しています。


7. Feature ベースの Clean Architecture

ディレクトリ構成

s├│├││││││├││││├│└r─────c─────/fa└a├││└c├├├└d└i├│└ep─p──o────o─n──ai─p──m────m─f──t/lmaruh{ic├└q├└ostecibae└r└rofco──u──ncyronusx─e─eneam──e──/hprn/stt─p─satmreeosireotiacuiglmsrtnurass{uonrpeeia.saecnpiofrndedstsst.nstaitmee/saa/St.sttsul-oea}/ttoStss-r-crtt.eemos.realihurSSemtu/pieiroootesliesneummhte/n/g-teeihst.nettni..rarhhgnttem.ii.gsspetnntso}sggs.s/..tittstssory.###########tsイユ更参共Zレカ定ド外ンー新照有oスス数メ部フス系系定dポタイサラケ義ンムンー層ー((ススエロビ:スCRキ型ラジス層reーなーッ連Heaマどク携o(adnCt)+oQeR/型ルSU定ー)p義タdーate/Delete)

CQRS (Command Query Responsibility Segregation)

更新系と参照系を明確に分離するパターンです。

c├├└o───m───macucnrpadednsaac/tteeel(OOO更rrr新ddd系eee)rrr...tttsssque├└r──i──esgl/eits(Ot参rO照dr系ed)re.rtss.ts
  • Command: 副作用を持つ(DB 書き込み、外部API呼び出し等)
  • Query: 副作用なし(データの取得のみ)

この分離により、「この関数はデータを変更するか?」が一目で分かり、テストやレビューの観点も明確になります。

層の依存ルール

aadipponipmf/larh↓i↓i↑aocnsn呼a呼参toびtび(照r出i出ドのu(すoすメみcイnイtンンuフ(層rラユ)e層ー)ス(ケイーンスフ層ラ)層)←←最もD内B側,。外外部部にA依P存Iし等なのい具体的な実装

内側の層は外側の層を知りません。これにより、ドメインロジックが特定のフレームワークやライブラリに依存しない設計になります。


8. Fire-and-Forget パターン — 非同期処理の切り離し

ユースケース

メインの処理(例: メッセージ送信)の成否に影響しないが、裏でやっておきたい処理(例: 検索用インデックスの更新)がある場合に使います。

実装

/**
 * 重い処理を非同期で実行する(fire-and-forget)
 * エラーはログのみ出力し、呼び出し元に伝播しない
 */
async function processInBackground(params: {
  id: string;
  content: string;
}): Promise<void> {
  try {
    const result = await callExternalAPI(params.content);
    await saveToDatabase(params.id, result);
  } catch (error) {
    console.error(`[Background] Failed for ${params.id}:`, error);
    // エラーを throw しない → 呼び出し元に伝播しない
  }
}

/**
 * 公開関数 — 戻り値が void(Promise ではない)
 */
export function triggerBackgroundProcess(params: {
  id: string;
  content: string;
}): void {
  processInBackground(params).catch(() => {
    // 内部で既にログ済み
  });
}

2重のエラーガード構造

ガード役割
内部の try-catch通常のエラーをキャッチしてログ出力
外部の .catch(() => {})万が一の Unhandled Promise Rejection を防止

.catch(() => {}) がないと、try ブロック外で例外が発生した場合に Node.js の Unhandled Promise Rejection となり、プロセスがクラッシュする可能性があります。

処理の流れ

API├├└───ハ───ンドメtレライrスンiポのgン処gス理eを(r返DBすBa保c存kなgどr)ou←ndaPwrao←icteバsッすsクる(グ)ラウ←ンドa処w理aのi完t了をし待なたいな(い裏で勝手に走る)

SQS 等のキューとの違い

Fire-and-Forgetメッセージキュー (SQS 等)
リトライなしあり
永続性サーバー再起動で消えるキューに残る
複雑さゼロ(コードだけ)インフラ構築が必要
適するケース失敗しても再生成可能な処理確実に実行が必要な処理

9. フィーチャーフラグによる段階的リリース

レイアウトへの機能埋め込み

export function AppLayout({ children }: { children: ReactNode }) {
  const featureEnabled = isFeatureEnabled("AI_CHAT");
  const { contextType, contextId } = usePageContext();

  return (
    <div className="flex flex-col h-screen">
      <Header />
      <main>{children}</main>
      {featureEnabled && (
        <ChatProvider contextType={contextType} contextId={contextId}>
          <FloatingButton />
          <ChatPanel />
        </ChatProvider>
      )}
    </div>
  );
}

URL パスからコンテキストを判定するフック

function usePageContext() {
  const pathname = usePathname();
  return useMemo(() => {
    const match = pathname.match(
      /^\/orders\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i
    );
    if (match) {
      return { contextType: "ORDER" as const, contextId: match[1] };
    }
    return { contextType: undefined, contextId: undefined };
  }, [pathname]);
}

useMemo で URL パスが変わらない限り再計算しないようにしています。UUID の正規表現でパスパラメータを抽出し、アプリのどの画面にいるかを判定します。

ポイント

  • フラグ OFF ならコンポーネントツリー自体が生成されない(パフォーマンスに影響なし)
  • 全ページ共通のレイアウトに配置することで、どの画面からでも機能にアクセス可能
  • contextType / contextId により、ページごとに異なるコンテキストを機能に渡せる

まとめ

パターン解決する課題
Optional Catch-all [[...route]]Hono 等のフレームワークへのルーティング委譲
Hono ブリッジNext.js に依存しない API ロジックの実装
型の re-exportフロントとバックの型安全な契約
バレルファイル公開 / 非公開の境界を明示
Context + Providerコンポーネント間の状態共有
Feature ベース Clean Architecture + CQRS機能単位の凝集度と責務分離
Fire-and-Forgetメイン処理をブロックしない非同期実行
フィーチャーフラグ安全な段階的リリース

これらのパターンは単独でも有用ですが、組み合わせることで「型安全で、テスタブルで、段階的にリリースできる」アプリケーションを構築できます。