空雲 Blog

Eye catchNext.jsによるGraphQLに入門させないGraphQL入門

publication: 2023/10/10
update:2024/02/20

ソースコードと実働環境

最終的に出来ること

Prisma スキーマを設定するだけで、GraphQL スキーマやリゾルバを自動生成し、対応する GraphQL クエリも自動的に作って、React コンポーネントから使える Hooks を自動生成し、普通にコンポーネントを作るときちんと SSR で動作し、JavaScript を切ったとしてもページジェネレーションぐらいまでは表示できるようになります。

今回使うもの一覧

種類Package
DBPostgreSQL
ORMPrisma
GraphQL ServerGraphQL Yoga
GraphQL FrameworkPothos GraphQL
GraphQL ClientUrql

GraphQL Server は Apollo と Yoga の二択で、入門用により簡単に使える Yoga を今回は選択しています。
GraphQL Framework は Nexus と Pothos の二択で、Prisma との連携をカスタマイズする上で便利な Pothos を選びました。
GraphQL Client は ApolloClient と Urql の二択で、Mutation 時のキャッシュ管理が楽な Urql を利用しています。

Next.js の利用方法

種類内容
App RouterGraphQL Yoga
Pages Router各 Components

App Router を GraphQL のバックエンド専用、Pages Router をフロント用に利用しています。コンポーネントは App Router 側には置きませんが、コンポーネント上でデータ取得用の hooks を使うだけで完全な SSR を行います。データ取得専用の命令をコンポーネント外に書く必要はありません。

GraphQL でアプリケーションを作るまでの道のり

Next.js で GraphQL を使ったアプリケーションを作ろうと思ったとき、少々長い道のりを経ることになります。例えば、バックエンドに PostgreSQL を用いて ORM に Prisma を選択した場合、フロントの実装に至るまで以下のようになります。

  • 1.PostgreSQL の環境構築(Docker もしくは外部サービス)

  • 2.Prisma スキーマの作成

  • 3.Prisma の DB へのマイグレーション

  • 4.Prisma のバックエンド API 用ジェネレーション

  • 5.GraphQL 用 API サーバの準備

  • 6.GraphQL スキーマの作成とリゾルバの実装

  • 7.GraphQL クエリの作成

  • 8.GraphQL クエリを graphql-codegen などで、フロントで実装しやすい形に変換

ということで、やることはそれなりにあります。順番にやっていきましょう。

自動変換までの流れ

PostgreSQL の環境構築

Docker の例です。

docker\docker-compose.yml

1version: "3.7"
2services:
3 postgres:
4 container_name: next-pothos-postgres
5 image: postgres:alpine
6 environment:
7 POSTGRES_DB: postgres
8 POSTGRES_USER: postgres
9 POSTGRES_PASSWORD: password
10 volumes:
11 - next-graphql-vol:/var/lib/postgresql/data
12 ports:
13 - "5432:5432"
14volumes:
15 next-graphql-vol:

package.json の scripts に以下のコマンドを追加しておくと便利です。-p のプロジェクト名設定は、アプリケーションに合わせた任意のものにしてください。設定しなくても問題ありませんが、Docker を並行して利用している場合は区別がつきやすくなります。

1{
2 "scripts": {
3 "dev:docker": "docker compose -p next-graphql -f docker/docker-compose.yml up -d"
4 }
5}

.env に DB への接続情報を設定しておきます。外部サービスを使う場合は、Docker の設定を行わず、接続情報だけ設定します。Dockerインスタンスで複数のschemaに切り替えたいときはschemaの名前を変更してください。

また、外部サービスでSupabaseなどを使用する場合も、schemaを切り替えると複数のアプリケーションに対応できます。

1DATABASE_URL="postgresql://postgres:password@localhost:5432/postgres?schema=test"

これで

  • 1.PostgreSQL の環境構築(Docker もしくは外部サービス)

が完了です。

Prisma スキーマの作成

タイトルとコンテンツ情報を持ったテーブルを作成します。リレーションの実験が出来るように複数のカテゴリを関連付けられるようにします。

prisma/schema.prisma

