mcpField
N
nextjs-data-fetching

nextjs-data-fetching

github.com/josiahsiegel開発5976 回インストール20 週間前に更新
リポジトリ

nextjs-data-fetching について

Next.js App Routerで開発する人向けのデータ取得リファレンススキルです。サーバー・クライアントでの取得、キャッシュ制御、ストリーミング、Route Handlerを扱います。

主な機能

  1. 01サーバーとクライアントでのデータ取得
  2. 02並列・逐次リクエスト
  3. 03キャッシュと再検証の設定
  4. 04Suspenseによるストリーミング
  5. 05Route Handlerの例

活用シーン

  1. 01Server Componentsでのデータ読み込み
  2. 02独立したデータの並列取得
  3. 03ダッシュボード各部の段階的な表示
  4. 04Route HandlerによるAPIエンドポイント作成

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/josiahsiegel/claude-plugin-marketplace --skill nextjs-data-fetching

インストール後、Claude Code で/nextjs-data-fetchingを入力して読み込まれたか確認します。

詳細

クイックリファレンス

パターンコード目的
サーバーでの取得await fetch(url)デフォルトでキャッシュされます
キャッシュなし{ cache: 'no-store' }常に最新のデータを取得します
再検証{ next: { revalidate: 60 } }時間ベースで更新します
タグ{ next: { tags: ['posts'] } }タグベースで無効化します
設定値効果
dynamic'force-dynamic'常に SSR を実行します
revalidate6060 秒ごとに ISR を実行します
fetchCache'force-no-store'キャッシュしません
クライアントライブラリフック用途
SWRuseSWR(key, fetcher)シンプルなクライアント側のデータ取得
TanStack QueryuseQuery({ queryKey, queryFn })複雑な状態管理やミューテーション

このスキルを使用する場面

データ読み込みパターンには、次の用途で使用します。

  • Server Components でのデータ取得
  • Promise.all を使った並列データ取得の設定
  • Suspense 境界を使ったストリーミングの実装
  • Route Handlers を使った API ルートの作成
  • クライアント側でのデータ取得とミューテーション

関連スキル:

  • キャッシュ戦略については、nextjs-caching を参照してください
  • Server Actions(ミューテーション)については、nextjs-server-actions を参照してください
  • App Router の基本については、nextjs-app-router を参照してください

Next.js のデータ取得

Server Components でのデータ取得

基本的なデータ取得
// app/posts/page.tsx
async function getPosts() {
  const res = await fetch('https://api.example.com/posts');
  if (!res.ok) throw new Error('Failed to fetch posts');
  return res.json();
}

