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>
  );
}

以上となります。参考にしてみてください。

いいなと思ったら応援しよう!