Next.jsによるGraphQLに入門させないGraphQL入門
ソースコードと実働環境
Vercel に設置した動作確認環境
https://next-graphql-five.vercel.app/
最終的に出来ること
Prisma スキーマを設定するだけで、GraphQL スキーマやリゾルバを自動生成し、対応する GraphQL クエリも自動的に作って、React コンポーネントから使える Hooks を自動生成し、普通にコンポーネントを作るときちんと SSR で動作し、JavaScript を切ったとしてもページジェネレーションぐらいまでは表示できるようになります。
今回使うもの一覧
種類 | Package |
---|---|
DB | PostgreSQL |
ORM | Prisma |
GraphQL Server | GraphQL Yoga |
GraphQL Framework | Pothos GraphQL |
GraphQL Client | Urql |
GraphQL Server は Apollo と Yoga の二択で、入門用により簡単に使える Yoga を今回は選択しています。
GraphQL Framework は Nexus と Pothos の二択で、Prisma との連携をカスタマイズする上で便利な Pothos を選びました。
GraphQL Client は ApolloClient と Urql の二択で、Mutation 時のキャッシュ管理が楽な Urql を利用しています。
Next.js の利用方法
種類 | 内容 |
---|---|
App Router | GraphQL 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-postgres5 image: postgres:alpine6 environment:7 POSTGRES_DB: postgres8 POSTGRES_USER: postgres9 POSTGRES_PASSWORD: password10 volumes:11 - next-graphql-vol:/var/lib/postgresql/data12 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-schema34generator client {5 provider = "prisma-client-js"6}78datasource db {9 provider = "postgresql"10 url = env("DATABASE_URL")11}121314model 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 @updatedAt22 publishedAt DateTime @default(now())23}2425model Category {26 id String @id @default(uuid())27 name String28 posts Post[]29 createdAt DateTime @default(now())30 updatedAt DateTime @updatedAt31}
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 には標準的なプラグインの他に、追加で以下のプラグインを入れています
Prisma スキーマから GraphQL スキーマとリゾルバ作成するプラグイン
https://www.npmjs.com/package/pothos-prisma-generatorGraphQL スキーマから GraphQL クエリを作成するプラグイン
https://www.npmjs.com/package/pothos-query-generatorGraphQL スキーマをファイルに出力するプラグイン
https://www.npmjs.com/package/pothos-schema-exporter
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";910const prismaClient = new PrismaClient();1112/**13 * Create a new schema builder instance14 */15export const builder = new SchemaBuilder<{16 // PrismaTypes: PrismaTypes; //Not used because it is generated automatically17}>({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});3940export const schema = builder.toSchema({ sortSchema: false });
src/app/route.tsx
1import { createYoga } from "graphql-yoga";2import { schema } from "./schema";34const { handleRequest } = createYoga<{}>({5 schema,6 fetchAPI: { Response },7});89export { 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.graphqlとquery.graphqlが自動生成されています。
Query から Mutation まで必要なものはだいたい揃っています。これでデータの取得、作成、更新、削除が全て行えます。抽出条件の設定やソート、件数制限などの機能も備わっています。
GraphQL クエリを graphql-codegen などで、フロントで実装しやすい形に変換
schema.graphql と query.graphql から Urql 用の Hooks を作成します。
codegen/codegen.ts
1import { CodegenConfig } from "@graphql-codegen/cli";23const 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};1415export 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でプロキシした場合など、汎用的に対応可能です。
Urqlによるクエリを使ったコンポーネントをSSRにするプラグイン
https://www.npmjs.com/package/@react-libraries/next-exchange-ssr
上記を使って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";56const 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};1718App.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};2930export 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";78const isServerSide = typeof window === "undefined";910export 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";23export const getHost = (req?: Partial<IncomingMessage>) => {4 const headers = req?.headers;56 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";78// GraphQLのadditionalTypenamesを設定する9const context = {10 additionalTypenames: ["Category"],11};1213const 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 <form46 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 <button52 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 Category57 </button>58 </form>59 <hr className="m-2" />6061 {/* カテゴリ一覧 */}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};7374export default Page;
実行画面
投稿の作成と表示
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";1112// GraphQLのadditionalTypenamesを設定する13const context = {14 additionalTypenames: ["Post", "Category"],15};1617// 1ページに表示する投稿の数18const PageLimit = 5;1920const 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();4445 // 投稿フォームが送信されたときに呼び出される関数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.checked56 ? [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 };7475 // 投稿の総数を取得する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 <form87 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 <label98 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 <button107 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 Post112 </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 <Link122 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 <Link130 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>139140 {/* 投稿一覧 */}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 <button153 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 Del157 </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 Explorer175 </Link>176 </div>177 <div>178 <Link179 className="underline text-blue-500"180 href="https://github.com/SoraKumo001/next-graphql"181 >182 ソースコード183 </Link>184 </div>185 </div>186 </>187 );188};189190export default Page;
実行画面
Urql に SSR 用のプラグインを入れているため、結果はページを読み込んだ時点で HTML に挿入されています。CreatePostでデータを追加するとクライアント側で再レンダリングされます。コンポーネントのレンダリングはAppRouterではなくPagesRouterを使っているので、ServerComponents特有の制限はありません。
また、JavaScriptをOFFにしても、表示系機能はページジェネレーションを含め動作します。このあたりはNext.jsの標準機能として存在しているので、きちんとSSRさせているかが重要になります。
おまけ ApolloExplorerを使う
GraphQLクエリを作成・テストするときにApolloExplorerがあると便利です。Yogaにも実装されているのですが、ApolloExplorerの方が使い勝手が良いのです。ApolloServerを使っている場合は、標準で使えるのですが、Yogaを使っている場合は自分でコンポーネントを組み込む必要があります。
src/components/GraphQLExplorer
1import { ApolloExplorer } from "@apollo/explorer/react";2import { FC } from "react";34export const GraphQLExplorer: FC<{ schema: string }> = ({ schema }) => {5 return (6 <ApolloExplorer7 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";56const Page: NextPage<{ schema: string }> = ({ schema }) => {7 return <GraphQLExplorer schema={schema} />;8};910// Schemaを渡すのに使用、これでIntrospectionクエリが不要となる11export const getStaticProps: GetStaticProps = async () => {12 return {13 props: { schema: printSchema(schema) },14 };15};1617export default Page;
実行画面
まとめ
GraphQL に入門するのが難しい理由は、ほとんどの部分が自動生成されるためです。GraphQL スキーマを作成する必要も、GraphQL クエリを書く必要もありません。
今後必要なことは、データ構造を追加した場合などにprisma.schemaに追記していくことです。そうすることで、prisma generateを実行した後にhttp://localhost:3000/graphqlにアクセスするだけで、新しいクエリが自動生成されます。
ただし、実際に運用する場合には問題があります。query.graphqlに記述されているデータの範囲が大きいため、必要のないデータまで取得してしまうことがあります。最終的には、データの取得範囲を調整するために自分で修正することをオススメします。
また、今回は認証や権限管理に関して特に何もしていません。これらの処理は、schema.prismaにディレクティブを設定することで、自動生成されるリゾルバに権限管理を付加できます。詳しくは、こちらの記事の後半を参照してください。
内容的にはしれっとSSRさせています。AppRouterのServerComponentsを使わなくとも、UrqlのSuspenseをPages上でSSRさせるのは、Urqlにプラグインを入れるだけで非常に簡単に実現可能です。ぜひ、使ってみてください。