メインコンテンツへスキップ
kt-tech.blog

【実装】Next.js + Resend + NextAuth v5 でメール認証付きサインアップフローを実装

実装17分で読めます

この記事でわかること

  • メールのみ入力 → 認証メール → パスワード設定 → 自動ログインの実装全体
  • VerificationToken を複数の経路で使うとき、すべてで期限を検証しないと穴になるという話
  • Google アカウントの自動リンクを安全にやるための 2 つの条件
  • NextAuth v5 で未認証メールをブロックするカスタムエラーの書き方

Next.js 15 以上(App Router)、NextAuth v5 + PrismaAdapter が設定済みであること。Resend のアカウントとドメイン認証が済んでいること。

メモ
Next.js App Router + NextAuth v5 + Resend でメール認証付きのサインアップフローを実装する方法を解説します。メール入力 → 認証メール → パスワード設定 → 自動ログインの流れです。

はじめに

ユーザー登録時にメールアドレスの認証を必須にしたいケースは多いです。しかし、NextAuth v5 にはメール認証のビルトイン機能がないため、自前で実装する必要があります。

この記事では Resend をメール送信に使い、認証トークンベースのメール認証フローを実装します。

ポイント
2026 年時点の補足: Auth.js(NextAuth)は 2025 年 9 月に Better Auth チームの管理下へ移り、セキュリティ修正と緊急対応のみを継続するメンテナンス体制になっています。既存の NextAuth v5 プロジェクトはそのまま使い続けられますが、これから認証基盤を新規に選ぶなら Better Auth も候補に入れてください。

全体のフロー

sequenceDiagram
    actor U as ユーザー
    participant F as フロント
    participant A as API
    participant M as Resend

    U->>F: メールアドレスを入力(パスワードなし)
    F->>A: POST /api/auth/signup
    A->>A: ユーザー作成 + 認証トークン生成
    A->>M: 認証メール送信
    M-->>U: メール到着
    U->>A: GET /api/auth/verify-email?token=...
    A->>A: トークン検証 + emailVerified を更新
    A-->>U: /set-password へリダイレクト
    U->>A: POST /api/auth/set-password
    A->>A: 期限を再検証 + ハッシュして保存
    A-->>F: email を返す
    F->>F: signIn() で自動ログイン

1. バリデーションスキーマの定義

フロントエンド用とAPI用のスキーマを分けつつ、共通部分を変数で切り出します。

TypeScript
// lib/validations/auth.ts
import { z } from "zod";

// サインアップ: メールのみ
export const signupSchema = z.object({
  email: z.email("正しいメールアドレスを入力してください"),
});

// パスワード設定: フロントエンド用(確認入力あり)
const passwordSchema = z.string().min(8, "8文字以上");

export const setPasswordSchema = z.object({
  password: passwordSchema,
  confirmPassword: z.string().min(1, "確認用パスワードを入力"),
}).refine((data) => data.password === data.confirmPassword, {
  message: "パスワードが一致しません",
  path: ["confirmPassword"],
});

// パスワード設定: API用(トークン含む)
export const setPasswordApiSchema = z.object({
  token: z.string().min(1),
  password: passwordSchema,
});
Tips
passwordSchema を変数として切り出すことで、フロントエンドと API で同じバリデーションルールを共有できます。

2. サインアップ API(メールのみ)

TypeScript
// app/api/auth/signup/route.ts
import { NextResponse } from "next/server";
import prisma from "@/lib/prisma";
import { sendVerificationEmail } from "@/services/email";

export async function POST(request: Request) {
  const { email } = await request.json();

  const existingUser = await prisma.user.findUnique({ where: { email } });
  if (existingUser) {
    if (existingUser.emailVerified) {
      return NextResponse.json(
        { error: "このメールアドレスは既に登録されています" },
        { status: 409 }
      );
    }
    // 未認証ユーザーは削除して再登録を許可
    await prisma.verificationToken.deleteMany({ where: { identifier: email } });
    await prisma.user.delete({ where: { email } });
  }

  // パスワードなしでユーザー作成
  await prisma.user.create({ data: { email } });

  // 認証トークン生成
  const token = crypto.randomUUID();
  const expires = new Date(Date.now() + 24 * 60 * 60 * 1000);

  await prisma.verificationToken.create({
    data: { identifier: email, token, expires },
  });

  const baseUrl = request.headers.get("origin") || "http://localhost:3000";

  try {
    await sendVerificationEmail(email, token, baseUrl);
  } catch {
    await prisma.verificationToken.delete({ where: { token } });
    await prisma.user.delete({ where: { email } });
    return NextResponse.json(
      { error: "メールの送信に失敗しました" },
      { status: 500 }
    );
  }

  return NextResponse.json({ success: true }, { status: 201 });
}
Tips
未認証の既存ユーザーは削除して再登録を許可する設計にしています。メール送信失敗時はユーザーとトークンをロールバックします。

