セキュリティ

next-safe-action 導入ガイド - Next.js 16 + Better Auth + Drizzle 環境での型安全なサーバーアクション

Next.js 16 アプリケーションに next-safe-action を導入し、Better Auth と Drizzle ORM と組み合わせて型安全なサーバーアクションを実装する方法を解説します。

next-safe-action とは

next-safe-action は、Next.js App Router のサーバーアクションに型安全性とバリデーションを追加するライブラリです。Zod スキーマによる入力バリデーション、ミドルウェアによる認証・認可、そしてクライアント側での使いやすいフックを提供します。

主な特徴

  • 型安全性: 入力から出力まで完全な TypeScript 型推論
  • Zod バリデーション: スキーマベースの入力検証
  • ミドルウェア: 認証・ログ・エラーハンドリングの共通化
  • React Hooks: useAction, useOptimisticAction などのクライアントフック
  • エラーハンドリング: バリデーションエラーとサーバーエラーの統一的な処理

現在のアプリケーション構成

このガイドは以下の技術スタックを前提としています:

  • Next.js 16 (App Router)
  • React 19
  • Better Auth (認証)
  • Drizzle ORM + Turso (データベース)
  • Zod (バリデーション)
  • TypeScript

現在のプロジェクト構造:

/src
├── /app                    # App Router
│   └── /api/auth/[...all]  # Better Auth ハンドラ
├── /components             # React コンポーネント
├── /lib
│   ├── auth.ts             # Better Auth サーバー設定
│   └── auth-client.ts      # クライアント側認証
├── /db
│   ├── /schema             # Drizzle スキーマ
│   └── index.ts            # DB クライアント
└── env.ts                  # 環境変数 (t3-env)

インストール

npm install next-safe-action zod

Zod は既にプロジェクトで使用されている場合はスキップしてください(env.ts で使用中)。

基本セットアップ

1. Action Client の作成

src/lib/safe-action.ts を作成します:

import { createSafeActionClient, DEFAULT_SERVER_ERROR_MESSAGE } from "next-safe-action";
import { z } from "zod";

// カスタムエラークラス
export class ActionError extends Error {
  constructor(message: string) {
    super(message);
    this.name = "ActionError";
  }
}

// ベースクライアント
export const actionClient = createSafeActionClient({
  // メタデータスキーマの定義(オプション)
  defineMetadataSchema() {
    return z.object({
      actionName: z.string(),
    });
  },

  // サーバーエラーのハンドリング
  // 第2引数 utils で clientInput, metadata, ctx などにアクセス可能
  handleServerError(e, utils) {
    const { clientInput, metadata, ctx } = utils;
    console.error("Action error:", e.message, { actionName: metadata?.actionName });

    // カスタムエラーはそのままメッセージを返す
    if (e instanceof ActionError) {
      return e.message;
    }

    // その他のエラーは汎用メッセージ
    return DEFAULT_SERVER_ERROR_MESSAGE;
  },
});

2. Better Auth との統合(認証ミドルウェア)

Better Auth のセッションを使用する認証付きクライアントを作成します:

// src/lib/safe-action.ts に追加

import { auth } from "@/lib/auth";
import { headers } from "next/headers";

// 認証付きクライアント
export const authActionClient = actionClient.use(async ({ next }) => {
  // Better Auth からセッションを取得
  const session = await auth.api.getSession({
    headers: await headers(),
  });

  if (!session) {
    throw new ActionError("ログインが必要です");
  }

  // コンテキストにユーザー情報を追加
  return next({
    ctx: {
      userId: session.user.id,
      user: session.user,
      session: session.session,
    },
  });
});

3. 型定義のエクスポート

型推論のための型定義を追加します:

// src/lib/safe-action.ts に追加

import type { InferSafeActionFnInput, InferSafeActionFnResult } from "next-safe-action";

// 認証コンテキストの型
export type AuthContext = {
  userId: string;
  user: {
    id: string;
    email: string;
    name: string;
    image?: string | null;
  };
  session: {
    id: string;
    userId: string;
    expiresAt: Date;
  };
};

// アクションの入力・出力型を推論するヘルパー
export type ActionInput<T extends (...args: any) => any> = InferSafeActionFnInput<T>;
export type ActionResult<T extends (...args: any) => any> = InferSafeActionFnResult<T>;

