どうも、SupabaseでDevRelをしているタイラーです!
SupabaseはPostgreSQLをフル活用したBaaSで、バックエンドを1から構築しなくてもサクッとPostgreSQLを使ってアプリを構築できるサービスです。基本的には各種クライアントライブラリーを介してSupabaseに用意されたエンドポイントをコールしてデータを読み書きする形になるのですが、実はSupabaseはpg_graphqlというPostgreSQLの拡張機能開発していて、それを通じてGraphQLの機能も提供しています。Supabaseが提供しているGraphQLエンドポイントはSupabaseのRLSも使えて、セキュアなアプリが簡単に作れます。
そんなpg_graphqlのv1.0が最近リリースされたこともあって今回はせっかくなのでGraphQLを使って簡単なアプリケーションを作ってみようかなと思いました。とりあえずSupabaseのGraphQLで触ってみるだけなので、シンプルなTodoリストアプリを作ってみます!
また、今回作るアプリは完成形をこちらに置いておくのでよかったら合わせてみてみてください。
今回使うスタック
- Supabase - データベースと認証認可機能
- pg_graphql - PostgresのGraphQL用拡張機能
- Next.js - フロントエンドフレームワーク
- Tailwind CSS - スタイリング
- Apollo Client - GraphQLクライアント
Step1: Supabaseの準備
まず、新しくSupabaseのプロジェクトを作りましょう。こちらのリンクから新しくSupabaseのプロジェクトが作れます。
それが完了したら、テーブルを作りましょう。こちらのSQLをSupabaseのSQLエディターから走らせてください。tasks
テーブルを作成し、そのテーブルに対してログインしたユーザーしかタスクを作成できないRLSの設定をしてくれます。
create table if not exists tasks (
id uuid primary key not null default gen_random_uuid(),
user_id uuid not null references auth.users(id) default auth.uid(),
title text not null constraint title_length_check check (char_length(title) > 0),
is_completed boolean not null default false,
created_at timestamptz not null default now()
);
alter table public.tasks enable row level security;
create policy "Users can select their own tasks" on public.tasks for select using (auth.uid() = user_id);
create policy "Users can insert their own tasks" on public.tasks for insert with check (auth.uid() = user_id);
create policy "Users can update their own tasks" on public.tasks for update using (auth.uid() = user_id) with check (auth.uid() = user_id);
create policy "Users can delete their own tasks" on public.tasks for delete using (auth.uid() = user_id);
Step2: GraphQL周りの準備
アプリのコーディングに入っていく前にGraphQL周りの型生成だったりの準備をしていきましょう。今回使うApollo Clientは(おそらく他のGraphQLクライアントライブラリーも)GraphQL用のTypescript型を生成してくれる機能があります。そちらの設定をしておくとスムーズに開発が進められて楽なのでのんびり設定していきます。
まずはアプリがないことには何も始められないので、Next.jsのアプリをこちらのコマンドで作ります。TrailwindCSSが最初からインストールされているテンプレートを使いましょう。
npx create-next-app -e with-tailwindcss --ts
それができたらアプリをお好きなコードエディターで開いて、必要なパッケージをインストールしていきましょう。
まずはsupabase-js。今回は認証周りの機能だけ使います。
npm i @supabase/supabase-js
そしてGraphQLクライアントとしてApollo Client
npm i @apollo/client graphql
最後にTypescript用の型をGraphQLのquery, mutationから自動作成してくれるパッケージを入れます。ここら辺はApolloの公式Typescriptセットアップガイドから引っ張ってきました。
npm i -D typescript @graphql-codegen/cli @graphql-codegen/client-preset
最後にpackage.json
のscripts
にgraphql-codegen
を追加しましょう。これでnpm run compile
でTypescript用の型が生成されるようになります。
{
"scripts": {
...
"compile": "graphql-codegen --require dotenv/config --config codegen.ts dotenv_config_path=.env.local"
},
...
}
.env.local
ファイルを作り、以下の二つの環境変数を設定します。これらの値はSupabaseの管理画面内の設定画面から取得してくることができます。
NEXT_PUBLIC_SUPABASE_URL=https://yourprojectref.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=YOUR_SUPABASE_ANON_KEY
諸々下準備が整ったらいよいよコードを書いていきましょう。まずは各種定数を一箇所にまとめておく用のファイルを作ります。
import { createClient } from '@supabase/supabase-js'
import { gql } from '@apollo/client'
export const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!
export const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
export const supabase = createClient(supabaseUrl, supabaseAnonKey)
/** タスク一覧取得用クエリー */
export const tasksQuery = gql(`
query TasksQuery($orderBy: [tasksOrderBy!]) {
tasksCollection(orderBy: $orderBy) {
edges {
node {
title
is_completed
id
}
}
}
}
`)
/** 新規タスク作成用ミューテーション */
export const taskInsert = gql(`
mutation TaskMutation($objects: [tasksInsertInput!]!) {
insertIntotasksCollection(objects: $objects) {
records {
title
}
}
}
`)
/** タスクのステータス更新用ミューテーション */
export const taskUpdate = gql(`
mutation Mutation($set: tasksUpdateInput!, $filter: tasksFilter) {
updatetasksCollection(set: $set, filter: $filter) {
records {
is_completed
}
}
}
`)
僕はこのファイルの中で使うGraphQLをApollo Studioを使って生成しました。Supabaseプロジェクトの場合、GraphQLのエンドポイントはSUPABASE_URL/graphql/v1
になるので、ご自身のプロジェクトでApollo StudioなどのGraphQLツールを使う際はそちらのURLをお使いください。
では、型の生成に入りましょう。npm run compile
をする前に、codegen.ts
ファイルを作ってその中に今回使うGraphQLエンドポイントの情報や型ファイルの出力先を指定します。
import { CodegenConfig } from '@graphql-codegen/cli';
import { supabaseAnonKey, supabaseUrl } from './src/constants';
const config: CodegenConfig = {
// SupabaseのGraphQLエンドポイント
// `apikey`パラメーターは手前のAPI Gatewayを通るのに必要
schema: `${supabaseUrl}/graphql/v1?apikey=${supabaseAnonKey}`,
// 型を生成するクエリーがどこのファイルに記載されているか。
// 今回は`constants.ts`のみだが、今後の拡張性も考えてとりあえず全てのtsとtsxファイルを探すよう指定
documents: ['**/*.tsx','**/*.ts'],
// 出力したファイルをどこに置くかの指定
generates: {
'./src/__generated__/': {
preset: 'client',
plugins: [],
presetConfig: {
gqlTagName: 'gql',
}
}
},
ignoreNoDocuments: true,
};
export default config;
ここまでできたらようやく型生成ができます!Terminalでこちらのコマンドを走らせてGraphQL用の型ファイルを生成しちゃってください!
npm run compile
src/__generated__
にいくつかファイルが生成されたと思います。最後にconstants.ts
のインポートを編集して、今作ったファイルgpl
を読み込むようにしてあげましょう。
👇このインポートを消して
import { gql } from '@apollo/client'
👇こちらに書き換えます。これでアプリ内で型の効いた状態でGraphQLのレスポンスを扱うことができます
import { gql } from './__generated__'
Step 3: メインアプリの作成
改めて、今回作るアプリはこんな感じのものです。まず、ユーザーにログインしてもらって、ログインが完了すると下のようなタスク一覧画面に入ります。こちらの画面の下のテキストボックスから新しいタスクを作成することができ、作成したタスクは右の完了ボタンから完了にしたり未完に戻したりできます。
まず、_app.tsx
から編集していきましょう。Apollo Clientのインスタンスを作り、Provider
に渡します。その際、SupabaseのRLSを使うためにSupabase Authのアクセストークンも一緒にGraphQLのエンドポイントにheader
内で渡さないといけないので、こちらにあるコードを参考に毎回リクエスト前に最新のアクセストークンを渡すような形にしています。
import '../styles/globals.css'
import type { AppProps } from 'next/app'
import {
ApolloClient,
ApolloProvider,
createHttpLink,
InMemoryCache,
} from '@apollo/client'
import { supabase, supabaseAnonKey, supabaseUrl } from '../src/constants'
import { setContext } from '@apollo/client/link/context'
const httpLink = createHttpLink({
uri: `${supabaseUrl}/graphql/v1`,
})
const authLink = setContext(async (_, { headers }) => {
// get the authentication token from local storage if it exists
const session = (await supabase.auth.getSession()).data.session
// return the headers to the context so httpLink can read them
return {
headers: {
...headers,
authorization: `Bearer ${
session ? session.access_token : supabaseAnonKey
}`,
apikey: supabaseAnonKey,
},
}
})
const apolloClient = new ApolloClient({
uri: `${supabaseUrl}/graphql/v1`,
link: authLink.concat(httpLink),
cache: new InMemoryCache(),
})
function MyApp({ Component, pageProps }: AppProps) {
return (
<ApolloProvider client={apolloClient}>
<Component {...pageProps} />
</ApolloProvider>
)
}
export default MyApp
そして最後にメインページを作っていきましょう!この一つのファイルの中で色々やっちゃってるのでブレイクダウンして解説していきますね。
まず、ちょっとだけややこしくなっちゃうので、今回はサーバーサイドレンダリングは一切なしです。データを引っ張ってきたり、認証情報の確認だったりはuseEffectを使ってクライアントサイドでしか走らないようになっています。
TodoList
とLoginForm
というコンポーネントをファイルの下の方で定義しており、ユーザーの認証状態に応じてそれらを出し分けています。初めてアプリを開いたユーザーはログインフォームが表示されて、ログインが完了するとTodoリストが表示される形ですね。
TodoList
内にはまずuseQuery
を使ってデータを引っ張ってくる処理が書かれており、このコンポーネントが読み込まれた際に自動的にデータを引っ張ってくるような形になっております。そして、データを更新するためのuseMutation
も同じように定義されており、ユーザーがフォームを送信したり、ボタンを押した際に呼ばれるようになっております。mutationを使ってデータを更新した際には、useQuery
で帰ってきたrefetchTasks
を読んで最新のデータを再読み込みするような形になっています。キャッシュをうまく使えばデータを再読み込みする必要はおそらくないのですが、今回はちょっとそこまで作り込まなくてもいいかなってことでこんな感じの若干雑な実装にしちゃいました笑
あとは最後に申し訳程度にヘッダーを単独コンポーネントとして抜き出したくらいです。こちらはログインしている状態だとログアウトボタンが表示されるようになっています。
import { Session } from '@supabase/supabase-js'
import type { NextPage } from 'next'
import Head from 'next/head'
import React, { useEffect, useState } from 'react'
import { useMutation, useQuery } from '@apollo/client'
import { OrderByDirection } from '../src/__generated__/graphql'
import { supabase, taskInsert, tasksQuery, taskUpdate } from '../src/constants'
const Home: NextPage = () => {
const [session, setSession] = useState<Session | null>(null)
useEffect(() => {
const getInitialSession = async () => {
const initialSession = (await supabase.auth.getSession())?.data.session
setSession(initialSession)
}
getInitialSession()
const authListener = supabase.auth.onAuthStateChange(
async (event, session) => {
setSession(session)
}
)
return () => {
authListener.data.subscription.unsubscribe()
}
}, [])
return (
<div className="flex flex-col bg-black h-screen">
<Head>
<title>Supabase pg_graphql Example</title>
</Head>
<AppHeader isSignedIn={!!session} />
<main className="text-white flex-grow max-w-4xl mx-auto min-h-0">
{session ? <TodoList /> : <LoginForm />}
</main>
</div>
)
}
export default Home
const TodoList = (): JSX.Element => {
const {
loading,
data: queryData,
refetch: refetchTasks,
} = useQuery(tasksQuery, {
variables: {
orderBy: [
{
created_at: OrderByDirection.DescNullsFirst,
},
],
},
})
const [insertTask, { loading: mutationLoading }] = useMutation(taskInsert)
const [updateTask, { loading: updateLoading }] = useMutation(taskUpdate)
const onSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
const formElement = event.currentTarget
event.preventDefault()
const { title } = Object.fromEntries(new FormData(event.currentTarget))
if (typeof title !== 'string') return
if (!title) return
await insertTask({
variables: {
objects: [{ title }],
},
onCompleted: () => refetchTasks(),
})
formElement.reset()
}
const toggleTaskStatus = async (taskId: string, updatedStatus: boolean) => {
await updateTask({
variables: {
set: {
is_completed: updatedStatus,
},
filter: {
id: {
eq: taskId,
},
},
},
onCompleted: () => refetchTasks(),
})
}
if (loading) {
return <div>Loading</div>
}
const tasks = queryData!.tasksCollection!.edges
return (
<div className="h-full flex flex-col">
<div className="flex-grow min-h-0 overflow-y-auto">
{tasks.map((task) => (
<div key={task.node.id} className="text-lg p-1 flex">
<div className="flex-grow">{task.node.title}</div>
<button
className="px-2"
onClick={() =>
toggleTaskStatus(task.node.id, !task.node.is_completed)
}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className={`w-6 h-6 ${
task.node.is_completed
? 'stroke-green-500'
: 'stroke-gray-500'
}`}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</button>
</div>
))}
</div>
<form className="flex items-center p-1" onSubmit={onSubmit}>
<input
className="border-green-300 border bg-transparent rounded py-1 px-2 flex-grow mr-2"
type="title"
name="title"
placeholder="New Task"
/>
<button
type="submit"
disabled={mutationLoading}
className="py-1 px-4 text-lg bg-green-400 rounded text-black disabled:bg-gray-500"
>
{mutationLoading ? 'Saving...' : 'Save'}
</button>
</form>
</div>
)
}
const LoginForm = () => {
const sendMagicLink = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
const { email } = Object.fromEntries(new FormData(event.currentTarget))
if (typeof email !== 'string') return
const { error } = await supabase.auth.signInWithOtp({ email })
if (error) {
console.log(error)
alert(error.message)
} else {
alert('Check your email inbox')
}
}
return (
<form
className="flex flex-col justify-center space-y-2 max-w-md px-4 h-full"
onSubmit={sendMagicLink}
>
<input
className="border-green-300 border rounded p-2 bg-transparent text-white"
type="email"
name="email"
placeholder="Email"
/>
<button
type="submit"
className="py-1 px-4 text-lg bg-green-400 rounded text-black"
>
Send Magic Link
</button>
</form>
)
}
/** 上の`Home`をスッキリさせるためだけに抜き出したヘッダー */
const AppHeader = ({ isSignedIn }: { isSignedIn: boolean }) => {
console.log({ isSignedIn })
return (
<header className="bg-black shadow shadow-green-400 px-4">
<div className="flex max-w-4xl mx-auto items-center h-16">
<div className=" text-white text-lg flex-grow">
Supabase pg_graphql Example
</div>
{isSignedIn && (
<button
className="py-1 px-2 text-white border border-white rounded"
onClick={() => supabase.auth.signOut()}
>
Sign Out
</button>
)}
</div>
</header>
)
}
ここまでできたら、npm run dev
で実際にアプリを動かせるはずです!また、僕の方で作ったバージョンをVercelに乗せておいたので、「実際に自分で全部作るのは面倒だよ」って方はよかったらこちらからみてみてください。
締め
いかがだったでしょうか?個人的には、今までGraphQLをちゃんと使ってアプリを作ったことがなかったので、若干苦労したところもありましたがいい感じにできて満足です。GraphQL派の人にもぜひSupabase使っていただきたいなと思いました!もし何かpg_graphqlに機能リクエストなどがあったらぜひジャンジャンコメントに書いちゃってください!