💡 Tips

React Server Components×TypeScriptのデータフェッチ設計パターン【個人開発入門】

結論:RSCのデータフェッチはこの3パターンを押さえれば十分

React Server Components(RSC)のデータフェッチで悩む個人開発者に伝えたいことは一つです。
「どこで fetch するか」ではなく「誰がデータの責任を持つか」を設計の軸にすると、型安全でメンテしやすいコードになります。

具体的には次の3パターンを使い分けるだけで、個人開発SaaSの8割のユースケースはカバーできます。

パターン使いどころ特徴
① Page直接fetchページ固有の単純なデータ最もシンプル。まずここから
② Componentごとfetchコンポーネントが自律的にデータを持つ並列取得・再利用性に優れる
③ Repository層 + 型ガードDB/APIアクセスを抽象化中規模以上・テスト重視向け

以降で各パターンを実際のコードとともに解説します。


前提:App Routerの基本動作を確認する

Next.js 13以降のApp Routerでは、app/ディレクトリ以下のコンポーネントはデフォルトでServer Componentになります。

// app/dashboard/page.tsx
// この関数はサーバーサイドでのみ実行される
export default async function DashboardPage() {
  const data = await fetch('https://api.example.com/stats');
  // ...
}

Server Componentでできること / できないこと

できることできないこと
async/await で直接 fetchuseState / useEffect
サーバー環境変数の参照ブラウザAPIの利用
DBへの直接アクセスonClick などのイベントハンドラ
シークレットキーの利用

この制約を把握したうえで、3パターンを見ていきましょう。


パターン①:Page直接fetch(入門)

最もシンプルなパターンです。ページコンポーネントが直接データを取得します。

// app/projects/page.tsx
import { ProjectCard } from '@/components/ProjectCard';

// 型定義
type Project = {
  id: string;
  name: string;
  status: 'active' | 'archived';
  createdAt: string;
};

type ApiResponse = {
  projects: Project[];
};

async function getProjects(): Promise<Project[]> {
  const res = await fetch(`${process.env.API_BASE_URL}/projects`, {
    // Next.jsのキャッシュ戦略を指定
    next: { revalidate: 60 }, // 60秒でISR
  });

  if (!res.ok) {
    // Next.js 13+のエラーバウンダリに委ねる
    throw new Error('プロジェクトの取得に失敗しました');
  }

  const data: ApiResponse = await res.json();
  return data.projects;
}

export default async function ProjectsPage() {
  const projects = await getProjects();

  return (
    <main>
      <h1>プロジェクト一覧</h1>
      <ul>
        {projects.map((project) => (
          <ProjectCard key={project.id} project={project} />
        ))}
      </ul>
    </main>
  );
}
// components/ProjectCard.tsx
// Propsの型はPageから渡されるため自動的に型安全
type Props = {
  project: {
    id: string;
    name: string;
    status: 'active' | 'archived';
  };
};

export function ProjectCard({ project }: Props) {
  return (
    <li>
      <span>{project.name}</span>
      <span>{project.status === 'active' ? '稼働中' : 'アーカイブ'}</span>
    </li>
  );
}

ポイント: getProjects() の返り値を Promise<Project[]> と明示することで、コンポーネント側は完全な型推論の恩恵を受けられます。


パターン②:Componentごとfetch(並列取得)

ページが複数の独立したデータを必要とする場合、コンポーネントごとにfetchを分散させます。Next.jsの fetch はリクエストを自動的に重複排除(dedupe)するため、同じURLへの呼び出しはキャッシュされます。

// app/dashboard/page.tsx
import { Suspense } from 'react';
import { StatsWidget } from '@/components/StatsWidget';
import { RecentActivity } from '@/components/RecentActivity';

export default function DashboardPage() {
  return (
    <main>
      <h1>ダッシュボード</h1>
      {/* Suspenseで包むことでストリーミングレンダリングが有効に */}
      <Suspense fallback={<p>統計を読み込み中...</p>}>
        <StatsWidget />
      </Suspense>
      <Suspense fallback={<p>アクティビティを読み込み中...</p>}>
        <RecentActivity />
      </Suspense>
    </main>
  );
}
// components/StatsWidget.tsx
type Stats = {
  totalRevenue: number;
  activeUsers: number;
  churnRate: number;
};

async function getStats(): Promise<Stats> {
  const res = await fetch(`${process.env.API_BASE_URL}/stats`, {
    next: { revalidate: 300 },
  });
  if (!res.ok) throw new Error('統計の取得に失敗しました');
  return res.json();
}

// Server Componentなのでasync関数にできる
export async function StatsWidget() {
  const stats = await getStats();

  return (
    <div>
      <p>売上合計: ¥{stats.totalRevenue.toLocaleString()}</p>
      <p>アクティブユーザー: {stats.activeUsers}</p>
    </div>
  );
}

DashboardPage 自体は async でなくなり、StatsWidgetRecentActivity並列でデータ取得を開始します。Suspense により、遅いコンポーネントが速いコンポーネントのレンダリングをブロックしません。


パターン③:Repository層 + 型ガード(実践)

