Supabase と Nuxt 3 で SSR を実装して Cloudflare Workers にデプロイ
私はもともと Nuxt2 や Firebase, App Engine などを使って Web サービスを作っていた身ですが、最近賑わっている技術がどう良くなっているのか体感したく、試しに極めてシンプルなサービスを作って記事化してみることにしました(あと単純に Zenn の使い心地を知りたかった)。
以下がそのサービスです。
ユーザーのアクションが即座に OGP に反映されるようなケースを作りたかったので、以下のようなサービスにしました。
-
/
: それぞれのユーザーが自分のステータスを設定(認証前提・クライアントレンダリング) -
/[username]
: そのユーザーのステータスを閲覧(サーバーレンダリング)
個人情報とかめんどくさいので登録はできないようにしてますが、サーバーレンダリングのページなら見てもらえます。
リポジトリはこちらです。
Supabase
The Open Source Firebase Alternative
とかなり喧嘩腰なキャッチコピーで話題を呼んでいる BaaS(なんか久しぶりに聞きました)です。
個人的な印象としては、公式が出してる以下の画像にもあるように PostgreSQL as a Service かなあと思っています。
Firebase Alternative と言っているだけあって、認証や REST API、リアルタイムアップデートなど欲しいものが十分に揃っている印象です。
Nuxt 3
言わずとしれた Nuxt のバージョン3です。以下の記事が言いたいことを全部言ってくれているので一読をおすすめします。
個人的に、サクサク開発できるようになったり TypeScript が標準搭載されていたりといった使い勝手向上とは一線を画していると思うのが Nitro Engine です。
Nuxt3 のドキュメントのデプロイのところを見るとわかりますが、Nuxt で作ったアプリをこれらのサービスへ至極簡単にデプロイができます。触っていて一番感動しました。Cloudflare Workers にもこれでデプロイします。
Cloudflare Workers
コールドスタートがないサーバレス実行環境です。詳しくは以下の記事を見ると良さがわかります。
Nuxt 3 でセットアップ
それでは開発を始めます。
上記の通り
npx nuxi init アプリ名
すれば最小限のプロジェクトを作成できます。
Supabase プロジェクト立ち上げ
上記から Github でログインしてなんやかんやプロジェクト作成までします。特に迷うことはないかと思います。
Supabase のアクセス制御
Supabase は Postgres のアクセス制御 RLS(Row Level Security) を採用しています(立ち位置は Firebase の Security Rule です。Firebase の request.auth
みたいに Supabase 独自の認証されたユーザーのパラメーターも使えるように拡張されています)。以下の記事が参考になります。
Supabase は Firebase のようにクライアントから直接使えるのはもちろん、一般的なデータベースのようにミドルウェア経由でも使えます。
「クライアントから直接」パターンでは、最小限の権限を持つ API Key である anon
キーを使って認証 API にアクセスし、認証後 RLS でアクセス制御を行います。RLS はテーブル単位で設定できますが、RLS を設定していない API は anon
キーでアクセスし放題になります。
一方で、従来の「間にミドルウェア挟む」パターンでは、同様に anon
キーで認証後、 RLS をすべて有効かつクライアントからのアクセスを禁止(ポリシーを作成しない限りそうなります)するようにして、ミドルウェアから RLS を無視できる API Key である service_role
キーを使って Supabase へアクセスします。Firebase でいうと Security Rules をすべて false
にしてミドルウェアで firebase-admin
を使ってアクセスする感じです。
今回は前者の「クライアントから直接」パターンを採用します。
Supabase を Nuxt で使えるように
Supabase の Home
タブに先ほど述べた anon
キーとプロジェクト URL があるので、そちらを環境変数にセットします。
import { defineNuxtConfig } from "nuxt3";
export default defineNuxtConfig({
publicRuntimeConfig: {
SUPABASE_URL: process.env.SUPABASE_URL,
SUPABASE_ANON_KEY: process.env.SUPABASE_ANON_KEY,
},
});
そして Supabase クライアントをプラグインとして NuxtApp
に注入します。 @supabase/supabase-js
はインストールしておきましょう。 Nuxt3 ではプラグインをnuxt.config.ts
で指定しなくても自動読み込みしてくれるようになりましたね。
import { createClient } from "@supabase/supabase-js";
import { defineNuxtPlugin } from "#app";
export default defineNuxtPlugin((nuxtApp) => {
const supabase = createClient(
nuxtApp.$config.SUPABASE_URL as string,
nuxtApp.$config.SUPABASE_ANON_KEY as string
);
nuxtApp.provide("supabase", supabase);
});
declare module "#app" {
interface NuxtApp {
$supabase: SupabaseClient;
}
}
これで Supabase が使えるようになりました。
新規登録・ログイン
Supabase はデフォルトでメールアドレスログインが有効になっています。
適当にフォームを用意して supabase.auth.signUp
に メールアドレスとパスワード渡してあげると新規登録完了です。新規登録するとメールアドレス検証メールが飛びます(メール文言も Authentication
> Templates
から変更できます)。
<template>
<!-- UI省略 -->
</template>
<script setup lang="ts">
const nuxtApp = useNuxtApp();
const email = ref("");
const password = ref("");
const username = ref("");
const submit = async () => {
const session = await nuxtApp.$supabase.auth.signUp({
email: email.value,
password: password.value,
});
// TODO: データベースに初期値入れる
};
</script>
ログインも同じ感じで
await nuxtApp.$supabase.auth.signInWithPassword({
email: email.value,
password: password.value,
});
を使ってあげれば良いですが、デフォルトでメールアドレス検証しないとログインできないようになっているので注意です(以下のように Authentication
> Settings
から設定可能)。
今回はシンプルすぎてやってないですが、コンポーネントから直接 supabase
にアクセスするより、 composables
に認証系メソッドをまとめてそちらを呼び出す方がより筋が良いかもしれません。
認証済み判定
読み込み時にログインしているかどうかを判定します。
Firebase と同様 onAuthStateChange
(地味に onAuthStateChanged
ではない)がありますが、ページ読み込み時に発火してはくれず、以下のイシューのように読み込み時は supabase.auth.session()
を利用するワークアラウンドをよく見ます。
以下のようなログイン中のユーザー情報を取得するモジュールを作成します。
import { Session } from "@supabase/supabase-js";
export const useAuth = () => {
const nuxtApp = useNuxtApp();
const currentSession = ref<Session>(null);
const session = nuxtApp.$supabase.auth.session();
if (session) {
currentSession.value = session;
}
nuxtApp.$supabase.auth.onAuthStateChange((event, session) => {
if (event === "SIGNED_IN") {
currentSession.value = session;
}
});
return {
currentSession,
};
};
このモジュールを使って認証済みページにつかうレイアウトを作成します。
<template>
<div>
<slot v-if="currentUser" />
<div v-else>Loading...</div>
</div>
</template>
<script setup lang="ts">
import { useAuth } from "~/composables/useAuth";
const { currentSession } = useAuth();
const router = useRouter();
const currentUser = ref(null);
onMounted(() => {
if (!currentSession.value) {
router.replace("/signin");
return;
}
currentUser.value = currentSession.value.user;
});
</script>
currentSession
でそのまま要素を出し分けると Hydration エラーがでるので onMounted
で改めて currentUser
に入れてそれを使うというワンクッションを置いてます。
これだと認証前提のページはすべて、 SSR では pages
の中身を一切表示せず Loading...
になります。認証前提のページなので気にしなければそれでいいですが、OGP を表示したいとかサーバーもガワだけは表示してあげたいなどあると思います。その場合はレイアウトで分岐せず、ページ側で対処してあげれば良いです。
トップのステータス設定ページが認証前提なので仕込んでおきます。
<script lang="ts">
export default {
layout: "authenticated",
};
</script>
<script setup lang="ts">
<!-- 略 -->
</script>
<script setup lang="ts">
definePageMeta({
layout: "authenticated",
});
</script>
Supabase でテーブル作成
認証ができたのでデータベースを作っていきます。
Supabase のダッシュボード Table Editor
から user_statuses
というテーブルを作成しました。 SQL Editor
から SQL でも操作できるので慣れている人はそっちでも良いかもしれません。
id
はデフォルトで用意されている auth
データベースの users
テーブルの id
を外部キー設定しています。
username
はユニーク制約をつけています。文字数制限などは結局 SQL でやらないといけない?っぽいです。
RLS の設定
以下のようなルールを設定してあげます。
- 読み取りは誰でもできる
- 作成・更新・削除は自分自身のみ
Authentication
> Policies
にある user_statuses
の New Policy
からポリシーを作成します。ゼロから作成かテンプレートから作成を聞かれるのでテンプレートからにします。もろもろ参考にしながら以下を作成しました。書き込みは全部 auth.uid() = id
ですね。
クライアントで登録・取得・更新
実際にデータをあれこれしていきます。基本的に SQL をメソッドチェーンで書いていく感じになります。
初回登録
新規登録時に初期値を入れます。
const nuxtApp = useNuxtApp();
const email = ref("");
const password = ref("");
const username = ref("");
const submit = async () => {
const session = await nuxtApp.$supabase.auth.signUp({
email: email.value,
password: password.value,
});
+ await nuxtApp.$supabase
+ .from("user_statuses")
+ .insert({ id: session.user.id, status: "home", username: username.value });
};
自分の情報を取得
ステータス設定画面で初期値を入れるために、自分の情報を取得します。
<template>
<!-- UI省略 -->
</template>
<script setup lang="ts">
const currentUserStatus = ref(null);
const nuxtApp = useNuxtApp();
const { currentSession } = useAuth();
onMounted(async () => {
const { data: userStatus } = await nuxtApp.$supabase
.from("user_statuses")
.select("*")
.eq("id", currentSession.value.user.id)
.single();
currentUserStatus.value = userStatus;
});
</script>
情報を更新
ラジオボタンの入力に合わせてステータスを更新します。
<script setup lang="ts">
const currentUserStatus = ref(null);
const nuxtApp = useNuxtApp();
const { currentSession } = useAuth();
+ const changeStatus = async (status: string) => {
+ const { data: userStatus } = await nuxtApp.$supabase
+ .from("user_statuses")
+ .update({ status })
+ .match({ id: currentSession.value.user.id })
+ .single();
+ currentUserStatus.value = userStatus;
+ };
onMounted(async () => {
const { data: userStatus } = await nuxtApp.$supabase
.from("user_statuses")
.select("*")
.eq("id", currentSession.value.user.id)
.single();
currentUserStatus.value = userStatus;
});
</script>
毎回コンポーネント側で supabase
を直呼び出しするのは微妙なので、これも composables
とかに便利なラッパー作っておくと良さそうです。
ユーザーページはサーバー側でレンダリング
/ユーザー名
は完全にサーバー側ですべてレンダリングします。
useFetch
を使って REST API を叩きます。カラムの値に一致するものを取得したい場合は eq.
を使って以下のようになります。
https://プロジェクトURL/rest/v1/テーブル名?select=*&カラム名=eq.値
なお、認証には anon
キーを利用します。
<template>
<div class="container">
<Title>{{
userStatuses && userStatuses.length
? `${userStatuses[0].username} のステータス`
: `見つかりません`
}}</Title>
<!-- 省略 -->
</div>
</template>
<script setup lang="ts">
const nuxtApp = useNuxtApp();
const { params } = useRoute();
const { data: userStatuses } = useFetch<string, any[]>(
`${nuxtApp.$config.SUPABASE_URL}/rest/v1/user_statuses?select=*&username=eq.${params.username}`,
{
headers: {
apikey: nuxtApp.$config.SUPABASE_ANON_KEY,
Authorization: `Bearer ${nuxtApp.$config.SUPABASE_ANON_KEY}`,
},
}
);
</script>
Nuxt3 の新機能のメタコンポーネントである <Title>
タグを使うことで、タイトル要素をテンプレート HTML 内に記述できます。その他に <Meta>
タグなどあるのでそちらで OGP を設定しています。
これで実装完了です。
Cloudflare Workers にデプロイ
上記に手順が書かれていますが、Cloudflare に登録後、 account_id
を取得して以下を作成、
name = "好きなアプリ名"
type = "javascript"
account_id = "取得した account_id"
workers_dev = true
route = ""
zone_id = ""
[site]
bucket = ".output/public"
entry-point = ".output"
[build]
command = "NITRO_PRESET=cloudflare yarn nuxt build"
upload.format = "service-worker"
その後
wrangler publish
とするだけでデプロイできます。とても楽ですね。
気になるスクリプトサイズ
Cloudflare Workers の制限について、前述の記事内のコメントを見る限り、スクリプトサイズは 1MB が上限(申請で 2MB 以上も増やせる?)とのことですが、今回は
✔ Server built
└─ .output/server/index.mjs (507 kB) (157 kB gzip)
Σ Total size: 507 kB (157 kB gzip)
という結果になりました。 圧縮後のサイズと書いてあった気がするので 157kB
のほうですかね🤔ライブラリとか入れていくと簡単に 1MB 超えそうですね...
感想
これを作る前に Firebase を使ったアプリも Cloudflare Workers へデプロイしようとしましたが Firestore を使おうとするとエラーが出ました。今回の組み合わせがうまくいくということは、キャッチコピー通り Firebase の代わりに Supabase を使う人が増えていく予感がしますね。
スクリプトサイズの肌感もちょっとわかって、この構成をする基準をなんとなく知れたのもよかったです。
総じてかなりサクサク開発できてしまうので、とても快適でした。もはやここまで楽になるとエンジニアがどんどん怠惰というかバカになっていくのではと思いましたが、悩む時間が減って考える時間が増えるんだと信じて今後もゆるくキャッチアップしようかと思います。
(ちなみに Zenn は Vue のハイライティングがちょっと残念でしたが、リンクを貼ったら展開されるのが便利でした。総じて使いやすかったです。)
Discussion