1// This is your Prisma schema file,
2// learn more about it in the docs: https://pris.ly/d/prisma-schema
3
4generator client {
5 provider = "prisma-client-js"
6}
7
8datasource db {
9 provider = "postgresql"
10 url = env("DATABASE_URL")
11}
12
13
14model Post {
15 id String @id @default(uuid())
16 published Boolean @default(false)
17 title String @default("New Post")
18 content String @default("")
19 categories Category[]
20 createdAt DateTime @default(now())
21 updatedAt DateTime @updatedAt
22 publishedAt DateTime @default(now())
23}
24
25model Category {
26 id String @id @default(uuid())
27 name String
28 posts Post[]
29 createdAt DateTime @default(now())
30 updatedAt DateTime @updatedAt
31}

package.json の scripts に以下のコマンドを突っ込んでおくと便利です。

1{
2 "scripts": {
3 "prisma:migrate": "prisma format && prisma migrate dev",
4 "prisma:generate": "prisma generate"
5 }
6}

これで

  • 2.Prisma スキーマの作成

  • 3.Prisma の DB へのマイグレーション

  • 4.Prisma のバックエンド API 用ジェネレーション
    が完了です。

GraphQL 用 API サーバの準備

GraphQL 用のフレームワークに Pothos、サーバに GraphQL Yoga を使い、Next.js の AppRouter で処理を行います。

Pothos には標準的なプラグインの他に、追加で以下のプラグインを入れています

src/app/schema.tsx

1import path from "path";
2import SchemaBuilder from "@pothos/core";
3import PrismaPlugin from "@pothos/plugin-prisma";
4import PrismaUtils from "@pothos/plugin-prisma-utils";
5import { PrismaClient } from "@prisma/client";
6import PothosPrismaGeneratorPlugin from "pothos-prisma-generator";
7import PothosQueryGeneratorPlugin from "pothos-query-generator";
8import PothosSchemaExporterPlugin from "pothos-schema-exporter";
9
10const prismaClient = new PrismaClient();
11
12/**
13 * Create a new schema builder instance
14 */
15export const builder = new SchemaBuilder<{
16 // PrismaTypes: PrismaTypes; //Not used because it is generated automatically
17}>({
18 plugins: [
19 PrismaPlugin,
20 PrismaUtils,
21 PothosPrismaGeneratorPlugin,
22 PothosSchemaExporterPlugin,
23 PothosQueryGeneratorPlugin,
24 ],
25 prisma: {
26 client: prismaClient,
27 },
28 pothosSchemaExporter: {
29 output:
30 process.env.NODE_ENV === "development" &&
31 path.join(process.cwd(), "graphql", "schema.graphql"),
32 },
33 pothosQueryGenerator: {
34 output:
35 process.env.NODE_ENV === "development" &&
36 path.join(process.cwd(), "graphql", "query.graphql"),
37 },
38});
39
40export const schema = builder.toSchema({ sortSchema: false });

src/app/route.tsx

1import { createYoga } from "graphql-yoga";
2import { schema } from "./schema";
3
4const { handleRequest } = createYoga<{}>({
5 schema,
6 fetchAPI: { Response },
7});
8
9export { handleRequest as POST, handleRequest as GET };

pakcage.json には以下のコマンドを追加します。

1{
2 "scripts": {
3 "dev:next": "next"
4 }
5}

実行後、http://localhost:3000/graphqlにアクセスすると、Yoga の Explorer が起動します。

これで

  • 5.GraphQL 用 API サーバの準備

  • 6.GraphQL スキーマの作成とリゾルバの実装

  • 7.GraphQL クエリの作成

が完了です。

graphql フォルダにschema.graphqlquery.graphqlが自動生成されています。

Query から Mutation まで必要なものはだいたい揃っています。これでデータの取得、作成、更新、削除が全て行えます。抽出条件の設定やソート、件数制限などの機能も備わっています。

GraphQL クエリを graphql-codegen などで、フロントで実装しやすい形に変換

schema.graphql と query.graphql から Urql 用の Hooks を作成します。

codegen/codegen.ts

