Documentation Next.js

はじめに

Next.js App RouterとGraphQLを組み合わせることで、効率的なデータフェッチとキャッシュ制御が実現できます。Server ComponentsとClient Componentsそれぞれに適したアプローチを解説します。

GraphQLクライアントの選択

クライアント用途特徴
graphql-requestServer Components軽量、シンプル
urqlClient Components軽量、正規化キャッシュ
Apollo Client大規模アプリ高機能、複雑

パッケージのインストール

# 基本パッケージ
npm install graphql

# Server Components用
npm install graphql-request

# Client Components用
npm install urql @urql/next

# 型生成
npm install -D @graphql-codegen/cli @graphql-codegen/typescript @graphql-codegen/typescript-operations @graphql-codegen/typescript-urql

Server ComponentsでのGraphQL

graphql-requestを使用

// lib/graphql-client.ts
import { GraphQLClient } from 'graphql-request';

const endpoint = process.env.GRAPHQL_ENDPOINT!;

export const graphqlClient = new GraphQLClient(endpoint, {
  headers: {
    Authorization: `Bearer ${process.env.GRAPHQL_API_KEY}`,
  },
});

クエリ定義

// lib/queries/posts.ts
import { gql } from 'graphql-request';

export const GET_POSTS = gql`
  query GetPosts($first: Int!, $after: String) {
    posts(first: $first, after: $after) {
      edges {
        node {
          id
          title
          slug
          excerpt
          publishedAt
          author {
            name
            avatar
          }
        }
        cursor
      }
      pageInfo {
        hasNextPage
        endCursor
      }
    }
  }
`;

export const GET_POST_BY_SLUG = gql`
  query GetPostBySlug($slug: String!) {
    post(slug: $slug) {
      id
      title
      content
      publishedAt
      author {
        name
        avatar
        bio
      }
      tags {
        id
        name
      }
    }
  }
`;

Server Componentでデータ取得

// app/blog/page.tsx
import { graphqlClient } from '@/lib/graphql-client';
import { GET_POSTS } from '@/lib/queries/posts';
import { PostCard } from '@/components/PostCard';

interface PostsResponse {
  posts: {
    edges: {
      node: {
        id: string;
        title: string;
        slug: string;
        excerpt: string;
        publishedAt: string;
        author: {
          name: string;
          avatar: string;
        };
      };
      cursor: string;
    }[];
    pageInfo: {
      hasNextPage: boolean;
      endCursor: string;
    };
  };
}

async function getPosts() {
  const data = await graphqlClient.request<PostsResponse>(GET_POSTS, {
    first: 10,
  });
  return data.posts;
}

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

  return (
    <div className="container mx-auto py-8">
      <h1 className="text-3xl font-bold mb-8">ブログ</h1>
      <div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
        {posts.edges.map(({ node }) => (
          <PostCard key={node.id} post={node} />
        ))}
      </div>
    </div>
  );
}

キャッシュとRevalidation

// lib/graphql-client.ts
import { GraphQLClient } from 'graphql-request';

const endpoint = process.env.GRAPHQL_ENDPOINT!;

export function createGraphQLClient(options?: RequestInit) {
  return new GraphQLClient(endpoint, {
    headers: {
      Authorization: `Bearer ${process.env.GRAPHQL_API_KEY}`,
    },
    fetch: (url, init) =>
      fetch(url, {
        ...init,
        ...options,
      }),
  });
}

// 使用例
export async function getPostsWithCache() {
  const client = createGraphQLClient({
    next: { revalidate: 3600 }, // 1時間キャッシュ
  });
  return client.request(GET_POSTS, { first: 10 });
}

export async function getPostNoCache(slug: string) {
  const client = createGraphQLClient({
    cache: 'no-store',
  });
  return client.request(GET_POST_BY_SLUG, { slug });
}

Client ComponentsでのGraphQL(urql)

Providerセットアップ

// components/UrqlProvider.tsx
'use client';

import { useMemo } from 'react';
import { UrqlProvider as Provider, ssrExchange, cacheExchange, fetchExchange, createClient } from '@urql/next';

interface Props {
  children: React.ReactNode;
}

export function UrqlProvider({ children }: Props) {
  const [client, ssr] = useMemo(() => {
    const ssr = ssrExchange({
      isClient: typeof window !== 'undefined',
    });

    const client = createClient({
      url: process.env.NEXT_PUBLIC_GRAPHQL_ENDPOINT!,
      exchanges: [cacheExchange, ssr, fetchExchange],
      suspense: true,
    });

    return [client, ssr];
  }, []);

  return (
    <Provider client={client} ssr={ssr}>
      {children}
    </Provider>
  );
}

レイアウトに追加

// app/layout.tsx
import { UrqlProvider } from '@/components/UrqlProvider';

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="ja">
      <body>
        <UrqlProvider>{children}</UrqlProvider>
      </body>
    </html>
  );
}

Client Componentでクエリ

// components/UserProfile.tsx
'use client';

import { useQuery, gql } from '@urql/next';
import { Suspense } from 'react';

const GET_USER = gql`
  query GetUser($id: ID!) {
    user(id: $id) {
      id
      name
      email
      avatar
      createdAt
    }
  }
`;

interface UserData {
  user: {
    id: string;
    name: string;
    email: string;
    avatar: string;
    createdAt: string;
  };
}