個人開発SaaSが成長してきたら、データアクセスをRepository層として抽象化します。これにより、テスト・モック・DB切り替えが容易になります。

// lib/repositories/projectRepository.ts
import { z } from 'zod'; // zodで実行時バリデーション

// Zodスキーマ定義(型定義とバリデーションを一元化)
const ProjectSchema = z.object({
  id: z.string().uuid(),
  name: z.string().min(1).max(100),
  status: z.enum(['active', 'archived']),
  ownerId: z.string().uuid(),
  createdAt: z.string().datetime(),
});

// スキーマから型を生成(DRY原則)
export type Project = z.infer<typeof ProjectSchema>;

const ProjectListSchema = z.array(ProjectSchema);

export type ProjectRepository = {
  findAll: (ownerId: string) => Promise<Project[]>;
  findById: (id: string) => Promise<Project | null>;
};

// 実装
export const createProjectRepository = (): ProjectRepository => ({
  async findAll(ownerId) {
    const res = await fetch(
      `${process.env.API_BASE_URL}/projects?ownerId=${ownerId}`,
      { next: { tags: [`projects-${ownerId}`] } } // On-demand revalidation用
    );

    if (!res.ok) throw new Error('Failed to fetch projects');

    const raw = await res.json();

    // Zodで実行時バリデーション → 型安全を保証
    const result = ProjectListSchema.safeParse(raw);
    if (!result.success) {
      console.error('APIレスポンスの型が不正です:', result.error);
      throw new Error('Invalid API response');
    }

    return result.data;
  },

  async findById(id) {
    const res = await fetch(`${process.env.API_BASE_URL}/projects/${id}`, {
      next: { tags: [`project-${id}`] },
    });

    if (res.status === 404) return null;
    if (!res.ok) throw new Error('Failed to fetch project');

    const raw = await res.json();
    return ProjectSchema.parse(raw);
  },
});
// app/projects/[id]/page.tsx
import { createProjectRepository } from '@/lib/repositories/projectRepository';
import { notFound } from 'next/navigation';

type Props = {
  params: { id: string };
};

export default async function ProjectDetailPage({ params }: Props) {
  const repo = createProjectRepository();
  const project = await repo.findById(params.id);

  // nullの場合はNext.jsの404ページへ
  if (!project) notFound();

  // ここでは project が Project 型であることが型システムに保証されている
  return (
    <div>
      <h1>{project.name}</h1>
      <p>ステータス: {project.status}</p>
      <p>作成日: {new Date(project.createdAt).toLocaleDateString('ja-JP')}</p>
    </div>
  );
}

Zodを使う最大のメリットは、JSON.parse() 後の any 型地獄を根絶できることです。外部APIのレスポンスは実行時まで型が保証されないため、Zodによる境界での検証が型安全設計の要になります。

📚 おすすめ書籍

TypeScriptとReact/Next.jsでつくる実践Webアプリケーション開発

RSC・App Routerを実務レベルで体系的に学ぶなら

Amazonで見る →

エラーハンドリングの設計

RSCのエラーハンドリングは error.tsx で一元管理できます。

// app/projects/error.tsx
'use client'; // Error Boundaryはクライアントコンポーネント必須

import { useEffect } from 'react';

type Props = {
  error: Error & { digest?: string };
  reset: () => void;
};

export default function ProjectsError({ error, reset }: Props) {
  useEffect(() => {
    // エラー監視サービス(Sentry等)へ送信
    console.error(error);
  }, [error]);

  return (
    <div>
      <h2>データの取得に失敗しました</h2>
      <p>{error.message}</p>
      <button onClick={reset}>再試行</button>
    </div>
  );
}

Server Component内で throw new Error() すると、最も近い error.tsx がキャッチします。ページ全体を壊さずに部分的なエラー表示が可能です。


3パターンの選び方まとめ

個人開発の規模・フェーズで選ぶ

立ち上げ期(〜1,000 DAU)
  → パターン①:Page直接fetch
  → シンプルさ優先、速く動くものを作る

成長期(〜10,000 DAU)
  → パターン②:Componentごとfetch
  → UX改善のためストリーミング・並列化を導入

安定期・チーム化(10,000 DAU〜)
  → パターン③:Repository層 + Zod
  → テスト・保守性・型安全を本格整備

最初からパターン③を使う必要はありません。プロダクトのフェーズに合わせて段階的に移行するのが現実的な個人開発の戦略です。

📚 おすすめ書籍

良いコード/悪いコードで学ぶ設計入門

設計力を体系的に上げたいエンジニアに

Amazonで見る →

まとめ

  • RSCのデータフェッチは「誰がデータの責任を持つか」で設計する
  • パターン①(Page直接fetch)はシンプルで立ち上げ期に最適
  • パターン②(Component分散fetch + Suspense)は並列取得とUX向上に有効
  • パターン③(Repository + Zod)はスケールしても壊れない型安全設計の基盤
  • エラーハンドリングは error.tsx に集約し、Server Component側では潔く throw する

TypeScriptの型安全とRSCの設計をセットで学ぶことで、「動くけど怖くて触れないコード」から卒業できます。まずはパターン①から実際に手を動かして試してみてください。