完全なセットアップファイル

// src/lib/safe-action.ts

import {
  createSafeActionClient,
  DEFAULT_SERVER_ERROR_MESSAGE,
} from "next-safe-action";
import type {
  InferSafeActionFnInput,
  InferSafeActionFnResult,
} from "next-safe-action";
import { z } from "zod";
import { headers } from "next/headers";
import { auth } from "@/lib/auth";

// カスタムエラークラス
export class ActionError extends Error {
  constructor(message: string) {
    super(message);
    this.name = "ActionError";
  }
}

// ベースクライアント(認証不要なアクション用)
export const actionClient = createSafeActionClient({
  defineMetadataSchema() {
    return z.object({
      actionName: z.string(),
    });
  },
  handleServerError(e, utils) {
    const { metadata } = utils;
    console.error("Action error:", e.message, { actionName: metadata?.actionName });

    if (e instanceof ActionError) {
      return e.message;
    }

    return DEFAULT_SERVER_ERROR_MESSAGE;
  },
})
  // ロギングミドルウェア(開発時のデバッグ用)
  .use(async ({ next, clientInput, metadata }) => {
    if (process.env.NODE_ENV === "development") {
      console.log("Action:", metadata.actionName);
      console.log("Input:", clientInput);
    }

    const startTime = performance.now();
    const result = await next();
    const endTime = performance.now();

    if (process.env.NODE_ENV === "development") {
      console.log("Duration:", endTime - startTime, "ms");
    }

    return result;
  });

// 認証付きクライアント(ログインが必要なアクション用)
export const authActionClient = actionClient.use(async ({ next }) => {
  const session = await auth.api.getSession({
    headers: await headers(),
  });

  if (!session) {
    throw new ActionError("ログインが必要です");
  }

  return next({
    ctx: {
      userId: session.user.id,
      user: session.user,
      session: session.session,
    },
  });
});

// 型エクスポート
export type AuthContext = {
  userId: string;
  user: typeof auth.$Infer.Session.user;
  session: typeof auth.$Infer.Session.session;
};

export type ActionInput<T extends (...args: any) => any> =
  InferSafeActionFnInput<T>;
export type ActionResult<T extends (...args: any) => any> =
  InferSafeActionFnResult<T>;

実践例:ブックマーク機能の実装

現在のプロジェクトには未実装のブックマーク機能があります。これを next-safe-action で実装してみましょう。

1. サーバーアクションの作成

// src/actions/bookmark.ts
"use server";

import { z } from "zod";
import { revalidatePath } from "next/cache";
import { eq, and } from "drizzle-orm";
import { authActionClient, ActionError } from "@/lib/safe-action";
import { db } from "@/db";
import { articleBookmarks } from "@/db/schema";

// 入力スキーマ
const bookmarkInputSchema = z.object({
  articleSlug: z.string().min(1, "記事スラッグは必須です"),
});

// ブックマーク追加アクション
export const addBookmark = authActionClient
  .metadata({ actionName: "addBookmark" })
  .inputSchema(bookmarkInputSchema)
  .action(async ({ parsedInput: { articleSlug }, ctx: { userId } }) => {
    // 既存のブックマークをチェック
    const existing = await db.query.articleBookmarks.findFirst({
      where: and(
        eq(articleBookmarks.userId, userId),
        eq(articleBookmarks.articleSlug, articleSlug)
      ),
    });

    if (existing) {
      throw new ActionError("既にブックマークしています");
    }

    // ブックマークを追加
    await db.insert(articleBookmarks).values({
      userId,
      articleSlug,
    });

    revalidatePath(`/articles/${articleSlug}`);

    return { bookmarked: true };
  });

// ブックマーク削除アクション
export const removeBookmark = authActionClient
  .metadata({ actionName: "removeBookmark" })
  .inputSchema(bookmarkInputSchema)
  .action(async ({ parsedInput: { articleSlug }, ctx: { userId } }) => {
    await db
      .delete(articleBookmarks)
      .where(
        and(
          eq(articleBookmarks.userId, userId),
          eq(articleBookmarks.articleSlug, articleSlug)
        )
      );

    revalidatePath(`/articles/${articleSlug}`);

    return { bookmarked: false };
  });

