Zenn
Open1

react router v7, supabase, cloudflareでログイン処理まで実装

tonbitonbitonbitonbi

初めに

個人開発のすすめをみてcloudflareと、supabaseは採用。next.jsもやってみたけどデバック中利用できるようになるまでが遅いのでreact router v7を採用。あたらしいreact router v7、supabaseもライブラリが新しくなって古い情報と新しい情報が混じり、AIに聞いても微妙に間違った回答が来る、バックエンドを主にやってきたので、フロントエンドの理屈に不慣れで苦労した。

プロジェクトの初期化

npx create-react-router@latest --template remix-run/react-router-templates/cloudflare react-router-supabase-auth

npm run dev,npm run deployしてそれぞれ表示されることを確認。

環境変数の追加

supabaseのクライアントを作成する際に必要なURLとANON_KEYを.dev.varsとwrangler.jsoncに追加
この2つ、秘密にしなければならないと思ってたんだけど、別にばれても問題はないらしい。でもさすがにソースコードに直書きは気持ち悪い。

.dev.vars
SUPABASE_URL=https://XXXXXXXXXXXXXX.supabase.co
SUPABASE_ANON_KEY=YYYYYYY
+++ b/wrangler.jsonc
@@ -6,7 +6,7 @@
   "assets": {},
   "vars": {
     "VALUE_FROM_CLOUDFLARE": "Hello from Cloudflare",
+    "SUPABASE_URL":"https://XXXXXXXXXXXX.supabae.co",
+    "SUPABASE_ANON_KEY":"YYYYYYYYYYYYYYYYY"
   }
 }

wrangler typesするとworker-configuration.d.tsが追加されて、新しい環境変数が参照できるようになる。
テストのため、SUPABASE_ANON_KEYを表示するように修正。

