💡Next.js App Router + NextAuth v5 + Resend でメール認証付きのサインアップフローを実装する方法を解説します。メール入力 → 認証メール → パスワード設定 → 自動ログインの流れです。
はじめに
ユーザー登録時にメールアドレスの認証を必須にしたいケースは多いです。しかし、NextAuth v5 にはメール認証のビルトイン機能がないため、自前で実装する必要があります。
この記事では Resend をメール送信に使い、認証トークンベースのメール認証フローを実装します。
この記事でわかること
-
メールのみの新規登録 → 認証メール → パスワード設定 → 自動ログインの実装方法
-
Prisma の VerificationToken モデルを使った認証トークン管理
-
NextAuth v5 でのカスタムエラーハンドリング(未認証メールの制御)
-
Google OAuth と Credentials の共存・アカウントリンク
対象読者
前提条件
全体のフロー
1. メールアドレス入力(パスワードなし)
↓
2. サーバー: ユーザー作成 + 認証トークン生成 + メール送信
↓
3. ユーザー: メール内のリンクをクリック
↓
4. サーバー: トークン検証 + emailVerified 更新
↓
5. パスワード設定ページへリダイレクト
↓
6. パスワード設定 → 自動ログイン → チャットページ
1. バリデーションスキーマの定義
フロントエンド用とAPI用のスキーマを分けつつ、共通部分を変数で切り出します。
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"],
});
export const setPasswordApiSchema = z.object({
token: z.string().min(1),
password: passwordSchema,
});
💡passwordSchema を変数として切り出すことで、フロントエンドとAPIで同じバリデーションルールを共有できます。
2. サインアップ API(メールのみ)
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 });
}
💡未認証の既存ユーザーは削除して再登録を許可する設計にしています。メール送信失敗時はユーザーとトークンをロールバックします。
3. メール認証 API
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));
}
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
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 } });
return NextResponse.json({ success: true, email: user.email });
}
5. NextAuth でメール未認証をブロック
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 };
},
}),
],
});
💡NextAuth v5 では CredentialsSignin を継承したカスタムエラークラスで code プロパティを設定すると、クライアント側で error や code として受け取れます。
6. フロントエンドでのエラーハンドリング
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("メールアドレスまたはパスワードが正しくありません");
});
7. Google アカウントの自動リンク
メールで登録済みのユーザーが後から 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;
},
}
Tips
1️⃣middleware.ts で /verify-email と /set-password を publicPaths に追加するのを忘れずに。未認証ユーザーがアクセスするページです。
2️⃣HTML メールでは display: flex が使えません。レイアウトには table ベース、中央寄せには line-height + text-align: center を使います。
3️⃣パスワード設定後の自動ログインでは signIn の redirect: false を使い、成功時に router.push で遷移します。URL パラメータで Toast を出すのがスマートです。
参考リンク
まとめ
-
メールのみ入力 → 認証メール → パスワード設定 → 自動ログインのフローを実装
-
CredentialsSignin を継承したカスタムエラーで未認証メールを制御
-
Google OAuth との共存は signIn コールバックで自動リンク
-
バリデーションスキーマはフロントエンド/API 共通部分を切り出して重複を排除