// ブックマーク状態取得アクション
export const getBookmarkStatus = authActionClient
  .metadata({ actionName: "getBookmarkStatus" })
  .inputSchema(bookmarkInputSchema)
  .action(async ({ parsedInput: { articleSlug }, ctx: { userId } }) => {
    const bookmark = await db.query.articleBookmarks.findFirst({
      where: and(
        eq(articleBookmarks.userId, userId),
        eq(articleBookmarks.articleSlug, articleSlug)
      ),
    });

    return { bookmarked: !!bookmark };
  });

// トグルアクション(追加/削除を自動判定)
export const toggleBookmark = authActionClient
  .metadata({ actionName: "toggleBookmark" })
  .inputSchema(bookmarkInputSchema)
  .action(async ({ parsedInput: { articleSlug }, ctx: { userId } }) => {
    const existing = await db.query.articleBookmarks.findFirst({
      where: and(
        eq(articleBookmarks.userId, userId),
        eq(articleBookmarks.articleSlug, articleSlug)
      ),
    });

    if (existing) {
      await db
        .delete(articleBookmarks)
        .where(
          and(
            eq(articleBookmarks.userId, userId),
            eq(articleBookmarks.articleSlug, articleSlug)
          )
        );

      revalidatePath(`/articles/${articleSlug}`);
      return { bookmarked: false };
    }

    await db.insert(articleBookmarks).values({
      userId,
      articleSlug,
    });

    revalidatePath(`/articles/${articleSlug}`);
    return { bookmarked: true };
  });

2. クライアントコンポーネントでの使用

// src/components/bookmark-button-with-action.tsx
"use client";

import { useState, useEffect } from "react";
import { useAction } from "next-safe-action/hooks";
import { Bookmark } from "lucide-react";
import { motion } from "motion/react";
import { toggleBookmark, getBookmarkStatus } from "@/actions/bookmark";
import { cn } from "@/lib/utils";

interface BookmarkButtonProps {
  articleSlug: string;
  initialBookmarked?: boolean;
  onAuthRequired?: () => void;
}

export function BookmarkButtonWithAction({
  articleSlug,
  initialBookmarked = false,
  onAuthRequired,
}: BookmarkButtonProps) {
  const [isBookmarked, setIsBookmarked] = useState(initialBookmarked);

  // ブックマークトグルアクション
  const { execute, isPending, result } = useAction(toggleBookmark, {
    onSuccess: ({ data }) => {
      if (data) {
        setIsBookmarked(data.bookmarked);
      }
    },
    onError: ({ error }) => {
      // 認証エラーの場合はログインモーダルを表示
      if (error.serverError === "ログインが必要です") {
        onAuthRequired?.();
        return;
      }
      console.error("Bookmark error:", error);
    },
  });

  // 初期状態の取得
  const { execute: fetchStatus } = useAction(getBookmarkStatus, {
    onSuccess: ({ data }) => {
      if (data) {
        setIsBookmarked(data.bookmarked);
      }
    },
  });

  useEffect(() => {
    fetchStatus({ articleSlug });
  }, [articleSlug, fetchStatus]);

  const handleClick = () => {
    execute({ articleSlug });
  };

  return (
    <motion.button
      onClick={handleClick}
      disabled={isPending}
      className={cn(
        "p-2 rounded-full transition-colors",
        isBookmarked
          ? "bg-primary text-primary-foreground"
          : "bg-muted hover:bg-muted/80"
      )}
      whileTap={{ scale: 0.95 }}
      aria-label={isBookmarked ? "ブックマークを解除" : "ブックマークに追加"}
    >
      <Bookmark
        className={cn("h-5 w-5", isBookmarked && "fill-current")}
      />
    </motion.button>
  );
}

3. エラーハンドリングの詳細

useAction フックは様々な状態とコールバックを提供します:

const {
  execute,         // アクションを実行(Promise を返さない)
  executeAsync,    // アクションを実行(Promise を返す)
  input,           // execute に渡された入力値
  result,          // 実行結果
  reset,           // 状態をリセット
  status,          // 'idle' | 'executing' | 'hasSucceeded' | 'hasErrored'
  isIdle,          // 待機中
  isTransitioning, // トランジション中(useTransition)
  isExecuting,     // 実行中
  isPending,       // 実行中またはトランジション中
  hasSucceeded,    // 成功
  hasErrored,      // エラー
  hasNavigated,    // next/navigation が呼び出された
} = useAction(myAction, {
  onExecute: ({ input }) => {
    // 実行開始時
  },
  onSuccess: ({ data, input }) => {
    // 成功時の処理
  },
  onError: ({ error, input }) => {
    // エラー時の処理
    // error.serverError: サーバーエラーメッセージ
    // error.validationErrors: バリデーションエラー
  },
  onSettled: ({ result, input }) => {
    // 成功・失敗に関わらず実行
  },
});

バリデーションエラーの返却

サーバーアクション内でバリデーションエラーを返す場合、returnValidationErrors を使用します。この関数は内部で throw するため、呼び出し後のコードは実行されません:

import { returnValidationErrors } from "next-safe-action";

const postInputSchema = z.object({
  title: z.string().min(1).max(100),
  content: z.string().min(10),
});

export const createPost = authActionClient
  .metadata({ actionName: "createPost" })
  .inputSchema(postInputSchema)
  .action(async ({ parsedInput: { title, content }, ctx: { userId } }) => {
    // カスタムバリデーション
    const existingPost = await db.query.posts.findFirst({
      where: eq(posts.title, title),
    });

    if (existingPost) {
      // 特定フィールドにエラーを返す(内部で throw される)
      returnValidationErrors(postInputSchema, {
        title: { _errors: ["このタイトルは既に使用されています"] },
      });
    }

    // ルートレベルのエラー
    if (content.includes("禁止ワード")) {
      returnValidationErrors(postInputSchema, {
        _errors: ["不適切な内容が含まれています"],
      });
    }

    // 正常処理
    const post = await db.insert(posts).values({ title, content, userId });
    return { success: true, postId: post.id };
  });

フォームとの統合

標準フォームでの使用(FormData)

<form action={execute}> を使用する場合、フォームは FormData を送信します。これを処理するには zod-form-data パッケージを使用します:

npm install zod-form-data

サーバーアクション側:

// src/actions/post.ts
"use server";

import { actionClient } from "@/lib/safe-action";
import { z } from "zod";
import { zfd } from "zod-form-data";

// FormData 用のスキーマ
const formDataSchema = zfd.formData({
  title: zfd.text(z.string().min(1, "タイトルは必須です").max(100)),
  content: zfd.text(z.string().min(10, "内容は10文字以上必要です")),
});

export const createPostFormAction = actionClient
  .metadata({ actionName: "createPostFormAction" })
  .inputSchema(formDataSchema)
  .action(async ({ parsedInput: { title, content } }) => {
    // データベースに保存
    return { success: true, title };
  });

クライアント側:

"use client";

import { useAction } from "next-safe-action/hooks";
import { createPostFormAction } from "@/actions/post";

export function CreatePostForm() {
  const { execute, result, isPending } = useAction(createPostFormAction);

  return (
    <form action={execute}>
      <div>
        <input type="text" name="title" placeholder="タイトル" required />
        {result.validationErrors?.title?._errors?.map((error, i) => (
          <p key={i} className="text-red-500 text-sm">{error}</p>
        ))}
      </div>

      <div>
        <textarea name="content" placeholder="内容" required />
        {result.validationErrors?.content?._errors?.map((error, i) => (
          <p key={i} className="text-red-500 text-sm">{error}</p>
        ))}
      </div>

      {result.validationErrors?._errors?.map((error, i) => (
        <p key={i} className="text-red-500">{error}</p>
      ))}

      {result.serverError && (
        <p className="text-red-500">{result.serverError}</p>
      )}

      <button type="submit" disabled={isPending}>
        {isPending ? "送信中..." : "投稿する"}
      </button>
    </form>
  );
}

JavaScript オブジェクトを直接渡す場合

通常の Zod スキーマを使用して、execute を手動で呼び出す方法:

"use client";

import { useState } from "react";
import { useAction } from "next-safe-action/hooks";
import { createPost } from "@/actions/post";

