空雲 Blog

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

publication: 2026/01/23
update:2026/01/25

{"width":"1024px","height":"559px"}


概要🔗


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)"]




目次🔗


  1. プロジェクト概要
  2. インストールとセットアップ
  3. スクリプト一覧
  4. プロジェクト構成
  5. アーキテクチャと実装詳細
  6. まとめ




プロジェクト概要🔗


動作サンプル & リポジトリ🔗



アプリケーション機能🔗


  • 投稿管理:
    • 投稿リストの表示(ホームページ)
    • 投稿詳細の表示(リードオンリービュー)
    • 新規投稿の作成(タイトル、コンテンツ、公開ステータス、カテゴリ)
    • 既存の投稿の編集・削除
    • 下書きシステム(公開/非公開投稿)
  • ユーザーシステム:
    • ユーザー切り替え/認証(デモ用に簡略化/シミュレート)
    • ユーザーごとの投稿数表示
    • ログイン時の自動リダイレクト
    • ユーザーロール管理
  • カテゴリ管理:
    • 投稿へのカテゴリ設定
  • 管理機能:
    • データのシード(初期化)リセット機能(ヘッダーボタン)


技術スタック🔗


  • フレームワーク: 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(ローカル開発用データベース)


手順🔗


  1. 依存関係のインストール

    1pnpm install
  2. データベースの起動
    Docker Compose を使用して PostgreSQL を起動します。

    1pnpm docker
  3. データベースのセットアップ
    スキーマの初期化とシードデータのロード(マイグレーション & シード)を行います。

    1pnpm drizzle:reset
  4. 開発サーバーの起動

    1pnpm dev
  5. アプリケーションへのアクセス
    ブラウザで http://localhost:3000 を開きます。




スクリプト一覧🔗


開発や運用で使用する主要なコマンドです。