3. メール認証 API

TypeScript
// app/api/auth/verify-email/route.ts
import { NextRequest, NextResponse } from "next/server";
import prisma from "@/lib/prisma";

export async function GET(request: NextRequest) {
  const token = request.nextUrl.searchParams.get("token");
  if (!token) {
    return NextResponse.redirect(new URL("/verify-email?error=missing-token", request.url));
  }

  const verificationToken = await prisma.verificationToken.findUnique({ where: { token } });
  if (!verificationToken) {
    return NextResponse.redirect(new URL("/verify-email?error=invalid-token", request.url));
  }

  if (verificationToken.expires < new Date()) {
    await prisma.verificationToken.delete({ where: { token } });
    return NextResponse.redirect(new URL("/verify-email?error=expired-token", request.url));
  }

  // emailVerified を更新
  await prisma.user.update({
    where: { email: verificationToken.identifier },
    data: { emailVerified: new Date() },
  });

  // パスワード設定ページへリダイレクト(トークンは再利用)
  return NextResponse.redirect(new URL(`/set-password?token=${token}`, request.url));
}

4. パスワード設定 API

TypeScript
// app/api/auth/set-password/route.ts
import { NextResponse } from "next/server";
import bcrypt from "bcryptjs";
import prisma from "@/lib/prisma";
import { setPasswordApiSchema } from "@/lib/validations/auth";

export async function POST(request: Request) {
  const body = await request.json();
  const parsed = setPasswordApiSchema.safeParse(body);
  if (!parsed.success) {
    return NextResponse.json({ error: parsed.error.issues[0]?.message }, { status: 400 });
  }

  const { token, password } = parsed.data;
  const verificationToken = await prisma.verificationToken.findUnique({ where: { token } });
  if (!verificationToken) {
    return NextResponse.json({ error: "無効なトークンです" }, { status: 400 });
  }

  // 有効期限を必ず検証する(verify-email 側と同じチェックをここでも行う)
  if (verificationToken.expires < new Date()) {
    await prisma.verificationToken.delete({ where: { token } });
    return NextResponse.json(
      { error: "トークンの有効期限が切れています。もう一度登録してください" },
      { status: 400 }
    );
  }

  const user = await prisma.user.findUnique({
    where: { email: verificationToken.identifier },
  });
  if (!user || user.password) {
    return NextResponse.json({ error: "無効なリクエストです" }, { status: 400 });
  }

  const hashedPassword = await bcrypt.hash(password, 12);
  await prisma.user.update({
    where: { email: verificationToken.identifier },
    data: { password: hashedPassword },
  });
  await prisma.verificationToken.delete({ where: { token } });

  // email を返して自動ログインに使用
  return NextResponse.json({ success: true, email: user.email });
}
重要
有効期限の検証を落とさないこと。 verify-email 側にだけ expires チェックを書いて set-password 側で省くと、認証メールのリンクを一度開いた人は 24 時間の期限を過ぎても無期限にパスワードを設定できてしまいます。トークンを受け取る経路が複数あるなら、すべての経路で同じ検証を通してください。

5. NextAuth でメール未認証をブロック

TypeScript
// lib/auth.ts
import NextAuth, { CredentialsSignin } from "next-auth";

// カスタムエラークラス
class EmailNotVerifiedError extends CredentialsSignin {
  code = "EMAIL_NOT_VERIFIED";
}

export const { handlers, signIn, signOut, auth } = NextAuth({
  // ...省略
  providers: [
    Credentials({
      async authorize(credentials) {
        const user = await prisma.user.findUnique({
          where: { email: credentials.email as string },
        });
        if (!user?.password) return null;

        const isValid = await bcrypt.compare(
          credentials.password as string,
          user.password
        );
        if (!isValid) return null;

        // メール未認証はエラー
        if (!user.emailVerified) {
          throw new EmailNotVerifiedError();
        }

        return { id: user.id, name: user.name, email: user.email };
      },
    }),
  ],
});
Tips
NextAuth v5 では CredentialsSignin を継承したカスタムエラークラスで code プロパティを設定すると、クライアント側で errorcode として受け取れます。