export default async function PostsPage() {
  const posts = await getPosts();

  return (
    <ul>
      {posts.map((post: Post) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  );
}
データベースへの直接アクセス
// app/users/page.tsx
import { db } from '@/lib/db';

export default async function UsersPage() {
  // Direct database query - no API needed
  const users = await db.users.findMany({
    orderBy: { createdAt: 'desc' },
    take: 10,
  });

  return (
    <ul>
      {users.map((user) => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}
並列データ取得
// app/dashboard/page.tsx
async function getUser() {
  const res = await fetch('https://api.example.com/user');
  return res.json();
}

async function getPosts() {
  const res = await fetch('https://api.example.com/posts');
  return res.json();
}

async function getAnalytics() {
  const res = await fetch('https://api.example.com/analytics');
  return res.json();
}

export default async function DashboardPage() {
  // Fetch all data in parallel
  const [user, posts, analytics] = await Promise.all([
    getUser(),
    getPosts(),
    getAnalytics(),
  ]);

  return (
    <div>
      <UserCard user={user} />
      <PostsList posts={posts} />
      <AnalyticsChart data={analytics} />
    </div>
  );
}
順次データ取得
// When data depends on previous request
async function getUser(userId: string) {
  const res = await fetch(`https://api.example.com/users/${userId}`);
  return res.json();
}

async function getUserPosts(userId: string) {
  const res = await fetch(`https://api.example.com/users/${userId}/posts`);
  return res.json();
}

export default async function UserPage({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;

  // Sequential: user first, then posts
  const user = await getUser(id);
  const posts = await getUserPosts(user.id);

  return (
    <div>
      <h1>{user.name}</h1>
      <PostsList posts={posts} />
    </div>
  );
}

キャッシュと再検証

キャッシュの動作
// Cached by default (equivalent to cache: 'force-cache')
const data = await fetch('https://api.example.com/data');

// Opt out of caching
const data = await fetch('https://api.example.com/data', {
  cache: 'no-store',
});

// Time-based revalidation
const data = await fetch('https://api.example.com/data', {
  next: { revalidate: 3600 }, // Revalidate every hour
});

// Tag-based revalidation
const data = await fetch('https://api.example.com/posts', {
  next: { tags: ['posts'] },
});
ルートセグメントの設定
// app/posts/page.tsx
export const dynamic = 'force-dynamic'; // Always dynamic
// or
export const revalidate = 60; // Revalidate every 60 seconds
// or
export const fetchCache = 'force-no-store'; // Don't cache any fetches
データの再検証
// app/actions.ts
'use server';

import { revalidatePath, revalidateTag } from 'next/cache';

export async function createPost(formData: FormData) {
  await db.posts.create({
    data: { title: formData.get('title') as string },
  });

  // Revalidate specific path
  revalidatePath('/posts');

  // Or revalidate by tag
  revalidateTag('posts');
}

Suspense を使ったストリーミング

コンポーネント単位のストリーミング
// app/dashboard/page.tsx
import { Suspense } from 'react';

async function SlowComponent() {
  const data = await fetch('https://api.example.com/slow-data');
  return <div>{/* render data */}</div>;
}

async function FastComponent() {
  const data = await fetch('https://api.example.com/fast-data');
  return <div>{/* render data */}</div>;
}

export default function DashboardPage() {
  return (
    <div>
      {/* Fast component renders immediately */}
      <Suspense fallback={<FastSkeleton />}>
        <FastComponent />
      </Suspense>

      {/* Slow component streams in when ready */}
      <Suspense fallback={<SlowSkeleton />}>
        <SlowComponent />
      </Suspense>
    </div>
  );
}
ネストした Suspense 境界
// app/page.tsx
import { Suspense } from 'react';

export default function Page() {
  return (
    <Suspense fallback={<PageSkeleton />}>
      <Header />
      <Suspense fallback={<SidebarSkeleton />}>
        <Sidebar />
      </Suspense>
      <Suspense fallback={<ContentSkeleton />}>
        <MainContent />
        <Suspense fallback={<CommentsSkeleton />}>
          <Comments />
        </Suspense>
      </Suspense>
    </Suspense>
  );
}

クライアント側でのデータ取得

SWR
'use client';

import useSWR from 'swr';

const fetcher = (url: string) => fetch(url).then((res) => res.json());

export function UserProfile({ userId }: { userId: string }) {
  const { data, error, isLoading, mutate } = useSWR(
    `/api/users/${userId}`,
    fetcher
  );

  if (isLoading) return <div>Loading...</div>;
  if (error) return <div>Error loading user</div>;

  return (
    <div>
      <h1>{data.name}</h1>
      <button onClick={() => mutate()}>Refresh</button>
    </div>
  );
}
TanStack Query
// providers/query-provider.tsx
'use client';

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useState } from 'react';

export function QueryProvider({ children }: { children: React.ReactNode }) {
  const [queryClient] = useState(
    () =>
      new QueryClient({
        defaultOptions: {
          queries: {
            staleTime: 60 * 1000,
          },
        },
      })
  );

  return (
    <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
  );
}
// components/Posts.tsx
'use client';

import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';

async function fetchPosts() {
  const res = await fetch('/api/posts');
  return res.json();
}

async function createPost(data: { title: string }) {
  const res = await fetch('/api/posts', {
    method: 'POST',
    body: JSON.stringify(data),
  });
  return res.json();
}

export function Posts() {
  const queryClient = useQueryClient();

  const { data: posts, isLoading } = useQuery({
    queryKey: ['posts'],
    queryFn: fetchPosts,
  });

  const mutation = useMutation({
    mutationFn: createPost,
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['posts'] });
    },
  });

  if (isLoading) return <div>Loading...</div>;

  return (
    <div>
      <ul>
        {posts?.map((post: Post) => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>
      <button onClick={() => mutation.mutate({ title: 'New Post' })}>
        Add Post
      </button>
    </div>
  );
}

Route Handlers(API ルート)

基本的な Route Handler
// app/api/posts/route.ts
import { NextResponse } from 'next/server';

export async function GET() {
  const posts = await db.posts.findMany();
  return NextResponse.json(posts);
}

export async function POST(request: Request) {
  const body = await request.json();
  const post = await db.posts.create({ data: body });
  return NextResponse.json(post, { status: 201 });
}
動的な Route Handler
// app/api/posts/[id]/route.ts
import { NextResponse } from 'next/server';

interface RouteParams {
  params: Promise<{ id: string }>;
}

export async function GET(request: Request, { params }: RouteParams) {
  const { id } = await params;
  const post = await db.posts.findUnique({ where: { id } });

  if (!post) {
    return NextResponse.json({ error: 'Not found' }, { status: 404 });
  }

  return NextResponse.json(post);
}

export async function PUT(request: Request, { params }: RouteParams) {
  const { id } = await params;
  const body = await request.json();
  const post = await db.posts.update({
    where: { id },
    data: body,
  });
  return NextResponse.json(post);
}

export async function DELETE(request: Request, { params }: RouteParams) {
  const { id } = await params;
  await db.posts.delete({ where: { id } });
  return new NextResponse(null, { status: 204 });
}
リクエストヘルパー
// app/api/search/route.ts
import { NextResponse } from 'next/server';

export async function GET(request: Request) {
  const { searchParams } = new URL(request.url);
  const query = searchParams.get('q');
  const page = parseInt(searchParams.get('page') || '1');

  const results = await search(query, page);

  return NextResponse.json(results);
}
ヘッダーと Cookie
// app/api/auth/route.ts
import { NextResponse } from 'next/server';
import { cookies, headers } from 'next/headers';

export async function GET() {
  const cookieStore = await cookies();
  const token = cookieStore.get('token');

  const headersList = await headers();
  const authorization = headersList.get('authorization');

  // Set cookie in response
  const response = NextResponse.json({ success: true });
  response.cookies.set('session', 'value', {
    httpOnly: true,
    secure: true,
    sameSite: 'strict',
    maxAge: 60 * 60 * 24, // 1 day
  });

  return response;
}

静的生成

generateStaticParams
// app/posts/[slug]/page.tsx
export async function generateStaticParams() {
  const posts = await db.posts.findMany({ select: { slug: true } });

  return posts.map((post) => ({
    slug: post.slug,
  }));
}

export default async function PostPage({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const post = await db.posts.findUnique({ where: { slug } });
  return <article>{post?.content}</article>;
}
動的な params の動作
// Allow dynamic paths beyond generateStaticParams
export const dynamicParams = true; // default

// 404 for paths not in generateStaticParams
export const dynamicParams = false;

エラー処理

fetch のエラー処理
async function getData() {
  const res = await fetch('https://api.example.com/data');

  if (!res.ok) {
    throw new Error('Failed to fetch data');
  }

  return res.json();
}

export default async function Page() {
  try {
    const data = await getData();
    return <div>{/* render data */}</div>;
  } catch (error) {
    return <div>Error loading data</div>;
  }
}
error.tsx の使用
// app/posts/error.tsx
'use client';

export default function Error({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  return (
    <div>
      <h2>Error loading posts</h2>
      <p>{error.message}</p>
      <button onClick={() => reset()}>Try again</button>
    </div>
  );
}

ベストプラクティス

プラクティス説明
Server Components で取得する可能な場合はクライアント側での取得を避けます
並列取得を使用する独立したデータには Promise.all を使用します
ストリーミングを実装する段階的な読み込みには Suspense を使用します
適切にキャッシュする最新のデータを取得するには revalidate または tags を使用します
エラーを処理するerror.tsx 境界を使用します
データに型を付けるAPI レスポンスには TypeScript を使用します

コメント