コマンド説明
devNext.js 開発サーバーを起動します。
dockerPostgreSQL コンテナを起動します。
build本番用にアプリケーションをビルドします。
start本番サーバーを起動します。
drizzle:generateスキーマ変更に基づいて SQL マイグレーションを生成します。
drizzle:migrate開発環境のデータベースにマイグレーションを適用します。
drizzle:migrate:production本番環境のデータベースにマイグレーションを適用します。
drizzle:seedテストデータをデータベースにシードします。
drizzle:resetデータベースをリセットします(マイグレーション + シード)。
lintESLint を実行します。
graphql:schemaGraphQL スキーマをエクスポートします。
graphql:codegenGraphQL の変更を監視し、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";
11
12// Tables to exclude from GraphQL schema generation
13// Junction tables like "postsToCategories" are typically excluded
14const EXCLUDE_TABLES: Array<keyof typeof relations> = ["postsToCategories"];
15
16export interface PothosTypes {
17 DrizzleRelations: typeof relations;
18 Context: HonoContext<Context>;
19}
20
21/**
22 * Initialize Pothos Schema Builder with plugins:
23 * - DrizzlePlugin: Integrates Drizzle ORM with Pothos
24 * - PothosDrizzleGeneratorPlugin: Automatically generates GraphQL schema from Drizzle schema
25 */
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 models
37 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 executable
41 // This guards against unauthorized mutations by requiring authentication
42 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 operations
49 // Excludes auto-managed system fields from user input
50 inputFields: () => {
51 return { exclude: ["createdAt", "updatedAt"] };
52 },
53 },
54 // Model-specific configuration
55 models: {
56 posts: {
57 // Automatically inject data during create/update operations
58 // Sets authorId to the current authenticated user
59 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 type
65 // This implements row-level security
66 where: ({ ctx, operation }) => {
67 // For queries (findMany, findFirst, count): show published posts or user's own posts
68 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 posts
74 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";
11
12// Secret key for JWT token signing and verification
13const SECRET = getEnvVariable("SECRET");
14
15// JWT token expiration time: 400 days in seconds
16const TOKEN_MAX_AGE = 60 * 60 * 24 * 400;
17
18// Cookie configuration shared across authentication operations
19const COOKIE_OPTIONS = {
20 httpOnly: true,
21 sameSite: "strict" as const,
22 path: "/",
23};
24
25builder.queryType({
26 fields: (t) => ({
27 // Returns the currently authenticated user
28 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});
38
39/**
40 * Authentication mutations
41 * Provides user authentication functionality including sign-in, sign-out, and current user retrieval
42 */
43builder.mutationType({
44 fields: (t) => ({
45 // Authenticates a user by email and sets JWT cookie
46 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 cookie
56 setCookie(ctx, "auth-token", "", { ...COOKIE_OPTIONS, maxAge: 0 });
57 } else {
58 // Authentication successful: generate JWT and set secure cookie
59 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 cookie
71 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 seeds
80 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});
101
102export 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";
14
15// Secret key for JWT verification
16const SECRET = getEnvVariable("SECRET");
17
18// Cookie name for authentication token
19const AUTH_TOKEN_COOKIE = "auth-token";
20
21// Apollo Explorer introspection interval (10 seconds)
22const INTROSPECTION_INTERVAL = 10000;
23
24// Sample query generation depth
25const QUERY_GENERATION_DEPTH = 1;
26
27/**
28 * Middleware to extract and verify JWT token from cookies
29 * Sets the authenticated user in the request context
30 */
31const authMiddleware = async (
32 c: HonoContext<Context>,
33 next: () => Promise<void>
34) => {
35 const cookies = getCookie(c);
36 const token = cookies[AUTH_TOKEN_COOKIE] ?? "";
37
38 /**
39 * Verify JWT token and extract user information
40 * If verification fails (invalid/expired token), user will be undefined
41 */
42 const user = await jwtVerify(token, new TextEncoder().encode(SECRET))
43 .then(
44 (data) => data.payload.user as typeof relations.users.table.$inferSelect
45 )
46 .catch(() => undefined);
47 // Store user in request context
48 const context = getContext<Context>();
49 context.set("user", user);
50
51 return next();
52};
53
54/**
55 * Initialize Hono application with custom context type
56 * The Context type provides type-safe access to user authentication state
57 */
58export const app = new Hono<Context>();
59
60/**
61 * Enable context storage middleware
62 * This allows access to the request context from anywhere in the application
63 */
64app.use(contextStorage());
65
66/**
67 * Apollo Explorer endpoint
68 * Provides an interactive GraphQL IDE for testing queries and mutations
69 */
70app.get("*", (c) => {
71 return c.html(
72 explorer({
73 initialState: {
74 // Auto-generate sample GraphQL operations from the schema
75 document: generate(schema, QUERY_GENERATION_DEPTH),
76 },
77 // GraphQL endpoint URL for the explorer to connect to
78 endpointUrl: c.req.url,
79 // Automatically refresh schema periodically
80 introspectionInterval: INTROSPECTION_INTERVAL,
81 })
82 );
83});
84
85/**
86 * GraphQL endpoint
87 * Handles GraphQL queries and mutations via POST requests
88 * Authentication is handled by the authMiddleware
89 */
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";
3
4export async function POST(request: Request) {
5 return app.fetch(request);
6}
7
8export 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-querygenerate 関数を使用し、Apollo Explorer の初期状態としてサンプルクエリを自動セットしています。これにより、開発者はブラウザですぐに動作確認を行えます。


認証と認可のフロー🔗


セキュリティと UX を両立させるため、堅牢な認証フローと、透過的な SSR 対応を実装しています。


A. 認証 (Authentication)🔗


サーバーサイドでの身元確認プロセスです。


  1. JWT 生成: signIn Mutation でユーザー検証後、署名付き JWT を生成。
  2. Cookie 保存: HttpOnly, SameSite: Strict 属性を持つ Cookie に保存(XSS 対策)。
  3. リクエスト検証: 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 が自動付与されないため、以下の仕組みで状態を引き継ぎます。


  1. Layout (RSC): Cookie を読み取り、暗号化。
  2. UrqlProvider (Client): 暗号化されたトークンを Props として受け取る。
  3. 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";
6
7const context = { additionalTypenames: ["User"] };
8
9export default function Users() {
10 const [{ data, fetching, error }, executeQuery] = useFindManyUserQuery({
11 context,
12 });
13 const signIn = useSignIn();
14 const router = useRouter();
15
16 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 &larr; Back to Home
22 </Link>
23
24 <div className="flex items-center gap-3 mb-8">
25 <h1 className="text-3xl font-bold">Users</h1>
26 <button
27 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>
41
42 {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 <div
55 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 <h2
69 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} posts
77 </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 <button
87 onClick={async () => {
88 await signIn(user.email);
89 router.push("/");
90 }}
91 className="btn btn-primary btn-sm"
92 >
93 Sign In
94 </button>
95 </div>
96 </div>
97 ))}
98 </div>
99 )}
100
101 {!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 の活用🔗


  1. データプリフェッチ: SSR 中に実行されたクエリ結果を収集。
  2. ハイドレーション: 収集したデータを HTML に埋め込み、クライアントでの再取得を回避。
  3. 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";
12
13const isServerSide = typeof window === "undefined";
14const endpoint = "/api/graphql";
15
16const options: RetryExchangeOptions = {
17 maxDelayMs: 3000,
18 randomDelay: false,
19};
20
21export 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 && token
42 ? `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-deps
56 }, [nextSSRExchange, session, cacheState]);
57 return (
58 <Provider value={client}>
59 <NextSSRProvider>{children}</NextSSRProvider>
60 </Provider>
61 );
62};
63
64export const useUrqlCache = () => {
65 return useSelector((state: { urqlCache: object }) => state.urqlCache);
66};
67
68export const useClearUrqlCache = () => {
69 const dispatch = useDispatch<{ urqlCache: object }>();
70
71 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";
10
11const geistSans = Geist({
12 variable: "--font-geist-sans",
13 subsets: ["latin"],
14});
15
16const geistMono = Geist_Mono({
17 variable: "--font-geist-mono",
18 subsets: ["latin"],
19});
20
21async 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}
27
28export default async function RootLayout({
29 children,
30}: Readonly<{
31 children: React.ReactNode;
32}>) {
33 // Get token from cookies
34 const token = await cookies().then((v) => v.get("auth-token")?.value);
35 // Get origin for GraphQL client
36 const host = await getOrigin();
37 // Verify user token
38 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));
47
48 return (
49 <StoreProvider
50 initState={{
51 user,
52 }}
53 >
54 <UrqlProvider
55 host={host}
56 // Pass encrypted token to Client Component
57 token={token && encrypt(token, process.env.secret ?? "")}
58 >
59 <html lang="en">
60 <body
61 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";
5
6const context = { additionalTypenames: ["Post"] };
7
8export default function Home() {
9 const [{ data, error, fetching }, executeQuery] = useFindManyPostQuery({
10 variables: { orderBy: [{ createdAt: OrderBy.Desc }] },
11 context,
12 });
13
14 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 <button
22 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 Post
38 </Link>
39 </div>
40
41 {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 )}
57
58 {!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)の環境差分を意識せず、単一のロジックで一貫したフェッチ処理を実現しています。このアプローチは、開発効率を大幅に向上させ、保守性の高いアプリケーション開発を強力に支援します。このプロジェクトが、同様の技術スタックを採用する際の参考になれば幸いです。