export function CreatePostFormManual() {
  const [title, setTitle] = useState("");
  const [content, setContent] = useState("");
  const { execute, result, isPending } = useAction(createPost);

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    execute({ title, content });
  };

  return (
    <form onSubmit={handleSubmit}>
      <div>
        <input
          type="text"
          value={title}
          onChange={(e) => setTitle(e.target.value)}
          placeholder="タイトル"
        />
        {result.validationErrors?.title?._errors?.map((error, i) => (
          <p key={i} className="text-red-500 text-sm">{error}</p>
        ))}
      </div>

      <div>
        <textarea
          value={content}
          onChange={(e) => setContent(e.target.value)}
          placeholder="内容"
        />
        {result.validationErrors?.content?._errors?.map((error, i) => (
          <p key={i} className="text-red-500 text-sm">{error}</p>
        ))}
      </div>

      {result.serverError && (
        <p className="text-red-500">{result.serverError}</p>
      )}

      <button type="submit" disabled={isPending}>
        {isPending ? "送信中..." : "投稿する"}
      </button>
    </form>
  );
}

React Hook Form との統合

npm install react-hook-form @hookform/resolvers @next-safe-action/adapter-react-hook-form
"use client";

import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useHookFormAction } from "@next-safe-action/adapter-react-hook-form/hooks";
import { z } from "zod";
import { createPost } from "@/actions/post";

const formSchema = z.object({
  title: z.string().min(1, "タイトルは必須です").max(100),
  content: z.string().min(10, "内容は10文字以上必要です"),
});

export function CreatePostFormWithRHF() {
  const { form, action, handleSubmitWithAction } = useHookFormAction(
    createPost,
    zodResolver(formSchema),
    {
      formProps: {
        defaultValues: {
          title: "",
          content: "",
        },
      },
      actionProps: {
        onSuccess: ({ data }) => {
          form.reset();
          // 成功時の処理
        },
      },
    }
  );

  return (
    <form onSubmit={handleSubmitWithAction}>
      <div>
        <input {...form.register("title")} placeholder="タイトル" />
        {form.formState.errors.title && (
          <p className="text-red-500">{form.formState.errors.title.message}</p>
        )}
      </div>

      <div>
        <textarea {...form.register("content")} placeholder="内容" />
        {form.formState.errors.content && (
          <p className="text-red-500">{form.formState.errors.content.message}</p>
        )}
      </div>

      <button type="submit" disabled={action.isPending}>
        {action.isPending ? "送信中..." : "投稿する"}
      </button>
    </form>
  );
}

Optimistic Updates

楽観的更新を使用してユーザー体験を向上させる:

"use client";

import { useOptimisticAction } from "next-safe-action/hooks";
import { toggleBookmark } from "@/actions/bookmark";

export function OptimisticBookmarkButton({ articleSlug, initialBookmarked }) {
  const { execute, optimisticState } = useOptimisticAction(toggleBookmark, {
    currentState: { bookmarked: initialBookmarked },
    updateFn: (state, input) => ({
      bookmarked: !state.bookmarked,
    }),
  });

  return (
    <button onClick={() => execute({ articleSlug })}>
      {optimisticState.bookmarked ? "ブックマーク済み" : "ブックマーク"}
    </button>
  );
}

ディレクトリ構成の推奨

/src
├── /actions              # サーバーアクション
│   ├── bookmark.ts
│   ├── post.ts
│   └── user.ts
├── /lib
│   ├── safe-action.ts    # Action Client 設定
│   ├── auth.ts
│   └── auth-client.ts
└── /components
    └── bookmark-button-with-action.tsx

まとめ

next-safe-action を導入することで:

  1. 型安全性: Zod スキーマによる入力バリデーションと TypeScript 型推論
  2. 認証の統一: Better Auth との統合でセッション管理を一元化
  3. エラーハンドリング: バリデーションエラーとサーバーエラーの統一的な処理
  4. クライアント統合: useAction フックによる簡単な状態管理
  5. コード整理: サーバーアクションを /actions ディレクトリに集約

これにより、従来の API Routes を使用する方法よりも型安全で保守性の高いコードを書くことができます。

参考リンク