Next.js App Router × Supabase Auth で Google 認証の実装方法
はじめに
みなさん、こんにちは。
note ではプロダクトマネジメント関連の記事を上げていますが、最近プライベートで書いたコードで Next.js App Router で Supabse の Auth を使用した実装を行ったので、記事にしようと思います。
前提
"next": "14.2.5",
"@supabase/ssr": "0.4.0",
"@supabase/supabase-js": "2.45.0",
1. 実装準備
1.1 パッケージの Install
必要なパッケージをインストールします。
npm i @supabase/ssr @supabase/supabase-js
1.2 環境変数の設定
Supabase プロジェクトの設定を行い、以下の環境変数を `.env.local` に追加してください。
Auth Provider で Google で認証を行う場合は、GCP でプロジェクトの作成を行う必要があります。この設定は他の記事でも多数紹介されていると思うので、本記事ではスキップします。
NEXT_PUBLIC_SUPABASE_URL=YOUR_SUPABASE_PROJECT_URL
NEXT_PUBLIC_SUPABASE_ANON_KEY=YOUR_SUPABASE_ANON_KEY
2. Supabase クライアントの設定
App Router では、サーバーサイドで認証を行いますが、クライアントとサーバー両方の Supabase クライアントを作ります。
// 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 || '',
);
}
// src/lib/supabase/server.ts
import { createServerClient } from '@supabase/ssr';
import { cookies } from 'next/headers';
export function createClient() {
const cookieStore = cookies();
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL || '',
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || '',
{
cookies: {
getAll() {
return cookieStore.getAll();
},
setAll(cookiesToSet) {
try {
for (const { name, value, options } of cookiesToSet) {
cookieStore.set(name, value, options);
}
} catch (error) {
console.error('Failed to set cookies in Server Component:', error);
throw new Error(
'Failed to set cookies in Server Component. This may indicate an issue with server-side rendering.',
);
}
},
},
},
);
}
3. Middleware の作成
`src/middleware.ts` で実行するための関数を lib/supabase 配下に作成します。
// src/lib/supabase/middleware.ts
import { createServerClient } from '@supabase/ssr';
import { type NextRequest, NextResponse } from 'next/server';
export async function updateSession(request: NextRequest) {
try {
let response = NextResponse.next({
request: {
headers: request.headers,
},
});
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL || '',
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || '',
{
cookies: {
getAll() {
return request.cookies.getAll();
},
setAll(cookiesToSet) {
for (const { name, value } of cookiesToSet) {
response.cookies.set(name, value);
}
response = NextResponse.next({
request,
});
for (const { name, value, options } of cookiesToSet) {
response.cookies.set(name, value, options);
}
},
},
},
);
await supabase.auth.getUser();
return response;
} catch (_e) {
return NextResponse.next({
request: {
headers: request.headers,
},
});
}
}
// src/middleware.ts
import { updateSession } from '@/lib/supabase/middleware';
import type { NextRequest } from 'next/server';
export async function middleware(request: NextRequest) {
return await updateSession(request);
}
export const config = {
matcher: [
/*
* Match all request paths except for the ones starting with:
* - _next/static (static files)
* - _next/image (image optimization files)
* - favicon.ico (favicon file)
* Feel free to modify this pattern to include more paths.
*/
'/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
],
};
上記で作成した updateSession を middleware で実行することで、認証状態に基づくルーティングの制御を行う事ができます。
4. ログイン処理の実装
4.3 ログイン、ログアウト関数の作成
ログインとログアウト処理を実行する関数を作成します。
// src/actions/auth.ts
'use server';
import { createClient } from '@/lib/supabase/server';
import { redirect } from 'next/navigation';
export async function signInWithGoogle() {
const supabase = createClient();
const {
data: { url },
error,
} = await supabase.auth.signInWithOAuth({
provider: 'google',
options: {
redirectTo: `${process.env.NEXT_PUBLIC_BASE_URL}/api/auth/callback`,
},
});
if (error) {
console.error('Error during Google sign-in:', error.message);
redirect('/error?message=authentication-failed');
}
if (!url) {
console.error('No URL returned from signInWithOAuth');
redirect('/error?message=authentication-failed');
}
redirect(url);
}
export async function signOut() {
const supabase = createClient();
await supabase.auth.signOut();
redirect('/login');
}
// src/app/api/auth/callback.ts
import prisma from '@/lib/prisma';
import { createClient } from '@/lib/supabase/server';
import { NextResponse } from 'next/server';
export async function GET(request: Request) {
const { searchParams, origin } = new URL(request.url);
const code = searchParams.get('code');
const next = searchParams.get('next') ?? '/';
if (!code) {
return NextResponse.redirect(`${origin}/400`, { status: 400 });
}
const supabase = createClient();
const {
data: { user: supabaseUser },
error,
} = await supabase.auth.exchangeCodeForSession(code);
if (error) {
console.error('Error exchanging code for session:', error);
return NextResponse.redirect(`${origin}/401`, { status: 401 });
}
if (!supabaseUser) {
return NextResponse.redirect(`${origin}/404`, { status: 404 });
}
try {
let user = await prisma.user.findUnique({
where: { email: supabaseUser.email },
});
if (user) {
user = await prisma.user.update({
where: { id: user.id },
data: {
name: supabaseUser.user_metadata.full_name || user.name,
updatedAt: new Date(),
},
});
} else {
user = await prisma.user.create({
data: {
id: supabaseUser.id,
email: supabaseUser.email || '',
name: supabaseUser.user_metadata.full_name || supabaseUser.email || '',
},
});
}
} catch (dbError) {
console.error('Error creating/updating user record:', dbError);
return NextResponse.redirect(`${origin}/500`, { status: 500 });
}
const forwardedHost = request.headers.get('x-forwarded-host');
const isLocalEnv = process.env.NODE_ENV === 'development';
if (isLocalEnv) {
return NextResponse.redirect(`${origin}${next}`, { status: 302 });
}
if (forwardedHost) {
return NextResponse.redirect(`https://${forwardedHost}${next}`, { status: 302 });
}
return NextResponse.redirect(`${origin}${next}`, { status: 302 });
}
4.2 ログインページの作成
import { signInWithGoogle } from '@/actions/auth';
import Image from 'next/image';
export default function Page(): JSX.Element {
return (
<div className="grid min-h-screen place-items-center p-4">
<div className="w-full max-w-md">
<form action={signInWithGoogle}>
<button
type="submit"
className="flex w-full items-center justify-center rounded-md border border-gray-300 bg-white px-4 py-2 font-medium text-gray-700 text-sm shadow-sm transition-colors hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
>
<Image src="/images/google-logo.svg" alt="Google" width={18} height={18} className="mr-2" />
Google でログインする
</button>
</form>
</div>
</div>
);
}
ログイン後のページも作っちゃいましょう。
import { signOut } from '@/actions/auth';
import { createClient } from '@/lib/supabase/server';
import { Header } from '@/components/header';
export default async function Page(): Promise<JSX.Element> {
const supabase = createClient();
const {
data: { user: supabaseUser },
error,
} = await supabase.auth.getUser();
if (error || !supabaseUser) {
console.error(error);
redirect('/login');
}
return (
<div className="grid min-h-screen grid-rows-[auto,1fr,auto] bg-stone-50">
<Header />
<form action={signOut}>
<button type="submit">Sign Out</button>
</form>
</div>
);
}
以上となります。参考にしてみてください。