1import { CodegenConfig } from "@graphql-codegen/cli";
2
3const config: CodegenConfig = {
4 schema: "graphql/schema.graphql",
5 overwrite: true,
6 documents: "graphql/query.graphql",
7 generates: {
8 "src/generated/graphql.ts": {
9 plugins: ["typescript", "typescript-operations", "typescript-urql"],
10 config: { scalars: { DateTime: "string" } },
11 },
12 },
13};
14
15export default config;

pakcage.json には以下のコマンドを追加します。

1{
2 "scripts": {
3 "graphql:codegen": "graphql-codegen --config codegen/codegen.ts"
4 }
5}

src/generated/graphql.ts に型付で必要な hooks が用意されました。バックエンドに接続するための作業は以上になります。あとはフロントを書いていくだけです。

生成されるファイルは以下のようになります。
graphql.ts

これで

  • 8.GraphQL クエリを graphql-codegen などで、フロントで実装しやすい形に変換

が完了です。

フロントの実装

準備作業

今回は認証などの処理は入れていませんが、SSR 時に認証情報が必要になったときに備えて Cookie で渡せるようにしてあります。また、アプリケーションをデプロイした先のURLをSSR時に認識できるように、ホスト名の引き渡しも行っています。Vercelに置いた場合やNginxでプロキシした場合など、汎用的に対応可能です。

上記を使ってpages にコンポーネントを配置すれば、SSR 対応のアプリケーションになります。

_src/global.css

1@tailwind base;
2@tailwind components;
3@tailwind utilities;

src/pages/_app.tsx

1import "../global.css";
2import { AppContext, AppProps } from "next/app";
3import { UrqlProvider } from "@/components/Provider/UrqlProvider";
4import { getHost } from "@/libs/getHost";
5
6const App = ({
7 Component,
8 pageProps,
9}: AppProps<{ host?: string; cookie?: string }>) => {
10 const { cookie, host } = pageProps;
11 return (
12 <UrqlProvider host={host} cookie={cookie} endpoint="/graphql">
13 <Component {...pageProps} />
14 </UrqlProvider>
15 );
16};
17
18App.getInitialProps = async (context: AppContext) => {
19 // ホスト名とクッキーを渡す
20 const req = context?.ctx?.req;
21 const host = getHost(req);
22 return {
23 pageProps: {
24 cookie: req?.headers?.cookie,
25 host,
26 },
27 };
28};
29
30export default App;

src\components\Provider\UrqlProvider.tsx

1import {
2 NextSSRProvider,
3 createNextSSRExchange,
4} from "@react-libraries/next-exchange-ssr";
5import { ReactNode, useMemo } from "react";
6import { cacheExchange, Client, fetchExchange, Provider } from "urql";
7
8const isServerSide = typeof window === "undefined";
9
10export const UrqlProvider = ({
11 host,
12 cookie,
13 endpoint,
14 children,
15}: {
16 host?: string;
17 cookie?: string;
18 endpoint: string;
19 children: ReactNode;
20}) => {
21 const client = useMemo(() => {
22 const nextSSRExchange = createNextSSRExchange();
23 const url = isServerSide ? `${host}${endpoint}` : endpoint;
24 return new Client({
25 url,
26 fetchOptions: {
27 headers: {
28 "apollo-require-preflight": "true",
29 cookie: cookie ?? "",
30 },
31 },
32 suspense: isServerSide,
33 exchanges: [cacheExchange, nextSSRExchange, fetchExchange],
34 });
35 }, [host, cookie]);
36 return (
37 <Provider value={client}>
38 <NextSSRProvider>{children}</NextSSRProvider>
39 </Provider>
40 );
41};

src/libs/getHost.ts

1import type { IncomingMessage } from "http";
2
3export const getHost = (req?: Partial<IncomingMessage>) => {
4 const headers = req?.headers;
5
6 const host = headers?.["x-forwarded-host"] ?? headers?.["host"];
7 if (!host) return undefined;
8 const proto =
9 headers?.["x-forwarded-proto"]?.toString().split(",")[0] ?? "http";
10 return headers ? `${proto}://${host}` : undefined;
11};

