GraphQL入門(Next.js,GraphQL,Apollo,Prisma)
はじめに
私は普段Web開発を行うにあたりAPIの構築にはRESTAPIしか使用したことがありませんでした。しかしGraphQLの存在は知っており、気になっていました。本記事はGraphQLについて私が学んだこと、またそれにあたって作成したTodoアプリについてまとめたものとなります。
GitHub
RESTとGraphQL
GraphQLとは
GraphQLとはFacebookが開発したAPIの設計手法です。クライアントが必要なデータやデータ構造を指定し、データを取得します。RESTは複数のエンドポイントを用意しデータを取得しますが、GraphQLは一つのエンドポイントから複数のリソースにアクセスすることができます。また、RESTでデータをフェッチする際必要以上にデータを取得しますが、GraphQLは必要なデータのみを取得できるのでオーバーフェッチを防ぐことができます。さらに、一度のリクエストで複数のデータを取得することができるのでリクエスト回数を減らすことができます。
オーバーフェッチを防ぐとは
以下のようなデータがあったとし、実際に欲しい情報は各ユーザーのidとnameだけだったとします。
{
"data": {
"users": [
{
"id": 0,
"name": "Taro",
"age":18,
"country":"Japan",
"job": "student"
},
{
"id": 1,
"name": "Tom",
"age":36,
"country":"America",
"job": "engineer"
}
]
}
}
RESTでは必要のないage、country、jobの情報まで取得してしまいますが、GraphQLの場合を見てみます。
以下のようなクエリを作成した場合、
{
users {
id
name
}
}
このように指定した情報だけを取得できます。
{
"data": {
"users": [
{
"id": 0,
"name": "Taro"
},
{
"id": 1,
"name": "Tom"
}
]
}
}
これがオーバーフェッチを防ぐということです。
比較
項目 | GraphQL | REST |
---|---|---|
設計思想 | クライアントが必要なデータを指定して取得 | サーバーが提供するリソースにアクセス |
エンドポイント | 1つ | リソースごとに複数のエンドポイントが必要 |
オーバーフェッチ | なし | あり |
リクエスト | 一度のリクエストで複数のデータを取得可能 | 各リソースごとに個別のリクエストが必要 |
学習コスト | 高 | 低 |
用語解説
GraphQLを使用するにあたり、知っておかなければならない用語を解説します。
スキーマと型定義
スキーマ
スキーマ(schema)とはデータの形状を定義するための設計図です。
スキーマの中には主に型定義が書かれています。
type Todo {
id: ID!
title: String!
completed: Boolean!
}
type Query {
getTodos: [Todo!]!
}
type Mutation {
addTodo(title: String!): Todo!
setCompleted(id: ID!, completed: Boolean!): Todo!
}
型定義
スキーマの中身を見ていきます。
type Todo {
id: ID!
title: String!
completed: Boolean!
}
上の例はTodo型を定義しており、Todo型はidというID型の値、titleというString型の値、completedというBoolean型の値を必ず持っていなければいけません。
type Query {
getTodos: [Todo!]!
}
上の例はクエリ型を定義しており、このプロジェクトで使用するクエリはgetTodosのみ。getTodosの戻り値はTodo型の値を必ず持つ配列で、必ず存在しなければいけません。
type Mutation {
addTodo(title: String!): Todo!
setCompleted(id: ID!, completed: Boolean!): Todo!
}
上の例はミューテーション型を定義しており、このプロジェクトで使用するミューテーションはaddTodoとsetCompletedの2種類ある。addTodoは引数にtitleというString型の値が必ず必要であり、戻り値はTodo型で、必ず存在しなければいけません。setCompletedは引数にidというID型の値とcompletedというBoolean形の値が必ず必要であり、戻り値はTodo型で、必ず存在しなければいけません。
クエリとミューテーション
Queryはデータ取得用のクエリ、Mutationはデータ更新用のクエリです。
SQL | REST | GraphQL | |
---|---|---|---|
データ取得 | GET | Select | Query |
データ追加 | POST | Create | Mutation |
データ更新 | PATCH | Update | Mutation |
データ削除 | DELETE | Delete | Mutation |
リゾルバ
スキーマはあくまでも定義であり実際にデータの操作を行うわけではありません。実際にデータ操作を行うものがリゾルバとなります。
const todos: Todo[] = [
{
id: "0",
title: "todo0",
completed: false,
}
];
const resolvers: Resolvers = {
Query: {
getTodos() {
return todos;
},
},
};
上の例はスキーマで定義したgetTodosというクエリの実行内容を示したリゾルバとなります。実行内容はtodosという定数を返すというものです。
Todoアプリ作成
今回使用したバージョンは以下となります。
"dependencies": {
"@apollo/client": "^3.13.8",
"@apollo/server": "^4.12.2",
"@as-integrations/next": "^3.2.0",
"@prisma/client": "^6.9.0",
"graphql": "^16.11.0",
"next": "15.3.3",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@graphql-codegen/cli": "^5.0.7",
"@graphql-codegen/client-preset": "^4.8.2",
"@graphql-codegen/typescript": "^4.1.6",
"@graphql-codegen/typescript-resolvers": "^4.5.1",
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "15.3.3",
"prisma": "^6.9.0",
"tailwindcss": "^4",
"typescript": "^5"
}
環境構築
プロジェクトを作成します。
npx create-next-app@latest
Apollo関連の依存関係をインストールします。
npm install graphql @apollo/client @apollo/server @as-integrations/next
GraphQL Code Generator関係の依存関係をインストールします。
npm install -D @graphql-codegen/cli @graphql-codegen/client-preset @graphql-codegen/typescript @graphql-codegen/typescript-resolvers
ハードコーディングしたTodoの一覧を画面に表示させる
まずはハードコーディングしたTodoを画面に出力することを目指します。
GraphQLのドキュメントを定義
スキーマを作成します。
type Query {
getTodos: [Todo!]!
}
type Todo {
id: ID!
title: String!
completed: Boolean!
}
codegenに関する操作
codegen.tsを作成します。
import { CodegenConfig } from "@graphql-codegen/cli";
const config: CodegenConfig = {
schema: "apollo/documents/**/*.gql",
documents: ["apollo/documents/**/*.gql"],
generates: {
"./apollo/__generated__/client/": {
preset: "client",
plugins: [],
presetConfig: {
gqlTagName: "gql",
},
},
"./apollo/__generated__/server/resolvers-types.ts": {
plugins: ["typescript", "typescript-resolvers"],
},
},
ignoreNoDocuments: true,
};
export default config;
型定義を生成するためのコマンドをpackage.jsonに追加します。
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
+ "compile": "graphql-codegen"
},
型定義を自動生成します。
npm run compile
apolloフォルダの中にフォルダが生成されます。
今回はプラグインに"typescript"と"typescript-resolvers"を指定しています。
"typescript"はスキーマからTypeScript型を生成するためのもので、クライアント側でTypeScriptを利用するときに自分で型定義をする必要がなくなります。
"typescript-resolvers"はスキーマをから、定義した型とフィールドを考慮し、リゾルバ関数が使用して返すデータを正確に記述するために必要な型を出力するもので、サーバー側でリゾルバを作成する際やクライアント側でリゾルバを使用する際に役立ちます。
schema.gqlに変更があるたびに自動生成する必要があります。
サーバーの作成
Next.jsのAPI Routesを利用してGraphQLサーバーを作成します。
import { readFileSync } from "fs";
import { join } from "path";
import { startServerAndCreateNextHandler } from "@as-integrations/next";
import { ApolloServer } from "@apollo/server";
import {
Resolvers,
Todo,
} from "../../../../apollo/__generated__/server/resolvers-types";
const typeDefs = readFileSync(
join(process.cwd(), "apollo/documents/schema.gql"),
"utf-8"
);
// 疑似データ
const todos: Todo[] = [
{
id: "0",
title: "todo0",
completed: false,
},
{
id: "1",
title: "todo1",
completed: false,
},
];
// 疑似データを返すリゾルバ
const resolvers: Resolvers = {
Query: {
getTodos() {
return todos;
},
},
};
const apolloServer = new ApolloServer<Resolvers>({ typeDefs, resolvers });
const handler = startServerAndCreateNextHandler(apolloServer);
export { handler as GET, handler as POST };
サーバーの作成に合わせて疑似データ、疑似データを返すためのリゾルバも作成しています。
開発画面から/api/graphqlにアクセスするとApollo Sandboxが起動していることが確認できます。
試しにApollo Sandboxを動かしてみるとgetTodosが正しく動作していることがわかります。
また、GraphQLらしく必要なデータだけ取得する様子も確認できます。
クライアントの作成
クライアント側で操作できるようにするためにclient.tsを作成します。
import { ApolloClient, InMemoryCache } from "@apollo/client";
export const client = new ApolloClient({
uri: "/api/graphql",
cache: new InMemoryCache(),
});
画面に表示させる
サーバーで取得した値を画面に表示させます。
schemaに追記します。
...
query GET_TODOS {
getTodos {
id
title
}
}
npm run compile
プロバイダーを作成します。
"use client";
import { ApolloProvider } from "@apollo/client";
import { client } from "../../../apollo/client";
const Provider = ({ children }: React.PropsWithChildren) => {
return <ApolloProvider client={client}>{children}</ApolloProvider>;
};
export default Provider;
表示させるコンテンツを作成します。
"use client";
import { useQuery } from "@apollo/client";
import { Get_TodosDocument } from "../../../apollo/__generated__/client/graphql";
const Todos = () => {
const { data, loading, error } = useQuery(Get_TodosDocument);
if (loading) return <div>loading...</div>;
return (
<div>
{error && <div>{error.message}</div>}
<ul>
{data &&
data.getTodos.map((todo) => <li key={todo.id}>{todo.title}</li>)}
</ul>
</div>
);
};
export default Todos;
表示させるコンテンツをプロバイダーでラップします。
import Provider from "./components/Provider";
import Todos from "./components/Todos";
export default function Home() {
return (
<Provider>
<Todos />
</Provider>
);
}
サーバーから取得したデータが画面に表示できていることが確認できます。
ハードコーディングしたデータに新しいTodoを追加する
ハードコーディングしたTodoに新しいTodoを追加することを目指します。
サーバー側
schemaに追記します。
...
type Mutation {
addTodo(title: String!): Todo!
}
npm run compile
リゾルバにmutationを追加します。
const resolvers: Resolvers = {
...
Mutation: {
addTodo: (parent, args) => {
const newId = String(todos.length);
const newTodo = {
id: newId,
title: args.title,
completed: false,
};
todos.push(newTodo);
return newTodo;
},
},
};
Apollo Sandboxを確認します。
追加されていることが確認できます。
画面にも表示されていることが確認できます。
クライアント側
追加するTodoのタイトルをinputに入力し、ボタンをクリックするとTodoが追加され、その際に再度フェッチさせることを目指します。
Todos.tsxを編集します。
"use client";
import { useMutation, useQuery } from "@apollo/client";
import { useState } from "react";
import {
Add_TodoDocument,
Get_TodosDocument,
} from "../../../apollo/__generated__/client/graphql";
const Todos = () => {
// Todo取得用クエリ
const { data, loading, error, refetch } = useQuery(Get_TodosDocument);
// Todo追加用ミューテーション
const [addTodo] = useMutation(Add_TodoDocument);
const [newTitle, setNewTitle] = useState<string>("");
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setNewTitle(e.target.value);
};
const handleClick = () => {
if (newTitle !== "") {
// newTitleを新しいtitleとするTodoを追加
addTodo({ variables: { title: newTitle } });
// inputに入力されている文字列をリセット
setNewTitle("");
// refetchさせる
refetch();
}
if (loading) return <div>loading...</div>;
return (
<div>
{error && <div>{error.message}</div>}
<div>
<input
type="text"
className="bg-gray-100 border-1 rounded-2xl mr-2 py-1 px-2"
placeholder="追加するTODO"
value={newTitle}
onChange={handleChange}
/>
<button onClick={handleClick}>追加する</button>
</div>
<ul>
{data &&
data.getTodos.map((todo) => <li key={todo.id}>{todo.title}</li>)}
</ul>
</div>
);
};
export default Todos;
画面を確認します。
正しく動作していることがわかります。
データの永続化
現在、元のデータはハードコーディングで用意している、また追加したTodoはローカルでしか保持されていないのでサーバーを落とすとデータは初期化されます。
データを永続化させるためにPrismaを利用してデータベースにデータを格納します。
Prismaのセットアップ
Prisma CLIをインストールします。
npm i prisma --save-dev
Prisma Clientをインストールします。
npm i @prisma/client
初期化します。
npx prisma init
作成されたスキーマを編集します。今回はSQLiteを使用します。
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = "file:.dev.db"
}
モデルを作成します。
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = "file:.dev.db"
}
model Post {
id Int @id @default(autoincrement())
title String
completed Boolean
}
マイグレートを行います。
npx prisma migrate dev
クライアントを生成します。
npx prisma generate
データベースからデータを持ってくる使用にするためapiを編集します。
import { readFileSync } from "fs";
import { join } from "path";
import { startServerAndCreateNextHandler } from "@as-integrations/next";
import { ApolloServer } from "@apollo/server";
import { Resolvers } from "../../../../apollo/__generated__/server/resolvers-types";
import { PrismaClient } from "@prisma/client";
import { NextRequest } from "next/server";
const typeDefs = readFileSync(
join(process.cwd(), "apollo/documents/schema.gql"),
"utf-8"
);
// PrismaClient生成
const prisma = new PrismaClient();
// DBのデータ操作に変更
const resolvers: Resolvers = {
Query: {
getTodos: async (parent, args, context) => {
return await context.prisma.post.findMany();
},
},
Mutation: {
addTodo: async(parent, args, context) => {
return await context.prisma.post.create({
data: {
title: args.title,
completed: false,
},
});
},
},
};
const apolloServer = new ApolloServer<Resolvers>({ typeDefs, resolvers });
const handler = startServerAndCreateNextHandler<NextRequest>(apolloServer, {
// contextにPrismaClientを登録してリゾルバとデータを共有
context: async() => ({
prisma,
}),
});
export { handler as GET, handler as POST };
画面を確認します。
期待通りに動いていることがわかります。
念のためPrisma Studioも確認しておきます。
npx prisma studio
DBにデータが格納されていることがわかります。
機能追加
完了したTodoがわかるようにTodoごとにチェックボックスを設けます。チェックのつけ外しに応じてTodoのcompletedを変更、それに応じてcssがわかるような機能を作成します。
...
type Mutation {
addTodo(title: String!): Todo!
+ setCompleted(id: ID!, completed: Boolean!): Todo!
}
...
mutation SET_COMPLETED($id: ID!, $completed: Boolean!) {
setCompleted(id: $id, completed: $completed) {
id
title
completed
}
}
Mutation: {
...
setCompleted: async(parent, args, context) => {
return await context.prisma.post.update({
where: {
id: Number(args.id),
},
data: {
completed: !args.completed,
},
});
},
},
"use client";
import { useMutation, useQuery } from "@apollo/client";
import { useState } from "react";
import {
Add_TodoDocument,
Get_TodosDocument,
Set_CompletedDocument,
} from "../../../apollo/__generated__/client/graphql";
type HandleCheckArgsType = {
id: string;
completed: boolean;
};
const Todos = () => {
// Todo取得用クエリ
const { data, loading, error, refetch } = useQuery(Get_TodosDocument);
// Todo追加用ミューテーション
const [addTodo] = useMutation(Add_TodoDocument);
const [newTitle, setNewTitle] = useState<string>("");
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setNewTitle(e.target.value);
};
const handleClick = async() => {
if (newTitle !== "") {
// newTitleを新しいtitleとするTodoを追加
await addTodo({ variables: { title: newTitle } });
// inputに入力されている文字列をリセット
setNewTitle("");
// refetchして表示させる
refetch();
}
};
// completedの値を変えるミューテーション
const [setCompleted] = useMutation(Set_CompletedDocument);
const handleCheck = async({ id, completed }: HandleCheckArgsType) => {
await setCompleted({ variables: { id, completed } });
refetch();
};
return (
<div className="h-screen w-screen flex justify-center items-center">
<div className="h-fit bg-amber-100 p-10 rounded-2xl">
<h1 className="text-center font-bold text-3xl mb-10">Todoリスト</h1>
{loading && <div className="text-center">loading...</div>}
{error && <div className="text-center">{error.message}</div>}
{data && (
<div>
<div className="mb-10">
<input
type="text"
className="bg-gray-100 border-1 rounded-xl py-1 px-2 w-100"
placeholder="追加するTODO"
value={newTitle}
onChange={handleChange}
/>
<button
onClick={handleClick}
className="mx-5 cursor-pointer bg-gray-100 px-3 py-1 border-1 rounded-2xl border-slate-400"
>
追加する
</button>
</div>
<div className="text-xl">
{data.getTodos.map((todo) => (
<div key={todo.id} className="flex items-center mb-5">
<input
type="checkbox"
checked={todo.completed}
onChange={() =>
handleCheck({ id: todo.id, completed: todo.completed })
}
className="mr-5"
/>
<div className="flex">
{/* completedがtrueの時用のcss */}
<p className={`${todo.completed && "line-through"}`}>
{todo.title}
</p>
</div>
</div>
))}
</div>
</div>
)}
</div>
</div>
);
};
export default Todos;
画面を確認します。
期待通りに動いていることがわかります。
参考文献
まとめ
今回初めてGraphQLを使用しました。まだ慣れていないこともありRESTの手軽さがが魅力的に思えてしまうのですが、大きいデータを扱うようなプロジェクトの場合はGraphQLの利点がもっと実感できるのかなと思います。
どちらが優れているというよりは両方とも扱えるようになり、プロジェクトによって使い分けができるようになるべきなのかなと感じました。
そしてなにより大変だったのがApolloServerの現行はバージョン4ですが、これとNext.jsの組み合わせに関する記事が少なく、環境構築に一番時間がかかってしまいました。
Discussion