概要
Next.js App Router で Supabase Auth を使いメール/パスワード認証を実装する。
@supabase/ssr パッケージを使って Server Components・Server Actions・Middleware でセッションを共有する構成。
インストール
npm install @supabase/supabase-js @supabase/ssr
環境変数の設定
# .env.local
NEXT_PUBLIC_SUPABASE_URL=https://xxxx.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
Supabase クライアントの作成
App Router では「サーバー用」と「ブラウザ用」を使い分ける。
// src/lib/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) {
cookiesToSet.forEach(({ name, value, options }) => {
cookieStore.set(name, value, options);
});
},
},
}
);
}
// src/lib/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!
);
}
Middleware でセッションを更新する
// middleware.ts
import { createServerClient } from "@supabase/ssr";
import { NextResponse, type NextRequest } from "next/server";
export async function middleware(request: NextRequest) {
let supabaseResponse = NextResponse.next({ request });
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return request.cookies.getAll();
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value, options }) =>
request.cookies.set(name, value)
);
supabaseResponse = NextResponse.next({ request });
cookiesToSet.forEach(({ name, value, options }) =>
supabaseResponse.cookies.set(name, value, options)
);
},
},
}
);
// セッションを更新してトークンの有効期限切れを防ぐ
await supabase.auth.getUser();
return supabaseResponse;
}
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};
サインアップ・ログイン(Server Actions)
// src/app/auth/actions.ts
"use server";
import { redirect } from "next/navigation";
import { z } from "zod";
import { createClient } from "@/lib/supabase/server";
const authSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
});
export async function signUp(formData: FormData) {
const parsed = authSchema.safeParse({
email: formData.get("email"),
password: formData.get("password"),
});
if (!parsed.success) return { error: "入力内容を確認してください" };
const supabase = await createClient();
const { error } = await supabase.auth.signUp(parsed.data);
if (error) return { error: error.message };
redirect("/dashboard");
}
export async function signIn(formData: FormData) {
const parsed = authSchema.safeParse({
email: formData.get("email"),
password: formData.get("password"),
});
if (!parsed.success) return { error: "入力内容を確認してください" };
const supabase = await createClient();
const { error } = await supabase.auth.signInWithPassword(parsed.data);
if (error) return { error: "メールアドレスまたはパスワードが正しくありません" };
redirect("/dashboard");
}
export async function signOut() {
const supabase = await createClient();
await supabase.auth.signOut();
redirect("/login");
}
ログインフォーム
// src/app/login/page.tsx
import { signIn } from "@/app/auth/actions";
export default function LoginPage() {
return (
<form action={signIn} className="space-y-4 max-w-sm mx-auto mt-16">
<h1 className="text-xl font-bold">ログイン</h1>
<div>
<label htmlFor="email" className="block text-sm font-medium">
メールアドレス
</label>
<input
id="email"
name="email"
type="email"
required
className="mt-1 block w-full rounded border px-3 py-2 text-sm"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium">
パスワード
</label>
<input
id="password"
name="password"
type="password"
required
className="mt-1 block w-full rounded border px-3 py-2 text-sm"
/>
</div>
<button
type="submit"
className="w-full rounded bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
>
ログイン
</button>
</form>
);
}
Server Component でセッションを参照する
// src/app/dashboard/page.tsx
import { redirect } from "next/navigation";
import { createClient } from "@/lib/supabase/server";
import { signOut } from "@/app/auth/actions";
export default async function DashboardPage() {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
// 未ログインはログインページへ
if (!user) redirect("/login");
return (
<div className="p-8">
<p className="text-sm text-gray-600">ログイン中: {user.email}</p>
<form action={signOut} className="mt-4">
<button
type="submit"
className="rounded border px-4 py-2 text-sm hover:bg-gray-50"
>
ログアウト
</button>
</form>
</div>
);
}
ポイント
@supabase/ssrのcreateServerClient/createBrowserClientを用途ごとに使い分ける- Middleware でセッション更新を行うことでトークンの自動リフレッシュが機能する
- Server Actions から
redirect()を使うことで認証後のリダイレクトをサーバー側で完結できる - Server Components では
getUser()でセッションを確認し、未認証なら即redirect() - エラーメッセージは内部エラーをそのまま返さず、ユーザー向けに丸める