はじめに
Next.js App RouterとGraphQLを組み合わせることで、効率的なデータフェッチとキャッシュ制御が実現できます。Server ComponentsとClient Componentsそれぞれに適したアプローチを解説します。
GraphQLクライアントの選択
| クライアント | 用途 | 特徴 |
|---|
| graphql-request | Server Components | 軽量、シンプル |
| urql | Client 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 Components | graphql-request | 軽量、シンプル |
| Client Components | urql | 軽量、SSR対応 |
| 大規模アプリ | Apollo Client | 高機能キャッシュ |
参考文献