カテゴリ入力機能

graphql-codegen で生成した hook を呼び出し、Category テーブルの中身を操作します。データ取得時はnameをキーに昇順にソートさせています。

src/pages/category.tsx

1import Link from "next/link";
2import {
3 OrderBy,
4 useCreateOneCategoryMutation,
5 useFindManyCategoryQuery,
6} from "@/generated/graphql";
7
8// GraphQLのadditionalTypenamesを設定する
9const context = {
10 additionalTypenames: ["Category"],
11};
12
13const Page = () => {
14 const [{ data: dataCategory }] = useFindManyCategoryQuery({
15 context,
16 variables: { orderBy: { name: OrderBy.Asc } },
17 });
18 // カテゴリ一覧を取得
19 const [{ fetching: fetchingCategory }, createCategory] =
20 useCreateOneCategoryMutation();
21 // カテゴリの作成
22 const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
23 e.preventDefault();
24 const target = e.target as typeof e.target & {
25 name: { value: string };
26 };
27 // 新しいカテゴリを作成する
28 if (target.name.value) {
29 createCategory({
30 input: {
31 name: target.name.value,
32 },
33 });
34 // フォームをリセットする
35 e.currentTarget.reset();
36 }
37 };
38 return (
39 <>
40 <div className="max-w-2xl m-auto py-4">
41 <Link className="underline text-blue-500" href="/">
42 投稿一覧
43 </Link>
44 {/* カテゴリフォーム */}
45 <form
46 className="grid border-gray-400 border-solid"
47 onSubmit={handleSubmit}
48 >
49 <label htmlFor="flex-1">Category</label>
50 <input className="border p-1" type="text" name="name" />
51 <button
52 className="shadow bg-blue-500 hover:bg-blue-400 focus:shadow-outline focus:outline-none text-white font-bold py-2 px-4 rounded m-1 disabled:opacity-30 disabled:cursor-not-allowed"
53 type="submit"
54 disabled={fetchingCategory}
55 >
56 Create Category
57 </button>
58 </form>
59 <hr className="m-2" />
60
61 {/* カテゴリ一覧 */}
62 <div>
63 {dataCategory?.findManyCategory.map((category) => (
64 <div key={category.id} className="mt-5 p-1 border rounded">
65 <div className="flex-1">{category.name}</div>
66 </div>
67 ))}
68 </div>
69 </div>
70 </>
71 );
72};
73
74export default Page;

実行画面

{"width":"968px","height":"491px"}

投稿の作成と表示

1ページの表示件数を5件に設定して、ページジェネレーションを付けています。また、別ページで作成したカテゴリを設定する機能も付けています。QueryやMutationで使用するパラメータはPrismaに近い形のものが使用できます。

src/pages/index.tsx

