パスワードレスログインを実装したいが、何から手を付ければいいか分からない。
そう感じているなら、この記事で解決します。
magic link とは、ユーザーがメールアドレスを入力するとワンタイムリンクが届き、そのリンクをクリックするだけでログインできる仕組みです。パスワードの設定・管理が不要で、ユーザー体験がシンプルになります。
この記事で実装する流れ:
- Supabase ダッシュボードで Email Provider を有効化する
- Server Action(
signInWithOtp)でメールを送る /auth/callbackルートでコードをセッションに変換する
THINK YOU LAB でも同じスタック(Next.js App Router + @supabase/ssr + magic link)で本番稼働しています。この記事のコードは実際のプロダクションコードから引用しています。
magic link ログインの仕組み
ユーザーがメールアドレスを入力
↓ signInWithOtp を呼ぶ
↓ Supabase が magic link メールを送信
↓ ユーザーがリンクをクリック
↓ ブラウザが /auth/callback?code=XXXX にリダイレクト
↓ exchangeCodeForSession でセッション確立
↓ Cookie にセッションが保存される
↓ /member/onboarding にリダイレクト(ログイン完了)
Supabase Auth は PKCE(Proof Key for Code Exchange)フローを使います。signInWithOtp を呼ぶとブラウザ側でコードチャレンジが生成され、Supabase 側に保存されます。ユーザーがリンクをクリックして /auth/callback に戻ってきたとき、exchangeCodeForSession がコードとチャレンジを照合してセッションを発行します。
Supabase の設定(ダッシュボード側)
Email Provider を有効化する
- 左メニューから「Authentication」→「Providers」を開く
- 「Email」を展開して「Enable Email Provider」をオンにする
- 「Confirm email」を有効にする
- 「Save」をクリック
Site URL と Redirect URLs の設定
magic link のコールバック先 URL を Supabase 側に登録します。登録がないと「Invalid redirect URL」エラーで認証が失敗します。
「Authentication」→「URL Configuration」で設定:
| 設定項目 | 値の例 |
|---|---|
| Site URL | https://think-you-lab.vercel.app |
| Redirect URLs(本番) | https://think-you-lab.vercel.app/** |
| Redirect URLs(ローカル) | http://localhost:3000/** |
ワイルドカード(**)を使うと、/auth/callback?next=... のようなクエリパラメータ付きの URL もすべて許可されます。
Next.js 側の実装
@supabase/ssr のインストールと utils/supabase/server.ts
npm install @supabase/supabase-js @supabase/ssrサーバーサイドで Supabase クライアントを生成する utils/supabase/server.ts:
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";
export async function createClient() {
const cookieStore = await cookies();
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return cookieStore.getAll();
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
);
} catch {
// Server Component から呼ばれた場合は無視(middleware が担当)
}
},
},
}
);
}setAll の try/catch が重要です。Server Component(読み取り専用コンテキスト)から呼ばれた場合に Cookie への書き込みが失敗しますが、エラーを握りつぶすことで同じ createClient() をどこからでも安全に呼べるようにしています。
クライアントサイドでは utils/supabase/client.ts を使います:
import { createBrowserClient } from "@supabase/ssr";
export function createClient() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);
}Server Action で signInWithOtp を呼ぶ
// lms/app/(public)/login/actions.ts
"use server";
import { createClient } from "@/utils/supabase/server";
import { redirect } from "next/navigation";
export async function signInWithMagicLink(formData: FormData) {
const email = formData.get("email") as string;
const next = (formData.get("next") as string) || "/member/onboarding";
const supabase = await createClient();
const { error } = await supabase.auth.signInWithOtp({
email,
options: {
emailRedirectTo: `${process.env.NEXT_PUBLIC_SITE_URL}/auth/callback?next=${encodeURIComponent(next)}`,
},
});
if (error) {
redirect(`/login?error=${encodeURIComponent(error.message)}&next=${encodeURIComponent(next)}`);
}
redirect(`/login/check-email?email=${encodeURIComponent(email)}`);
}重要: emailRedirectTo は絶対 URLで指定してください。相対パスは動作しません。Vercel を使っている場合は NEXT_PUBLIC_SITE_URL 環境変数に本番 URL を設定しておきます。
ロジックの流れ:
formDataからメールアドレスと遷移先(next)を取得signInWithOtpでメールを送信- 送信後は
/login/check-emailにリダイレクトして「メールを確認してください」と表示
/auth/callback/route.ts で exchangeCodeForSession
// lms/app/auth/callback/route.ts
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";
import { NextResponse, type NextRequest } from "next/server";
function sanitizeNext(next: string | null) {
return next && next.startsWith("/") ? next : "/member/onboarding";
}
export async function GET(request: NextRequest) {
const { searchParams, origin } = new URL(request.url);
const code = searchParams.get("code");
const next = sanitizeNext(searchParams.get("next"));
if (!code) {
const message = searchParams.get("error_description") ?? "ログインリンクの確認に失敗しました。";
return NextResponse.redirect(`${origin}/login?error=${encodeURIComponent(message)}`);
}
const cookieStore = await cookies();
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() { return cookieStore.getAll(); },
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value, options }) => cookieStore.set(name, value, options));
},
},
}
);
const { error } = await supabase.auth.exchangeCodeForSession(code);
if (error) {
return NextResponse.redirect(`${origin}/login?error=${encodeURIComponent(error.message)}`);
}
return NextResponse.redirect(`${origin}${next}`);
}sanitizeNext() でオープンリダイレクト脆弱性を防いでいます:
/で始まる値 → そのまま通過(内部パス)- 外部 URL(
https://evil.comなど)→/member/onboardingに差し替え
フロントエンドのフォーム
// lms/app/(public)/login/page.tsx
import { signInWithMagicLink } from "./actions";
export default function LoginPage({ searchParams }: { searchParams: { next?: string } }) {
const next = searchParams.next || "/member/onboarding";
return (
<form action={signInWithMagicLink}>
<input type="email" name="email" placeholder="メールアドレス" required />
<input type="hidden" name="next" value={next} />
<button type="submit">メールでログイン</button>
</form>
);
}form の action に Server Action を渡すだけで動作します。クライアントサイドの JavaScript や useState は不要です。
ハマりポイントと対処法
リダイレクト先が localhost になる
症状: 本番環境でリンクをクリックすると http://localhost:3000/auth/callback に飛ばされる
原因: emailRedirectTo に http://localhost:3000 が指定されている
対処: NEXT_PUBLIC_SITE_URL 環境変数を本番 URL に設定して emailRedirectTo に使う。Vercel では Settings → Environment Variables に追加します。
Cookie が正しくセットされない
症状: /auth/callback でコード交換が成功しているのに、その後 getUser() が null を返す
原因: createServerClient に渡す cookies オブジェクトに setAll が実装されていない
対処: route.ts の setAll が正しく実装されているか確認。setAll がないと Supabase はセッション Cookie を保存できません。
"Invalid redirect URL" エラー
症状: magic link をクリックすると Supabase から Invalid redirect URL エラーが返る
原因: Supabase ダッシュボードの Redirect URLs にコールバック URL が登録されていない
対処: 「Authentication」→「URL Configuration」→「Redirect URLs」に本番 URL とローカル URL の両方を登録する。
Google OAuth との組み合わせ
magic link と Google OAuth を並列で提供する場合、signInWithGoogle を別の Server Action として定義します:
export async function signInWithGoogle(formData: FormData) {
const next = (formData.get("next") as string) || "/member/onboarding";
const supabase = await createClient();
const { data, error } = await supabase.auth.signInWithOAuth({
provider: "google",
options: {
redirectTo: `${process.env.NEXT_PUBLIC_SITE_URL}/auth/callback?next=${encodeURIComponent(next)}`,
},
});
if (error || !data.url) redirect(`/login?error=${encodeURIComponent(error?.message ?? "OAuth error")}`);
redirect(data.url);
}コールバック URL は magic link と同じ /auth/callback を使います。Google OAuth も PKCE フローを経由するため、exchangeCodeForSession で同様にセッション確立できます。
フロントエンドのボタン追加は1行のフォームだけ:
<form action={signInWithGoogle}>
<input type="hidden" name="next" value={next} />
<button type="submit">Google でログイン</button>
</form>まとめ
| 項目 | 実装場所 | ポイント |
|---|---|---|
| Email Provider 有効化 | Supabase ダッシュボード | Confirm email もオンに |
| Redirect URLs 登録 | Supabase URL Configuration | 本番・ローカル両方登録 |
| メール送信 | Server Action(signInWithOtp) | emailRedirectTo は絶対 URL |
| セッション確立 | /auth/callback/route.ts | exchangeCodeForSession |
| オープンリダイレクト防止 | sanitizeNext() | / で始まらない値を拒否 |
次のステップ:
- ログイン後のページ保護(middleware による認証ガード)
- Supabase 入門ガイド
「認証の実装で詰まって前に進めない」方向けに、Supabase Magic Link 認証の実装テンプレ(Next.js App Router 対応)を LINE で無料配布中です。
→ Supabase 認証テンプレ(Next.js 対応)を受け取る(LINE登録・無料)
LINE 公式アカウント
Supabase Auth 実装チートシート・magic link設定手順をLINEで配布中