

ユーザー登録時にメールアドレスの認証を必須にしたいケースは多いです。しかし、NextAuth v5 にはメール認証のビルトイン機能がないため、自前で実装する必要があります。
この記事では Resend をメール送信に使い、認証トークンベースのメール認証フローを実装します。
メールのみの新規登録 → 認証メール → パスワード設定 → 自動ログインの実装方法
Prisma の VerificationToken モデルを使った認証トークン管理
NextAuth v5 でのカスタムエラーハンドリング(未認証メールの制御)
Google OAuth と Credentials の共存・アカウントリンク
Next.js App Router + NextAuth v5 で認証を実装中の方
メール認証フローを自前で構築したい方
Next.js 15+ (App Router)
NextAuth v5 + PrismaAdapter 設定済み
Resend アカウント + ドメイン認証済み
1. メールアドレス入力(パスワードなし)
↓
2. サーバー: ユーザー作成 + 認証トークン生成 + メール送信
↓
3. ユーザー: メール内のリンクをクリック
↓
4. サーバー: トークン検証 + emailVerified 更新
↓
5. パスワード設定ページへリダイレクト
↓
6. パスワード設定 → 自動ログイン → チャットページ
フロントエンド用とAPI用のスキーマを分けつつ、共通部分を変数で切り出します。
// 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,
});
// 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 });
}
// 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));
}
// 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 });
}
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 });
}
// 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 };
},
}),
],
});
ts-pattern を使うと、ログイン結果の分岐が綺麗に書けます:
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("メールアドレスまたはパスワードが正しくありません");
});
メールで登録済みのユーザーが後から Google ログインしようとすると OAuthAccountNotLinked エラーになります。signIn コールバックで自動リンクを実装します:
callbacks: {
async signIn({ user, account }) {
if (account?.provider === "google" && user.email) {
const existingUser = await prisma.user.findUnique({
where: { email: user.email },
include: { accounts: true },
});
if (existingUser) {
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;
},
}
メールのみ入力 → 認証メール → パスワード設定 → 自動ログインのフローを実装
CredentialsSignin を継承したカスタムエラーで未認証メールを制御
Google OAuth との共存は signIn コールバックで自動リンク
バリデーションスキーマはフロントエンド/API 共通部分を切り出して重複を排除