🙃

GraphQL入門(Next.js,GraphQL,Apollo,Prisma)

に公開

はじめに

私は普段Web開発を行うにあたりAPIの構築にはRESTAPIしか使用したことがありませんでした。しかしGraphQLの存在は知っており、気になっていました。本記事はGraphQLについて私が学んだこと、またそれにあたって作成したTodoアプリについてまとめたものとなります。

GitHub

https://github.com/kiyo-8jo/zenn-graphql-apollo

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)とはデータの形状を定義するための設計図です。
スキーマの中には主に型定義が書かれています。

schema.gql
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アプリ作成

今回使用したバージョンは以下となります。

package.json
  "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のドキュメントを定義

スキーマを作成します。

apollo/documents/schema.gql
type Query {
  getTodos: [Todo!]!
}

type Todo {
  id: ID!
  title: String!
  completed: Boolean!
}

codegenに関する操作

codegen.tsを作成します。

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に追加します。

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サーバーを作成します。

src/app/api/graphql/route.ts
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を作成します。

apollo/client.ts
import { ApolloClient, InMemoryCache } from "@apollo/client";

export const client = new ApolloClient({
  uri: "/api/graphql",
  cache: new InMemoryCache(),
});

画面に表示させる

サーバーで取得した値を画面に表示させます。
schemaに追記します。

apollo/documents/schema.gql
...
query GET_TODOS {
  getTodos {
    id
    title
  }
}
npm run compile

プロバイダーを作成します。

src/app/components/Provider.tsx
"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;

表示させるコンテンツを作成します。

src/app/components/Todos.tsx
"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;

表示させるコンテンツをプロバイダーでラップします。

src/app/page.tsx
import Provider from "./components/Provider";
import Todos from "./components/Todos";

export default function Home() {
  return (
    <Provider>
      <Todos />
    </Provider>
  );
}

サーバーから取得したデータが画面に表示できていることが確認できます。

ハードコーディングしたデータに新しいTodoを追加する

ハードコーディングしたTodoに新しいTodoを追加することを目指します。

サーバー側

schemaに追記します。

apollo/documents/schema.gql
...
type Mutation {
  addTodo(title: String!): Todo!
}
npm run compile

リゾルバにmutationを追加します。

route.ts
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を編集します。

src/app/components/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を使用します。

prisma/schema.prisma
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "sqlite"
  url      = "file:.dev.db"
}

モデルを作成します。

prisma/schema.prisma
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を編集します。

src/app/api/graphql/route.ts
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がわかるような機能を作成します。

schema.gql
...
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
  }
}
src/app/api/graphql/route.ts
Mutation: {
   ...
    setCompleted: async(parent, args, context) => {
      return await context.prisma.post.update({
        where: {
          id: Number(args.id),
        },
        data: {
          completed: !args.completed,
        },
      });
    },
  },
Todos.tsx
"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;

画面を確認します。


期待通りに動いていることがわかります。

参考文献

https://graphql.org/
https://www.apollographql.com/docs
https://zenn.dev/yuta4j1/articles/nextjs-apollo-starter
https://qiita.com/curry__30/items/5b3978f85ae20b0a0716

まとめ

今回初めてGraphQLを使用しました。まだ慣れていないこともありRESTの手軽さがが魅力的に思えてしまうのですが、大きいデータを扱うようなプロジェクトの場合はGraphQLの利点がもっと実感できるのかなと思います。
どちらが優れているというよりは両方とも扱えるようになり、プロジェクトによって使い分けができるようになるべきなのかなと感じました。
そしてなにより大変だったのがApolloServerの現行はバージョン4ですが、これとNext.jsの組み合わせに関する記事が少なく、環境構築に一番時間がかかってしまいました。

Discussion