diff --git a/app/routes/home.tsx b/app/routes/home.tsx
index a8642a0..1055948 100644
--- a/app/routes/home.tsx
+++ b/app/routes/home.tsx
@@ -9,7 +9,7 @@ export function meta({}: Route.MetaArgs) {
 }
 
 export function loader({ context }: Route.LoaderArgs) {
-  return { message: context.cloudflare.env.VALUE_FROM_CLOUDFLARE };
+  return { message: context.cloudflare.env.SUPABASE_ANON_KEY };
 }
 
 export default function Home({ loaderData }: Route.ComponentProps) {

ローカルとcloudflare上でSUPABASE_ANON_KEYの内容が表示されることを確認。
Workers & Pagesの変数とシークレットで変数の値を変更すると、表示も変更されることを確認。

supabaseクライアントの作成

ネット検索しても、ChatGTPに聞いても@supabase/supabase-jsと@supabase/ssrの話が混じって超わかりにくかったんだけど、基本@supabase/ssrを使えばよいらしい。ssrとあるのでサーバ側でしか使えないのかと思ったらbrowser側でも使えるらしい。
ブラウザ側のクライアント作成コード

app/utils/supabase/client.ts
import {createBrowserClient } from "@supabase/ssr";

export function createBrowserSupabaseClient(env:{SUPABASE_URL:string,SUPABASE_ANON_KEY:string}){
    const supabase = createBrowserClient(env.SUPABASE_URL, env.SUPABASE_ANON_KEY);
    return supabase
}

サーバ側クライアント作成コード。これを見つけるのに苦労した。ていうかsupabaseのサイト見るだけじゃ全然わからない。

app/utils/supabase/server.ts
import { SupabaseClient } from "@supabase/supabase-js";
import { createServerClient, parseCookieHeader, serializeCookieHeader } from "@supabase/ssr";
type ItemWithRequiredValue = { name: string; value: string };

export function createServerSupabaseClient(request:Request,env:{SUPABASE_URL:string,SUPABASE_ANON_KEY:string}):[SupabaseClient,Headers]{
    const headers = new Headers()
    const supabase = createServerClient(env.SUPABASE_URL, env.SUPABASE_ANON_KEY, {
      cookies: {
        getAll() {

          const h1 = parseCookieHeader(request.headers.get('Cookie')||"")
          const headers = h1.filter((item): item is ItemWithRequiredValue =>item.value !== undefined)
          return headers
        },
        setAll(cookiesToSet) {
          cookiesToSet.forEach(({ name, value, options }) =>
            headers.append('Set-Cookie', serializeCookieHeader(name, value, options))
          )
        },
      },
    })
    return [supabase,headers]
}

flatRoutesの導入

ここでapp/routes/login.tsxを作ったのに全然表示されない。route.tsがファイルベースルーティングになってなかったので、flatRoutesを使うように変更。

--- a/app/routes.ts
+++ b/app/routes.ts
@@ -1,3 +1,4 @@
-import { type RouteConfig, index } from "@react-router/dev/routes";
+import type { RouteConfig } from '@react-router/dev/routes'
+import { flatRoutes } from '@react-router/fs-routes'
 
-export default [index("routes/home.tsx")] satisfies RouteConfig;
+export default flatRoutes() satisfies RouteConfig

するとなぜかzipやhttpがないといわれる。
vite.config.tsを変更したらなぜか治ったが原因がわからないので後で調査。

--- a/vite.config.ts
+++ b/vite.config.ts
@@ -1,12 +1,14 @@
 import { reactRouter } from "@react-router/dev/vite";
 import { cloudflare } from "@cloudflare/vite-plugin";
+import { cloudflareDevProxy as remixCloudflareDevProxy } from '@react-router/dev/vite/cloudflare'
 import tailwindcss from "@tailwindcss/vite";
 import { defineConfig } from "vite";
 import tsconfigPaths from "vite-tsconfig-paths";
 
 export default defineConfig({
   plugins: [
-    cloudflare({ viteEnvironment: { name: "ssr" } }),
+    remixCloudflareDevProxy(),
+    cloudflare(),
     tailwindcss(),
     reactRouter(),
     tsconfigPaths(),

loginの実装

app/route/login.tsx
import { createBrowserSupabaseClient } from "~/utils/supabase/client";
import type { Route } from "./+types/login";

export function loader(args:Route.LoaderArgs){

  return args.context.cloudflare.env
}
export default function Login(args:any)  {
  console.log(args.loaderData)
  const env:{SUPABASE_URL:string,SUPABASE_ANON_KEY:string} = args.loaderData
  const handleLogin = async () => {
    const supabase = createBrowserSupabaseClient(env)
    const redirectTo = `${window.location.origin}/callback`
    console.log(`redirect to ${redirectTo}`)
    const { data, error } = await supabase.auth.signInWithOAuth({
      provider: 'google',
      options: {
        redirectTo,
      },
    })
    if (error) console.error('Error logging in:', error);
  };

  return (
    <div>
      <h2>Login</h2>
      <button onClick={handleLogin}>Sign in with Google</button>
    </div>
  );
};
app/route/callback.tsx
import React, { useEffect } from 'react';
import { redirect,createCookie, } from 'react-router';
import { createServerSupabaseClient } from '~/utils/supabase/server';
import type { Route } from "./+types/callback";
export async function loader(args:Route.LoaderArgs){
    const url = new URL(args.request.url);
  
      // URLSearchParamsを使用してクエリパラメータを取得
  const searchParams = url.searchParams;
    
  // 特定のクエリパラメータを取得する例
    const code = searchParams.get("code")|| "";
    const {supabase,headers} = createServerSupabaseClient(args.request,args.context.cloudflare.env)
    const res = await supabase.auth.exchangeCodeForSession(code)
    const {data:{user}} = await supabase.auth.getUser()
    return redirect("/",{headers});
}
const Callback = () => {
  return (
    <div>
      <h2>認証処理中...</h2>
    </div>
  );
};

export default Callback;
app/route/_index.tsx
import type { Route } from "./+types/_index";
import { Welcome } from "../welcome/welcome";
import { createServerSupabaseClient } from "~/utils/supabase/server";
import { redirect, useNavigate } from "react-router";
import { createBrowserSupabaseClient } from "~/utils/supabase/client";

export function meta({}: Route.MetaArgs) {
  return [
    { title: "New React Router App" },
    { name: "description", content: "Welcome to React Router!" },
  ];
}

export async function loader({ request,context }: Route.LoaderArgs) {
  const {supabase} = createServerSupabaseClient(request,context.cloudflare.env)
  const {data:{user}} = await supabase.auth.getUser()
  if(user === null){
    return redirect("/login")
  }
  const t = { message: `hellow ${user?.email}`,
    url:context.cloudflare.env.SUPABASE_URL,
    key: context.cloudflare.env.SUPABASE_ANON_KEY
  }
  return t;
}

export default function Home({ loaderData }: Route.ComponentProps) {
   const env = {SUPABASE_URL:loaderData.url,SUPABASE_ANON_KEY:loaderData.key}
   const navigate = useNavigate();
   const handleLogout = async () => {
      const supabase = createBrowserSupabaseClient(env)
      const { error } = await supabase.auth.signOut()
      navigate("/login")
    };
  return <><Welcome message={loaderData.message} />
        <button onClick={handleLogout}>サインアウト</button>
      </>;
}
ログインするとコメントできます