1import Link from "next/link";
2import { useRouter } from "next/router";
3import {
4 OrderBy,
5 useCountPostQuery,
6 useCreateOnePostMutation,
7 useDeleteOnePostMutation,
8 useFindManyCategoryQuery,
9 useFindManyPostQuery,
10} from "@/generated/graphql";
11
12// GraphQLのadditionalTypenamesを設定する
13const context = {
14 additionalTypenames: ["Post", "Category"],
15};
16
17// 1ページに表示する投稿の数
18const PageLimit = 5;
19
20const Page = () => {
21 const router = useRouter();
22 // ページ番号を取得する
23 const page = Number(router.query.page) || 1;
24 // useFindManyPostQueryフックを使用して、投稿を取得する(updatedAtを降順)
25 const [{ data: dataPost }] = useFindManyPostQuery({
26 variables: {
27 orderBy: { updatedAt: OrderBy.Desc },
28 categoriesOrderBy: { name: OrderBy.Asc },
29 limit: 5,
30 offset: (page - 1) * PageLimit,
31 },
32 context,
33 });
34 const [{ data: dataCategory }] = useFindManyCategoryQuery({
35 variables: { orderBy: { name: OrderBy.Asc } },
36 });
37 // 投稿の総数を取得する
38 const [{ data: dataPostCount }] = useCountPostQuery({ context });
39 // 新しい投稿を作成する
40 const [{ fetching: fetchingCreatePost }, createPost] =
41 useCreateOnePostMutation();
42 // 投稿を削除する
43 const [, deletePost] = useDeleteOnePostMutation();
44
45 // 投稿フォームが送信されたときに呼び出される関数
46 const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
47 e.preventDefault();
48 const target = e.target as typeof e.target & {
49 title: { value: string };
50 content: { value: string };
51 category: RadioNodeList;
52 };
53 // カテゴリIDを取り出す
54 const categories = Array.from(target.category).flatMap((category) =>
55 category instanceof HTMLInputElement && category.checked
56 ? [category.value]
57 : []
58 );
59 // 新しい投稿を作成する
60 createPost({
61 input: {
62 title: target.title.value || "タイトルなし",
63 content: target.content.value || "内容なし",
64 categories: {
65 connect: categories.map((id) => ({
66 id,
67 })),
68 },
69 },
70 });
71 // フォームをリセットする
72 e.currentTarget.reset();
73 };
74
75 // 投稿の総数を取得する
76 const postCounts = dataPostCount?.countPost ?? 0;
77 // 投稿の総ページ数を計算する
78 const postPages = Math.ceil(postCounts / PageLimit);
79 return (
80 <>
81 <div className="max-w-2xl m-auto py-4">
82 <Link className="underline text-blue-500" href="/category">
83 カテゴリの追加
84 </Link>
85 {/* 投稿フォーム */}
86 <form
87 className="grid border-gray-400 border-solid"
88 onSubmit={handleSubmit}
89 >
90 <label htmlFor="flex-1">Title</label>
91 <input className="border p-1" type="text" name="title" />
92 <label htmlFor="content">Content</label>
93 <textarea className="border p-2 rounded" rows={5} name="content" />
94 {/* カテゴリ一覧 */}
95 <div className="flex gap-2 flex-wrap p-2">
96 {dataCategory?.findManyCategory.map((category) => (
97 <label
98 key={category.id}
99 className="border border-blue-400 rounded p-2"
100 >
101 <input type="checkbox" name="category" value={category.id} />{" "}
102 {category.name}
103 </label>
104 ))}
105 </div>
106 <button
107 className="shadow bg-blue-500 hover:bg-blue-400 focus:shadow-outline focus:outline-none text-white font-bold py-2 px-4 rounded m-1 disabled:opacity-30 disabled:cursor-not-allowed"
108 type="submit"
109 disabled={fetchingCreatePost}
110 >
111 Create Post
112 </button>
113 </form>
114 <hr className="m-2" />
115 {/* ページネーション */}
116 <div className="flex gap-2 items-center">
117 <div>最大5件表示</div>
118 <div>
119 Page {page}/{postPages}
120 </div>
121 <Link
122 className={`border p-1 rounded ${
123 page <= 1 ? "opacity-30 cursor-not-allowed" : ""
124 }`}
125 href={`/?page=${page <= 1 ? page : page - 1}`}
126 >
127
128 </Link>
129 <Link
130 className={`border p-1 rounded ${
131 page >= postPages ? "opacity-30 cursor-not-allowed" : ""
132 }`}
133 href={`/?page=${page >= postPages ? page : page + 1}`}
134 >
135
136 </Link>
137 All:{postCounts}
138 </div>
139
140 {/* 投稿一覧 */}
141 <div>
142 {dataPost?.findManyPost.map((post) => (
143 <div key={post.id} className="mt-5 p-1 border rounded">
144 <div className="flex gap-2">
145 <div className="flex-1">{post.title}</div>
146 <div>[{post.id}]</div>
147 <div>
148 {new Date(post.updatedAt).toLocaleString("ja-JP", {
149 timeZone: "Asia/Tokyo",
150 })}
151 </div>
152 <button
153 className="border p-px rounded bg-red-500 hover:bg-red-400 focus:shadow-outline focus:outline-none text-white font-bold px-px"
154 onClick={() => deletePost({ where: { id: post.id } })}
155 >
156 Del
157 </button>
158 </div>
159 <div className="whitespace-pre-wrap">{post.content}</div>
160 <div className="flex flex-wrap gap-1">
161 {post.categories.map((category) => (
162 <div key={category.id} className="bg-slate-100 px-1">
163 {category.name}
164 </div>
165 ))}
166 </div>
167 </div>
168 ))}
169 </div>
170 </div>
171 <div className="text-center">
172 <div>
173 <Link className="underline text-blue-500" href="/explorer">
174 動作確認用 Apollo Explorer
175 </Link>
176 </div>
177 <div>
178 <Link
179 className="underline text-blue-500"
180 href="https://github.com/SoraKumo001/next-graphql"
181 >
182 ソースコード
183 </Link>
184 </div>
185 </div>
186 </>
187 );
188};
189
190export default Page;

