Next.jsとDrizzleで構築する自動のGraphQLとSSR環境

概要🔗
Next.js、Drizzle ORM、GraphQL で構築された実装について解説します。
このプロジェクトは、Drizzle ORM で定義したデータベース構造を自動で GraphQL 化し、対応した Hooks の出力まで行います。また、Next.js から SSR 側とブラウザ側のデータ取得コードは共通の Hook で行われるので、別々にデータ取得ロジックを書く必要がありません。
主な特徴 (Technical Highlights):
- Hook の作成まで自動化: Drizzle で定義した DB のスキーマは、GraphQL スキーマへの変換後、さらに各操作に対応した Hook の作成まで自動で行ないます。
- N+1 問題の解消: リレーションを伴うクエリは Drizzle ORM によって最適化され、GraphQL の API に変換されます。
- データ取得の一元化: SSR 時のデータ取得を Client Component 上から行う構成により、サーバーとブラウザでデータ取得ロジックを統一できます。
graph LR
A[Drizzle Schema] -->|"Pothos + pothos-drizzle-generator"| B[GraphQL Schema]
B -->|"Hono + @hono/graphql-server"| C[GraphQL API]
B -->|"graphql-auto-query"| D[GraphQL Operation File]
D -->|"@graphql-codegen/cli"| E["React Hooks (Urql)"]
目次🔗
プロジェクト概要🔗
動作サンプル & リポジトリ🔗
アプリケーション機能🔗
- 投稿管理:
- 投稿リストの表示(ホームページ)
- 投稿詳細の表示(リードオンリービュー)
- 新規投稿の作成(タイトル、コンテンツ、公開ステータス、カテゴリ)
- 既存の投稿の編集・削除
- 下書きシステム(公開/非公開投稿)
- ユーザーシステム:
- ユーザー切り替え/認証(デモ用に簡略化/シミュレート)
- ユーザーごとの投稿数表示
- ログイン時の自動リダイレクト
- ユーザーロール管理
- カテゴリ管理:
- 投稿へのカテゴリ設定
- 管理機能:
- データのシード(初期化)リセット機能(ヘッダーボタン)
技術スタック🔗
- フレームワーク: Next.js (App Router)
- 言語: TypeScript
- データベース: PostgreSQL
- ORM: Drizzle ORM
- API: GraphQL (サーバー: Hono + Pothos, クライアント: Urql)
- スタイリング: Tailwind CSS + DaisyUI
- 認証: カスタム JWT 認証
- コード生成: GraphQL Codegen
アーキテクチャ概要🔗
Next.js がフロントとバックエンドサーバーを兼ねていますが、GraphQL を使うことでフロントと DB とやり取りするバックエンド部分は分離されています。また、FirstHTML 出力後は、ServerAction を使用せず、 Urql から GraphQL のキャッシュを利用した Fetch を使用します。
graph BT
subgraph DATABASE ["Database"]
DB[("PostgreSQL")]
end
subgraph ServerLogic ["Backend Server (Next.js)"]
DrizzleSchema[Drizzle Schema]
Drizzle[Drizzle ORM]
Pothos[Pothos Schema Builder]
GraphQL(GraphQL Schema)
Hono["Hono Server
(GraphQL Server)"]
end
subgraph NextJS ["Front Server (Next.js)"]
Route["API Route
/api/graphql"]
Urql[Urql Client]
Pages["Pages
(Client Component)"]
Layout["Layout
(Server Component)"]
end
subgraph Client ["Client Browser"]
ClientUrql[Urql Client]
ClientPages["Pages
(Client Component)"]
end
DrizzleSchema --> Drizzle
DrizzleSchema --> Pothos
Pothos -->|Define Schema| GraphQL
GraphQL --> Hono
Drizzle -->|DB Query| DB
Hono -->|Drizzle API| Drizzle
Route -->|Handle Request| Hono
Urql -->|Fetch| Route
Pages -->|GraphQL Query| Urql
Layout -->|Passes Encrypted Token| Pages
ClientUrql -->|Fetch| Route
ClientPages -->|GraphQL Query| ClientUrql
Client --> |"SSR First HTML
(Cookie token)"|Layout
インストールとセットアップ🔗
前提条件🔗
- Node.js (v18+)
- pnpm(推奨), npm, または yarn
- Docker(ローカル開発用データベース)
手順🔗
-
依存関係のインストール
1pnpm install -
データベースの起動
Docker Compose を使用して PostgreSQL を起動します。1pnpm docker -
データベースのセットアップ
スキーマの初期化とシードデータのロード(マイグレーション & シード)を行います。1pnpm drizzle:reset -
開発サーバーの起動
1pnpm dev -
アプリケーションへのアクセス
ブラウザで http://localhost:3000 を開きます。
スクリプト一覧🔗
開発や運用で使用する主要なコマンドです。
| コマンド | 説明 |
|---|---|
dev | Next.js 開発サーバーを起動します。 |
docker | PostgreSQL コンテナを起動します。 |
build | 本番用にアプリケーションをビルドします。 |
start | 本番サーバーを起動します。 |
drizzle:generate | スキーマ変更に基づいて SQL マイグレーションを生成します。 |
drizzle:migrate | 開発環境のデータベースにマイグレーションを適用します。 |
drizzle:migrate:production | 本番環境のデータベースにマイグレーションを適用します。 |
drizzle:seed | テストデータをデータベースにシードします。 |
drizzle:reset | データベースをリセットします(マイグレーション + シード)。 |
lint | ESLint を実行します。 |
graphql:schema | GraphQL スキーマをエクスポートします。 |
graphql:codegen | GraphQL の変更を監視し、TypeScript の型を生成します。 |
プロジェクト構成🔗
このプロジェクトの主要なディレクトリ構成は以下の通りです。
src/: アプリケーションのソースコードapp/: Next.js App Router ページと API ルートcomponents/: 共有 UI コンポーネント(StoreProvider など)db/: Drizzle スキーマとリレーション定義generated/: 生成された GraphQL 型とフックhooks/: カスタム React フックlibs/: ユーティリティライブラリserver/: GraphQL サーバーロジックとスキーマビルダー
codegen/: GraphQL Code Generator 設定drizzle/: データベースマイグレーションファイルtools/: シーディングと管理用スクリプト
アーキテクチャと実装詳細🔗
このプロジェクトでは、Code-First アプローチを採用し、開発効率と型安全性を最大化しています。
GraphQL サーバーとスキーマ設計🔗
1. データモデル (ER 図)🔗
erDiagram
User ||--o{ Post : "authors"
User {
uuid id PK
string email
string name
enum roles "ADMIN, USER"
}
Post ||--|{ PostToCategory : "categorized_in"
Post {
uuid id PK
boolean published
string title
string content
uuid authorId FK
}
Category ||--|{ PostToCategory : "contains"
Category {
uuid id PK
string name
}
PostToCategory {
uuid postId FK
uuid categoryId FK
}
2. スキーマの自動生成 (src/server/builder.ts)🔗
Pothos と Drizzle プラグインを組み合わせることで、DB スキーマから GraphQL スキーマを自動生成します。
自動生成はカスタマイズが可能であり、サンプルの例では以下の機能が提供されます。
- 自動化:
drizzle-ormの定義を読み取り、Query/Mutation を即座に作成。 - セキュリティ (RLS):
executable: 認証済みユーザーのみ Mutation を許可。where: ユーザー ID に基づき、取得・更新できるデータを自動フィルタリング。
- カスタマイズ: 中間テーブルの除外や、システム管理フィールド(
createdAtなど)の入力不可設定。
1import SchemaBuilder from "@pothos/core";2import DrizzlePlugin from "@pothos/plugin-drizzle";3import { getTableConfig } from "drizzle-orm/pg-core";4import PothosDrizzleGeneratorPlugin, {5 isOperation,6} from "pothos-drizzle-generator";7import { relations } from "../db/relations";8import type { Context } from "./context";9import type { Context as HonoContext } from "hono";10import { db } from "../db";1112// Tables to exclude from GraphQL schema generation13// Junction tables like "postsToCategories" are typically excluded14const EXCLUDE_TABLES: Array<keyof typeof relations> = ["postsToCategories"];1516export interface PothosTypes {17 DrizzleRelations: typeof relations;18 Context: HonoContext<Context>;19}2021/**22 * Initialize Pothos Schema Builder with plugins:23 * - DrizzlePlugin: Integrates Drizzle ORM with Pothos24 * - PothosDrizzleGeneratorPlugin: Automatically generates GraphQL schema from Drizzle schema25 */26export const builder = new SchemaBuilder<PothosTypes>({27 plugins: [DrizzlePlugin, PothosDrizzleGeneratorPlugin],28 drizzle: {29 client: () => db,30 relations,31 getTableConfig,32 },33 pothosDrizzleGenerator: {34 // Exclude specific tables from schema generation (e.g., junction tables)35 use: { exclude: EXCLUDE_TABLES },36 // Global configuration applied to all models37 all: {38 // Maximum query depth to prevent deeply nested queries (protection against DoS)39 depthLimit: () => 5,40 // Controls whether operations (findMany, findFirst, count, create, update, delete) are executable41 // This guards against unauthorized mutations by requiring authentication42 executable: ({ operation, ctx }) => {43 if (isOperation(["mutation"], operation) && !ctx.get("user")) {44 return false;45 }46 return true;47 },48 // Configure input fields for create/update operations49 // Excludes auto-managed system fields from user input50 inputFields: () => {51 return { exclude: ["createdAt", "updatedAt"] };52 },53 },54 // Model-specific configuration55 models: {56 posts: {57 // Automatically inject data during create/update operations58 // Sets authorId to the current authenticated user59 inputData: ({ ctx }) => {60 const user = ctx.get("user");61 if (!user) throw new Error("No permission");62 return { authorId: user.id };63 },64 // Apply WHERE clause filters based on operation type65 // This implements row-level security66 where: ({ ctx, operation }) => {67 // For queries (findMany, findFirst, count): show published posts or user's own posts68 if (isOperation(["query"], operation)) {69 return {70 OR: [{ authorId: ctx.get("user")?.id }, { published: true }],71 };72 }73 // For mutations (create, update, delete): only allow operations on user's own posts74 if (isOperation(["mutation"], operation)) {75 return { authorId: ctx.get("user")?.id };76 }77 },78 },79 },80 },81});
3. オペレーションの追加 (src/server/operations.ts)🔗
自動生成された CRUD 操作以外に、認証(ログイン・ログアウト)やデータシードなどのカスタム操作を追加しています。
1import { builder } from "./builder";2import { setCookie } from "hono/cookie";3import { SignJWT } from "jose";4import { getEnvVariable } from "../libs/getEnvVariable";5import { db } from "../db";6import type { GraphQLSchema } from "graphql";7import { isTable, sql } from "drizzle-orm";8import { getTableConfig, type PgTable } from "drizzle-orm/pg-core";9import { seed } from "drizzle-seed";10import * as dbSchema from "../db/schema";1112// Secret key for JWT token signing and verification13const SECRET = getEnvVariable("SECRET");1415// JWT token expiration time: 400 days in seconds16const TOKEN_MAX_AGE = 60 * 60 * 24 * 400;1718// Cookie configuration shared across authentication operations19const COOKIE_OPTIONS = {20 httpOnly: true,21 sameSite: "strict" as const,22 path: "/",23};2425builder.queryType({26 fields: (t) => ({27 // Returns the currently authenticated user28 me: t.drizzleField({29 type: "users",30 nullable: true,31 resolve: (_query, _root, _args, ctx) => {32 const user = ctx.get("user");33 return user || null;34 },35 }),36 }),37});3839/**40 * Authentication mutations41 * Provides user authentication functionality including sign-in, sign-out, and current user retrieval42 */43builder.mutationType({44 fields: (t) => ({45 // Authenticates a user by email and sets JWT cookie46 signIn: t.drizzleField({47 args: { email: t.arg({ type: "String" }) },48 type: "users",49 nullable: true,50 resolve: async (_query, _root, { email }, ctx) => {51 const user =52 email &&53 (await db.query.users.findFirst({ where: { email: email } }));54 if (!user) {55 // Authentication failed: clear any existing auth cookie56 setCookie(ctx, "auth-token", "", { ...COOKIE_OPTIONS, maxAge: 0 });57 } else {58 // Authentication successful: generate JWT and set secure cookie59 const token = await new SignJWT({ user: user })60 .setProtectedHeader({ alg: "HS256" })61 .sign(new TextEncoder().encode(SECRET));62 setCookie(ctx, "auth-token", token, {63 ...COOKIE_OPTIONS,64 maxAge: TOKEN_MAX_AGE,65 });66 }67 return user || null;68 },69 }),70 // Signs out the current user by clearing the authentication cookie71 signOut: t.field({72 type: "Boolean",73 nullable: true,74 resolve: async (_root, _args, ctx) => {75 setCookie(ctx, "auth-token", "", { ...COOKIE_OPTIONS, maxAge: 0 });76 return true;77 },78 }),79 // Create seeds80 seeds: t.field({81 type: "Boolean",82 nullable: true,83 resolve: async () => {84 await db.transaction(async (tx) => {85 // drizzle-seedのresetはスキーマ名が巻き込まれるため、相当のものを独自に実装86 await db.execute(87 sql.raw(88 `truncate ${Object.values(dbSchema)89 .filter((t) => isTable(t))90 .map((t) => `"${getTableConfig(t as PgTable).name}"`)91 .join(",")} cascade;`92 )93 );94 await seed(tx, dbSchema);95 });96 return true;97 },98 }),99 }),100});101102export const schema: GraphQLSchema = builder.toSchema({ sortSchema: false });
4. Hono によるサーバー構築 (src/server/hono.ts)🔗
GraphQL サーバーの実体には、軽量・高速な Hono を使用しています。
- 認証ミドルウェア: Cookie 内の JWT を検証し、コンテキストに
userをセットします。 - Apollo Explorer: ブラウザアクセス時にクエリをテストできる IDE を提供します。
- GraphQL エンドポイント:
@hono/graphql-serverでリクエストを処理します。
1import { graphqlServer } from "@hono/graphql-server";2import { explorer } from "apollo-explorer/html";3import { generate } from "graphql-auto-query";4import { Hono } from "hono";5import { contextStorage } from "hono/context-storage";6import { getContext } from "hono/context-storage";7import { getCookie } from "hono/cookie";8import { jwtVerify } from "jose";9import { schema } from "./builder";10import type { Context } from "./context.js";11import type { relations } from "../db/relations";12import type { Context as HonoContext } from "hono";13import { getEnvVariable } from "@/libs/getEnvVariable";1415// Secret key for JWT verification16const SECRET = getEnvVariable("SECRET");1718// Cookie name for authentication token19const AUTH_TOKEN_COOKIE = "auth-token";2021// Apollo Explorer introspection interval (10 seconds)22const INTROSPECTION_INTERVAL = 10000;2324// Sample query generation depth25const QUERY_GENERATION_DEPTH = 1;2627/**28 * Middleware to extract and verify JWT token from cookies29 * Sets the authenticated user in the request context30 */31const authMiddleware = async (32 c: HonoContext<Context>,33 next: () => Promise<void>34) => {35 const cookies = getCookie(c);36 const token = cookies[AUTH_TOKEN_COOKIE] ?? "";3738 /**39 * Verify JWT token and extract user information40 * If verification fails (invalid/expired token), user will be undefined41 */42 const user = await jwtVerify(token, new TextEncoder().encode(SECRET))43 .then(44 (data) => data.payload.user as typeof relations.users.table.$inferSelect45 )46 .catch(() => undefined);47 // Store user in request context48 const context = getContext<Context>();49 context.set("user", user);5051 return next();52};5354/**55 * Initialize Hono application with custom context type56 * The Context type provides type-safe access to user authentication state57 */58export const app = new Hono<Context>();5960/**61 * Enable context storage middleware62 * This allows access to the request context from anywhere in the application63 */64app.use(contextStorage());6566/**67 * Apollo Explorer endpoint68 * Provides an interactive GraphQL IDE for testing queries and mutations69 */70app.get("*", (c) => {71 return c.html(72 explorer({73 initialState: {74 // Auto-generate sample GraphQL operations from the schema75 document: generate(schema, QUERY_GENERATION_DEPTH),76 },77 // GraphQL endpoint URL for the explorer to connect to78 endpointUrl: c.req.url,79 // Automatically refresh schema periodically80 introspectionInterval: INTROSPECTION_INTERVAL,81 })82 );83});8485/**86 * GraphQL endpoint87 * Handles GraphQL queries and mutations via POST requests88 * Authentication is handled by the authMiddleware89 */90app.post("*", authMiddleware, (c, next) => {91 return graphqlServer({92 schema,93 })(c, next);94});
5. Next.js Route Handler への統合 (src/app/api/graphql/route.ts)🔗
Web Standard API に準拠した Hono サーバーを、Next.js Route Handler としてマウントします。
1"use server";2import { app } from "../../../server/hono";34export async function POST(request: Request) {5 return app.fetch(request);6}78export async function GET(request: Request) {9 return app.fetch(request);10}
6. クライアント用クエリの自動生成 (graphql-auto-query)🔗
このプロジェクトでは、graphql-auto-query を使用して、GraphQL スキーマからクライアントサイドで使用するクエリ(オペレーション)を自動生成しています。これにより、手動でのクエリ記述の手間を省き、開発効率を向上させています。
-
CLI での生成:
npm run graphql:schemaコマンド実行時に、エクスポートされたスキーマ (codegen/schema.graphql) を元に、利用可能なすべての Query と Mutation を網羅した.graphqlファイル (graphql/query.graphql) を生成します。1"scripts": {2 "graphql:schema": "tsx ./tools/export-schema.ts && graphql-auto-query ./codegen/schema.graphql -o ./graphql/query.graphql"3} -
Apollo Explorer での活用:
src/server/hono.ts内でもgraphql-auto-queryのgenerate関数を使用し、Apollo Explorer の初期状態としてサンプルクエリを自動セットしています。これにより、開発者はブラウザですぐに動作確認を行えます。
認証と認可のフロー🔗
セキュリティと UX を両立させるため、堅牢な認証フローと、透過的な SSR 対応を実装しています。
A. 認証 (Authentication)🔗
サーバーサイドでの身元確認プロセスです。
- JWT 生成:
signInMutation でユーザー検証後、署名付き JWT を生成。 - Cookie 保存:
HttpOnly,SameSite: Strict属性を持つ Cookie に保存(XSS 対策)。 - リクエスト検証: Hono ミドルウェアがリクエスト毎に検証し、コンテキストに
userを注入。
sequenceDiagram
participant Client
participant Hono as Hono (Middleware)
participant Resolver as GraphQL Resolver
participant DB
Client->>Hono: GraphQL Request (Cookie: auth-token)
Hono->>Hono: Verify JWT
alt Valid Token
Hono->>Resolver: Execute (context.user = User)
else Invalid/No Token
Hono->>Resolver: Execute (context.user = null)
end
Resolver->>DB: Query
B. 認可 (Authorization)🔗
「誰が何をできるか」を GraphQL スキーマレベルで制御します。
- Mutation 保護: 未認証ユーザーによる書き込み操作(作成・更新・削除)をブロック。
- 行レベルセキュリティ (RLS):
- Read: 公開記事、または自分の下書き記事のみ取得可能。
- Write: 自分が作成した記事のみ編集・削除可能。
C. SSR における認証トークンの受け渡し🔗
Next.js (Server Component) から GraphQL API への内部通信ではブラウザの Cookie が自動付与されないため、以下の仕組みで状態を引き継ぎます。
- Layout (RSC): Cookie を読み取り、暗号化。
- UrqlProvider (Client): 暗号化されたトークンを Props として受け取る。
- GraphQL Request: トークンを復号し、SSR 中のリクエストヘッダーにセット。
graph LR
Browser["Browser"] -- "Cookie" --> RSC["Layout (RSC)"]
RSC -- "Encrypted Token" --> CC["UrqlProvider (Client)"]
CC -- "Decrypted Token (Header)" --> API["GraphQL API"]
フロントエンド統合(状態管理とフック)🔗
サーバーサイドの認証状態(Cookie)と同期しつつ、クライアントサイドでリアクティブな UI を実現します。
A. 軽量なグローバル状態管理 (src/components/StoreProvider.tsx)🔗
React 18 の useSyncExternalStore を活用し、外部ライブラリ(Redux 等)なしで「現在のログインユーザー」をアプリ全体で共有します。SSR データとの整合性も保たれます。
B. 認証用カスタムフック (src/hooks/useAuth.ts)🔗
| フック名 | 役割 |
|---|---|
useUser() | 現在ログインしているユーザー情報を取得。 |
useSignIn() | ログインを実行し、ローカルストアを更新して UI に即座に反映。 |
useSignOut() | ログアウトを実行し、Cookie 削除とともにローカルストアをリセット。 |
認証の実装例🔗
1"use client";2import { useFindManyUserQuery } from "@/generated/graphql";3import { useSignIn } from "@/hooks/useAuth";4import Link from "next/link";5import { useRouter } from "next/navigation";67const context = { additionalTypenames: ["User"] };89export default function Users() {10 const [{ data, fetching, error }, executeQuery] = useFindManyUserQuery({11 context,12 });13 const signIn = useSignIn();14 const router = useRouter();1516 return (17 <>18 <title>Users</title>19 <div className="container mx-auto p-4 max-w-5xl">20 <Link href="/" className="btn btn-link no-underline pl-0 mb-4">21 ← Back to Home22 </Link>2324 <div className="flex items-center gap-3 mb-8">25 <h1 className="text-3xl font-bold">Users</h1>26 <button27 className={`btn btn-circle btn-ghost btn-sm ${28 fetching ? "animate-spin" : ""29 }`}30 onClick={() => executeQuery({ requestPolicy: "network-only" })}31 title="Refresh"32 disabled={fetching}33 >34 {fetching ? (35 <span className="loading loading-spinner loading-xs"></span>36 ) : (37 <span className="material-symbols-outlined">refresh</span>38 )}39 </button>40 </div>4142 {error ? (43 <div className="alert alert-error my-4">44 <span className="material-symbols-outlined">error</span>45 <span>Error: {error.message}</span>46 </div>47 ) : fetching && !data ? (48 <div className="flex justify-center items-center py-20">49 <span className="loading loading-spinner loading-lg"></span>50 </div>51 ) : (52 <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">53 {data?.findManyUser?.map((user) => (54 <div55 key={user.id}56 className="card bg-base-100 shadow-sm border border-base-200 hover:shadow-md hover:border-base-300 transition-all"57 >58 <div className="card-body flex flex-row items-center gap-4">59 <div className="avatar placeholder">60 <div className="bg-neutral text-neutral-content rounded-full w-12 h-12 flex items-center justify-center">61 <span className="text-xl font-bold">62 {user.name.charAt(0).toUpperCase()}63 </span>64 </div>65 </div>66 <div className="flex-1 min-w-0">67 <div className="flex items-center gap-2">68 <h269 className="card-title text-lg truncate"70 title={user.name}71 >72 {user.name}73 </h2>74 {user.postsCount !== undefined && (75 <div className="badge badge-secondary badge-sm">76 {user.postsCount} posts77 </div>78 )}79 </div>80 <p className="text-sm text-base-content/70 truncate">81 {user.email}82 </p>83 </div>84 </div>85 <div className="card-actions justify-end p-4 pt-0">86 <button87 onClick={async () => {88 await signIn(user.email);89 router.push("/");90 }}91 className="btn btn-primary btn-sm"92 >93 Sign In94 </button>95 </div>96 </div>97 ))}98 </div>99 )}100101 {!fetching && !error && data?.findManyUser?.length === 0 && (102 <div className="text-center py-10 text-base-content/50">103 <p>No users found.</p>104 </div>105 )}106 </div>107 </>108 );109}
データフェッチと SSR の統合🔗
Next.js App Router 上で、Client Component からのデータ取得を SSR 対応させています。
@react-libraries/next-exchange-ssr の活用🔗
- データプリフェッチ: SSR 中に実行されたクエリ結果を収集。
- ハイドレーション: 収集したデータを HTML に埋め込み、クライアントでの再取得を回避。
- RSC 不要:
use clientコンポーネントでも SEO や初期表示パフォーマンスを維持。
Urql を利用するときは、ユーザーの切り替えなどでキャッシュをクリアできるようにしています。
1"use client";2import {3 NextSSRProvider,4 useCreateNextSSRExchange,5} from "@react-libraries/next-exchange-ssr";6import { type RetryExchangeOptions, retryExchange } from "@urql/exchange-retry";7import { type ReactNode, useCallback, useMemo } from "react";8import { cacheExchange, Client, fetchExchange, Provider } from "urql";9import { useUser } from "../hooks/useAuth";10import { decrypt } from "../libs/encrypt";11import { useDispatch, useSelector } from "./StoreProvider";1213const isServerSide = typeof window === "undefined";14const endpoint = "/api/graphql";1516const options: RetryExchangeOptions = {17 maxDelayMs: 3000,18 randomDelay: false,19};2021export const UrqlProvider = ({22 children,23 host,24 token,25}: {26 children: ReactNode;27 host?: string;28 token?: string;29}) => {30 const session = useUser();31 const nextSSRExchange = useCreateNextSSRExchange();32 const cacheState = useUrqlCache();33 const client = useMemo(() => {34 return new Client({35 url: `${host}${endpoint}`,36 fetchOptions: {37 headers: {38 "apollo-require-preflight": "true",39 cookie:40 // SSR時にtokenをデコードして認証情報を渡す41 isServerSide && token42 ? `auth-token=${decrypt(token, process.env.secret ?? "")}`43 : "",44 },45 },46 suspense: isServerSide,47 exchanges: [48 cacheExchange,49 nextSSRExchange,50 retryExchange(options),51 fetchExchange,52 ],53 preferGetMethod: false,54 });55 // eslint-disable-next-line react-hooks/exhaustive-deps56 }, [nextSSRExchange, session, cacheState]);57 return (58 <Provider value={client}>59 <NextSSRProvider>{children}</NextSSRProvider>60 </Provider>61 );62};6364export const useUrqlCache = () => {65 return useSelector((state: { urqlCache: object }) => state.urqlCache);66};6768export const useClearUrqlCache = () => {69 const dispatch = useDispatch<{ urqlCache: object }>();7071 return useCallback(() => {72 dispatch((state) => ({73 ...state,74 urqlCache: {},75 }));76 }, [dispatch]);77};
認証 Token と GraphQL エンドポイントの設定🔗
layout.tsxは唯一の ServerComponent です。ここでアクセス時に発生したヘッダー情報を利用して Token の受け渡しや SSR 時に利用する GraphQL のエンドポイントの指定を行います。
- Token の受け渡し: クライアントコンポーネントに暗号化された認証トークンを渡します。これは SSR 時にサーバー上で認証状態を保持するためだけに利用されます。ブラウザに引き渡された後は、直接ブラウザ内の Cookie を利用するため不要となります。
- GraphQL エンドポイントの指定: SSR 時に利用する GraphQL のエンドポイントを
UrqlProviderに渡します。
1import { Geist, Geist_Mono } from "next/font/google";2import { UrqlProvider } from "../providers/UrqlProvider";3import { StoreProvider } from "../providers/StoreProvider";4import { cookies, headers } from "next/headers";5import { jwtVerify } from "jose";6import type { users } from "../db/schema";7import { encrypt } from "../libs/encrypt";8import { Header } from "../components/Header";9import "./globals.css";1011const geistSans = Geist({12 variable: "--font-geist-sans",13 subsets: ["latin"],14});1516const geistMono = Geist_Mono({17 variable: "--font-geist-mono",18 subsets: ["latin"],19});2021async function getOrigin() {22 const headersList = await headers();23 const host = headersList.get("x-forwarded-host") || headersList.get("host");24 const protocol = headersList.get("x-forwarded-proto") || "http";25 return `${protocol}://${host}`;26}2728export default async function RootLayout({29 children,30}: Readonly<{31 children: React.ReactNode;32}>) {33 // Get token from cookies34 const token = await cookies().then((v) => v.get("auth-token")?.value);35 // Get origin for GraphQL client36 const host = await getOrigin();37 // Verify user token38 const user =39 token &&40 (await jwtVerify<{ payload: { user?: typeof users.$inferSelect } }>(41 String(token),42 new TextEncoder().encode(process.env.secret)43 )44 .then(({ payload: { user } }) => user as typeof users.$inferSelect)45 .then(({ id, name }: typeof users.$inferSelect) => ({ id, name }))46 .catch(() => undefined));4748 return (49 <StoreProvider50 initState={{51 user,52 }}53 >54 <UrqlProvider55 host={host}56 // Pass encrypted token to Client Component57 token={token && encrypt(token, process.env.secret ?? "")}58 >59 <html lang="en">60 <body61 className={`${geistSans.variable} ${geistMono.variable} antialiased`}62 >63 <div className="max-w-257 mx-auto">64 <Header />65 {children}66 </div>67 </body>68 </html>69 </UrqlProvider>70 </StoreProvider>71 );72}
実装例: 投稿一覧ページ🔗
SSR 時も Client Component 上でデータ取得が行われ HTML が返されます。ServerComponent でデータを取得した場合と違い、そのままブラウザで動作するので、サーバーとブラウザで別コードを書く必要がありません。
1"use client";2import { useFindManyPostQuery, OrderBy } from "@/generated/graphql";3import Link from "next/link";4import { PostCard } from "@/components/PostCard";56const context = { additionalTypenames: ["Post"] };78export default function Home() {9 const [{ data, error, fetching }, executeQuery] = useFindManyPostQuery({10 variables: { orderBy: [{ createdAt: OrderBy.Desc }] },11 context,12 });1314 return (15 <>16 <title>Home</title>17 <div className="p-4">18 <div className="flex justify-between items-center mb-8">19 <div className="flex items-center gap-3">20 <h1 className="text-3xl font-bold">Latest Posts</h1>21 <button22 className={`btn btn-circle btn-ghost btn-sm ${23 fetching ? "animate-spin" : ""24 }`}25 onClick={() => executeQuery({ requestPolicy: "network-only" })}26 title="Refresh"27 disabled={fetching}28 >29 {fetching ? (30 <span className="loading loading-spinner loading-xs"></span>31 ) : (32 <span className="material-symbols-outlined">refresh</span>33 )}34 </button>35 </div>36 <Link href="/posts/new" className="btn btn-primary">37 Create New Post38 </Link>39 </div>4041 {error ? (42 <div className="alert alert-error my-4">43 <span className="material-symbols-outlined">error</span>44 <span>Error: {error.message}</span>45 </div>46 ) : fetching ? (47 <div className="flex justify-center items-center py-20">48 <span className="loading loading-spinner loading-lg"></span>49 </div>50 ) : (51 <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">52 {data?.findManyPost?.map((post) => (53 <PostCard key={post.id} post={post} />54 ))}55 </div>56 )}5758 {!fetching && !error && data?.findManyPost?.length === 0 && (59 <div className="text-center py-10 text-base-content/50">60 <p>No posts found. Be the first to create one!</p>61 </div>62 )}63 </div>64 </>65 );66}
まとめ🔗
Drizzle と GraphQL を統合することで、DB スキーマの定義から React Hooks の生成までをシームレスに自動化しました。これにより、開発者は API のボイラープレート作成から解放され、UI 実装に専念できます。また、Client Component を起点としたデータ取得戦略により、サーバー(SSR)とブラウザ(CSR)の環境差分を意識せず、単一のロジックで一貫したフェッチ処理を実現しています。このアプローチは、開発効率を大幅に向上させ、保守性の高いアプリケーション開発を強力に支援します。このプロジェクトが、同様の技術スタックを採用する際の参考になれば幸いです。