6. フロントエンドでのエラーハンドリング

ts-pattern を使うと、ログイン結果の分岐が綺麗に書けます:

TypeScript
import { match, P } from "ts-pattern";

const result = await signIn("credentials", {
  email, password, redirect: false,
});

match(result)
  .with({ error: P.nullish }, () => {
    router.push("/chat?logged_in=true");
  })
  .with(
    P.union({ code: "EMAIL_NOT_VERIFIED" }, { error: "EMAIL_NOT_VERIFIED" }),
    () => {
      setServerError("メールアドレスが認証されていません");
    }
  )
  .otherwise(() => {
    setServerError("メールアドレスまたはパスワードが正しくありません");
  });

7. Google アカウントの自動リンク

メールで登録済みのユーザーが後から Google ログインしようとすると OAuthAccountNotLinked エラーになります。これは NextAuth が「別プロバイダのアカウントを勝手に同じユーザーへ繋がない」ために意図的に出している保護です。無条件に自動リンクするとアカウント乗っ取りの入口になるので、リンクしてよい条件を明示したうえで signIn コールバックに実装します:

TypeScript
callbacks: {
  async signIn({ user, account, profile }) {
    if (account?.provider !== "google") return true;

    // 条件1: Google 側でメールが検証済みであること。
    // email_verified が false の Google アカウント(Workspace 等)が存在するため、
    // ここを省くと「他人のメールアドレスを名乗る Google アカウント」で
    // 既存ユーザーにログインできてしまう。
    if (profile?.email_verified !== true || !user.email) return false;

    const existingUser = await prisma.user.findUnique({
      where: { email: user.email },
      include: { accounts: true },
    });

    // 既存ユーザーがいなければ通常の新規作成フローに任せる
    if (!existingUser) return true;

    // 条件2: 自アプリ側でもメール確認済みであること。
    // 未確認のまま残っているレコードにはリンクさせない。
    if (!existingUser.emailVerified) return false;

    const hasGoogle = existingUser.accounts.some(
      (acc) => acc.provider === "google"
    );
    if (!hasGoogle) {
      await prisma.account.create({
        data: {
          userId: existingUser.id,
          type: account.type,
          provider: account.provider,
          providerAccountId: account.providerAccountId,
          access_token: account.access_token,
          expires_at: account.expires_at,
          token_type: account.token_type,
          scope: account.scope,
          id_token: account.id_token,
        },
      });
    }
    return true;
  },
}
注意
signInfalse を返すと NextAuth は AccessDenied としてエラーページへ遷移させます。「先にメールアドレスの確認を済ませてください」と案内できるよう、pages.error にカスタムページを用意しておくと親切です。

Tips

Tips
middleware.ts/verify-email/set-password を publicPaths に追加するのを忘れずに。未認証ユーザーがアクセスするページです。
Tips
HTML メールでは display: flex が使えません。レイアウトには table ベース、中央寄せには line-height + text-align: center を使います。
Tips
パスワード設定後の自動ログインでは signInredirect: false を使い、成功時に router.push で遷移します。URL パラメータで Toast を出すのがスマートです。

参考リンク

まとめ

  • メールのみ入力 → 認証メール → パスワード設定 → 自動ログインのフローを実装
  • CredentialsSignin を継承したカスタムエラーで未認証メールを制御
  • Google OAuth との共存は signIn コールバックでリンク。ただし Google 側の email_verified と自アプリ側の emailVerified を両方確認してから繋ぐ
  • バリデーションスキーマはフロントエンド/API 共通部分を切り出して重複を排除

更新履歴

  1. 全体フローをアスキーアートから mermaid のシーケンス図に差し替え。callout の色指定を他記事と揃え、識別子をインラインコードに統一。参考リンクに関連記事を追加。「この記事でわかること」「対象読者」「前提条件」を概要プロパティへ移動。
  2. set-password API にトークン有効期限(expires)の検証を追加(期限切れリンクでパスワード設定できる不具合を修正)。Google 自動アカウントリンクに email_verified / emailVerified の検証を追加。Auth.js のメンテナンス体制に関する注記を追加。

この記事のタグ