注意
あらかじめ言っておきますが、筆者は初心者なので、自分なりの解釈でまとめています。「100%信じるぜ!」という気持ちで読まないようお願いします。どうぞよろしく!
経緯
next.jsとsupabaseを使って、簡単なアプリを作りました(supabaseを選んだ理由は、流行っていそうだから)。せっかくなのでアカウント管理とログイン機能を作ってみようと思い立ちました。クライアントコンポーネントで実装するのは簡単だったのですが、最後に全体をラップする際に、「あれ?これって全体がクライアントコンポーネントになるんじゃ?」と思ったんです。そしたら、next.jsの意味がなくなっちゃう気がして、サーバーコンポーネントで実装しようと思ったんです。だけど、サーバー側でログイン機能を実装するのにちょっと手こずったので、その備忘録として残しておきます。
他に良い方法を知っている方がいれば、教えてください!
参考
参考にしたサイトは以下です。
こちらは、supabaseの設定やGoogleCloudの設定で参考にしました。とても分かりやすかったです。
以下は公式ドキュメントです。読んで、自分なりに簡単に解釈したつもりです(ここ重要)。
こちらは、クッキーに保存されたセッションを確認するためのsupabase.auth.getUser()についての参考資料です。
順番
- GoogleCloudやSupabaseの設定
- envファイルの作成
- Supabaseクライアントの作成するためのユーティリティ関数を書く
- auth/callbackファイルの作成
- ログインページの作成
- ServerActionsの作成
- クッキーの取得とラップ処理
内容
順を追って説明していきます。
1. GoogleCloudやSupabaseの設定
紹介したサイトを参考にして設定を進めてください。
GoogleCloudの設定時に以下のように記入する必要があります:
「承認済みの JavaScript 生成元」→ 自分のローカルのURL(例:http://localhost:9999)
「承認済みのリダイレクトURI」→ 自分のローカルURL + /auth/callback(例:http://localhost:9999/auth/callback)
2. envファイルの作成
プロジェクトのルートに.envファイルを作成し、以下のように記述します(supabaseのURLとAPIキーを設定)。
「NEXT_PUBLIC」をつけていないURLのKEYがありますが、これは、セキュリティ面を考え、クライアント側で使用する場合とサーバー側で使用する場合で分けるためです。クライアント側でsupabaseを呼び出す場合は、NEXT_PUBLICがついていないURLとKEYを。サーバー側でsupabaseを呼び出す場合は、NEXT_PUBLICをつけている方を。(多分、これは力技。参考にしないほうがいいと思います)
SUPABASE_URL=あなたのURL
SUPABASE_ANON_KEY=あなたのキー
NEXT_PUBLIC_SUPABASE_URL=あなたのURL
NEXT_PUBLIC_SUPABASE_ANON_KEY=あなたのキー
supabaseのURLとAPIキーは、プロジェクトのSettings > Project Settingsから確認できます。
3. Supabaseクライアントの作成するためのユーティリティ関数を書く
-
utils/supabase
フォルダを作成 -
client.ts
,server.ts
,middleware.ts
ファイルを作成 -
client.ts
ファイルに以下を記述(公式からコピペ)client.tsimport { createBrowserClient } from '@supabase/ssr' export function createClient() { return createBrowserClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! ) }
-
server.ts
ファイルに以下を記述(公式からコピペ)server.tsimport { 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 { // The `setAll` method was called from a Server Component. // This can be ignored if you have middleware refreshing // user sessions. } }, }, } ) }
-
middleware.ts
ファイルに以下を記述(公式からコピペ)middleware.tsimport { createServerClient } from '@supabase/ssr' import { NextResponse, type NextRequest } from 'next/server' export async function updateSession(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) ) }, }, } ) // Do not run code between createServerClient and // supabase.auth.getUser(). A simple mistake could make it very hard to debug // issues with users being randomly logged out. // IMPORTANT: DO NOT REMOVE auth.getUser() const { data: { user }, } = await supabase.auth.getUser() if ( !user && !request.nextUrl.pathname.startsWith('/login') && !request.nextUrl.pathname.startsWith('/auth') ) { // no user, potentially respond by redirecting the user to the login page const url = request.nextUrl.clone() url.pathname = '/login' return NextResponse.redirect(url) } // IMPORTANT: You *must* return the supabaseResponse object as it is. // If you're creating a new response object with NextResponse.next() make sure to: // 1. Pass the request in it, like so: // const myNewResponse = NextResponse.next({ request }) // 2. Copy over the cookies, like so: // myNewResponse.cookies.setAll(supabaseResponse.cookies.getAll()) // 3. Change the myNewResponse object to fit your needs, but avoid changing // the cookies! // 4. Finally: // return myNewResponse // If this is not done, you may be causing the browser and server to go out // of sync and terminate the user's session prematurely! return supabaseResponse }
4. auth/callbackファイルの作成(公式からコピペ)
- app/auth/callbackフォルダを作成(この順番が大事!!)
- route.ts ファイルを作成し、以下を記述
route.ts
import { NextResponse } from 'next/server' // The client you created from the Server-Side Auth instructions // import { supabase } from '../../../utils/supabase/middleware' import { createClient } from "@/utils/supabase/server"; export async function GET(request: Request) { const supabase = await createClient() const { searchParams, origin } = new URL(request.url) const code = searchParams.get('code') // if "next" is in param, use it as the redirect URL const next = searchParams.get('next') ?? '/' if (code) { //`OAuthの認可コードをSupabaseのセッションに交換する処理ということ。 const { error } = await supabase.auth.exchangeCodeForSession(code) if (!error) { const forwardedHost = request.headers.get('x-forwarded-host') // original origin before load balancer const isLocalEnv = process.env.NODE_ENV === 'development' if (isLocalEnv) { // we can be sure that there is no load balancer in between, so no need to watch for X-Forwarded-Host return NextResponse.redirect(`${origin}${next}`) } else if (forwardedHost) { return NextResponse.redirect(`https://${forwardedHost}${next}`) } else { return NextResponse.redirect(`${origin}${next}`) } } } // return the user to an error page with instructions return NextResponse.redirect(`${origin}/auth/auth-code-error`) }
5. ログインページの作成
login.tsxというファイルを作成し、ログインページを作成します。その際、フォームを使い、formのactionにServerActionを指定します。
import { handleLogin } from "@/actions/Login";
const LoginPage = () => {
return (
<div>
<form action={handleLogin}>
<button type="submit">Googleでログイン</button>
</form>
</div>
);
};
export default LoginPage
6. ServerActionsの作成
ServerActionのファイルを作成し以下を記述します。
'use server'
import { createClient } from "@/utils/supabase/server";
import { redirect } from "next/navigation";
export const handleLogin = async () => {
const supabase = await createClient()
const { data, error } = await supabase.auth.signInWithOAuth({
provider: 'google',
options: {
redirectTo: 'あなたのポート番号/auth/callback',
},
})
if (data.url) {
redirect(data.url) // use the redirect API for your server framework
}
};
7. クッキーの取得とラップ処理
layout.tsxでクッキーが残っているか確認し、ラップします。supabase.auth.getUser()でセッションがあるかどうか確認できます。
import { redirect } from "next/navigation";
import { createClient } from "@/utils/supabase/server";
const mainLayout = async ({
children,
}: Readonly<{
children: React.ReactNode;
}>) => {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
// ユーザー情報がない場合はログインページへリダイレクト
if (!user) {
redirect('/login');
}
return (
<div>
{children}
</div>
)
}
export default mainLayout
まとめ
初めてログイン機能を導入してみたけど、思った以上に難しかった。セキュリティを考えると、アプリが完成してもデプロイには慎重になってしまう。でも、こういう試行錯誤を通じて成長していくんだなと実感! まだまだ勉強あるのみ!