実行画面

Urql に SSR 用のプラグインを入れているため、結果はページを読み込んだ時点で HTML に挿入されています。CreatePostでデータを追加するとクライアント側で再レンダリングされます。コンポーネントのレンダリングはAppRouterではなくPagesRouterを使っているので、ServerComponents特有の制限はありません。

また、JavaScriptをOFFにしても、表示系機能はページジェネレーションを含め動作します。このあたりはNext.jsの標準機能として存在しているので、きちんとSSRさせているかが重要になります。

{"width":"1006px","height":"1212px"}

おまけ ApolloExplorerを使う

GraphQLクエリを作成・テストするときにApolloExplorerがあると便利です。Yogaにも実装されているのですが、ApolloExplorerの方が使い勝手が良いのです。ApolloServerを使っている場合は、標準で使えるのですが、Yogaを使っている場合は自分でコンポーネントを組み込む必要があります。

src/components/GraphQLExplorer

1import { ApolloExplorer } from "@apollo/explorer/react";
2import { FC } from "react";
3
4export const GraphQLExplorer: FC<{ schema: string }> = ({ schema }) => {
5 return (
6 <ApolloExplorer
7 className="fixed inset-0"
8 schema={schema}
9 endpointUrl="/graphql"
10 persistExplorerState={true}
11 handleRequest={(url, option) =>
12 fetch(url, { ...option, credentials: "same-origin" })
13 }
14 />
15 );
16};

src/pages/explorer.tsx

1import { printSchema } from "graphql";
2import { GetStaticProps, NextPage } from "next";
3import { schema } from "@/app/graphql/schema";
4import { GraphQLExplorer } from "@/components/GraphQLExplorer";
5
6const Page: NextPage<{ schema: string }> = ({ schema }) => {
7 return <GraphQLExplorer schema={schema} />;
8};
9
10// Schemaを渡すのに使用、これでIntrospectionクエリが不要となる
11export const getStaticProps: GetStaticProps = async () => {
12 return {
13 props: { schema: printSchema(schema) },
14 };
15};
16
17export default Page;

実行画面

{"width":"1913px","height":"966px"}

まとめ

GraphQL に入門するのが難しい理由は、ほとんどの部分が自動生成されるためです。GraphQL スキーマを作成する必要も、GraphQL クエリを書く必要もありません。

今後必要なことは、データ構造を追加した場合などにprisma.schemaに追記していくことです。そうすることで、prisma generateを実行した後にhttp://localhost:3000/graphqlにアクセスするだけで、新しいクエリが自動生成されます。

ただし、実際に運用する場合には問題があります。query.graphqlに記述されているデータの範囲が大きいため、必要のないデータまで取得してしまうことがあります。最終的には、データの取得範囲を調整するために自分で修正することをオススメします。

また、今回は認証や権限管理に関して特に何もしていません。これらの処理は、schema.prismaにディレクティブを設定することで、自動生成されるリゾルバに権限管理を付加できます。詳しくは、こちらの記事の後半を参照してください。

内容的にはしれっとSSRさせています。AppRouterのServerComponentsを使わなくとも、UrqlのSuspenseをPages上でSSRさせるのは、Urqlにプラグインを入れるだけで非常に簡単に実現可能です。ぜひ、使ってみてください。