function UserProfileContent({ userId }: { userId: string }) {
  const [result] = useQuery<UserData>({
    query: GET_USER,
    variables: { id: userId },
  });

  const { data, fetching, error } = result;

  if (fetching) return <div>読み込み中...</div>;
  if (error) return <div>エラー: {error.message}</div>;
  if (!data?.user) return <div>ユーザーが見つかりません</div>;

  return (
    <div className="p-6 bg-white rounded-lg shadow">
      <img
        src={data.user.avatar}
        alt={data.user.name}
        className="w-20 h-20 rounded-full"
      />
      <h2 className="mt-4 text-xl font-bold">{data.user.name}</h2>
      <p className="text-gray-600">{data.user.email}</p>
    </div>
  );
}

export function UserProfile({ userId }: { userId: string }) {
  return (
    <Suspense fallback={<div>読み込み中...</div>}>
      <UserProfileContent userId={userId} />
    </Suspense>
  );
}

Mutation

// components/CreatePostForm.tsx
'use client';

import { useMutation, gql } from '@urql/next';
import { useRouter } from 'next/navigation';

const CREATE_POST = gql`
  mutation CreatePost($input: CreatePostInput!) {
    createPost(input: $input) {
      id
      title
      slug
    }
  }
`;

interface CreatePostInput {
  title: string;
  content: string;
  tags: string[];
}

export function CreatePostForm() {
  const router = useRouter();
  const [result, executeMutation] = useMutation(CREATE_POST);

  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    const formData = new FormData(e.currentTarget);

    const input: CreatePostInput = {
      title: formData.get('title') as string,
      content: formData.get('content') as string,
      tags: (formData.get('tags') as string).split(',').map((t) => t.trim()),
    };

    const { data, error } = await executeMutation({ input });

    if (data?.createPost) {
      router.push(`/blog/${data.createPost.slug}`);
    }
  };

  return (
    <form onSubmit={handleSubmit} className="space-y-4">
      <div>
        <label htmlFor="title" className="block text-sm font-medium">
          タイトル
        </label>
        <input
          type="text"
          id="title"
          name="title"
          required
          className="mt-1 block w-full rounded-md border-gray-300"
        />
      </div>

      <div>
        <label htmlFor="content" className="block text-sm font-medium">
          本文
        </label>
        <textarea
          id="content"
          name="content"
          rows={10}
          required
          className="mt-1 block w-full rounded-md border-gray-300"
        />
      </div>

      <div>
        <label htmlFor="tags" className="block text-sm font-medium">
          タグ(カンマ区切り)
        </label>
        <input
          type="text"
          id="tags"
          name="tags"
          placeholder="Next.js, React, TypeScript"
          className="mt-1 block w-full rounded-md border-gray-300"
        />
      </div>

      <button
        type="submit"
        disabled={result.fetching}
        className="px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
      >
        {result.fetching ? '投稿中...' : '投稿する'}
      </button>

      {result.error && (
        <p className="text-red-600">{result.error.message}</p>
      )}
    </form>
  );
}

GraphQL Code Generator

codegen.ts

// codegen.ts
import type { CodegenConfig } from '@graphql-codegen/cli';

const config: CodegenConfig = {
  schema: process.env.GRAPHQL_ENDPOINT,
  documents: ['lib/**/*.ts', 'components/**/*.tsx', 'app/**/*.tsx'],
  generates: {
    './lib/generated/graphql.ts': {
      plugins: [
        'typescript',
        'typescript-operations',
        'typescript-urql',
      ],
      config: {
        withHooks: true,
        withComponent: false,
        urqlImportFrom: '@urql/next',
      },
    },
  },
  ignoreNoDocuments: true,
};

export default config;

実行スクリプト

{
  "scripts": {
    "codegen": "graphql-codegen",
    "codegen:watch": "graphql-codegen --watch"
  }
}

生成された型の使用

// 生成されたフックを使用
import { useGetUserQuery, useCreatePostMutation } from '@/lib/generated/graphql';

function UserProfile({ userId }: { userId: string }) {
  const [{ data, fetching, error }] = useGetUserQuery({
    variables: { id: userId },
  });

  // 型安全にアクセス
  if (data?.user) {
    return <div>{data.user.name}</div>;
  }
}

Server ActionsでMutation

// actions/posts.ts
'use server';

import { graphqlClient } from '@/lib/graphql-client';
import { revalidatePath } from 'next/cache';
import { gql } from 'graphql-request';

const CREATE_POST = gql`
  mutation CreatePost($input: CreatePostInput!) {
    createPost(input: $input) {
      id
      slug
    }
  }
`;

export async function createPost(formData: FormData) {
  const input = {
    title: formData.get('title') as string,
    content: formData.get('content') as string,
    tags: (formData.get('tags') as string).split(',').map((t) => t.trim()),
  };

  try {
    const result = await graphqlClient.request(CREATE_POST, { input });
    revalidatePath('/blog');
    return { success: true, slug: result.createPost.slug };
  } catch (error) {
    return { success: false, error: 'Failed to create post' };
  }
}

エラーハンドリング

// lib/graphql-client.ts
import { GraphQLClient, ClientError } from 'graphql-request';

export async function safeRequest<T>(
  query: string,
  variables?: Record<string, unknown>
): Promise<{ data: T | null; error: string | null }> {
  try {
    const data = await graphqlClient.request<T>(query, variables);
    return { data, error: null };
  } catch (error) {
    if (error instanceof ClientError) {
      const message = error.response.errors?.[0]?.message || 'GraphQL Error';
      console.error('GraphQL Error:', error.response.errors);
      return { data: null, error: message };
    }
    return { data: null, error: 'Unknown error occurred' };
  }
}

まとめ

用途推奨クライアント理由
Server Componentsgraphql-request軽量、シンプル
Client Componentsurql軽量、SSR対応
大規模アプリApollo Client高機能キャッシュ

参考文献

円