SupabaseのGraphQL機能を使ってNext.jsで簡単なアプリを作ってみる

どうも、SupabaseでDevRelをしているタイラーです！

SupabaseはPostgreSQLをフル活用したBaaSで、バックエンドを１から構築しなくてもサクッとPostgreSQLを使ってアプリを構築できるサービスです。基本的には各種[クライアントライブラリー](https://www.supabase.jp/docs/reference#library-structure)を介してSupabaseに用意されたエンドポイントをコールしてデータを読み書きする形になるのですが、実はSupabaseは[pg_graphql](https://github.com/supabase/pg_graphql)というPostgreSQLの拡張機能開発していて、それを通じてGraphQLの機能も提供しています。Supabaseが提供しているGraphQLエンドポイントは[SupabaseのRLS](https://supabase.com/docs/guides/auth/row-level-security)も使えて、セキュアなアプリが簡単に作れます。

そんな[pg_graphqlのv1.0が最近リリース](https://supabase.com/blog/pg-graphql-v1)されたこともあって今回はせっかくなのでGraphQLを使って簡単なアプリケーションを作ってみようかなと思いました。とりあえずSupabaseのGraphQLで触ってみるだけなので、シンプルなTodoリストアプリを作ってみます！

![Dec-31-2022 23-18-43.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/187071/2dffff54-5fcd-d9a7-b112-57dd6826d0b6.gif)

また、今回作るアプリは完成形を[こちら](https://github.com/dshukertjr/graphql-example)に置いておくのでよかったら合わせてみてみてください。



## 今回使うスタック

- [Supabase](https://supabase.com) - データベースと認証認可機能
- [pg_graphql](https://github.com/supabase/pg_graphql) - PostgresのGraphQL用拡張機能
- [Next.js](https://nextjs.org/) - フロントエンドフレームワーク
- [Tailwind CSS](https://tailwindcss.com/) - スタイリング
- [Apollo Client](https://www.apollographql.com/docs/react/) - GraphQLクライアント


## Step1: Supabaseの準備

まず、新しくSupabaseのプロジェクトを作りましょう。こちらのリンクから新しくSupabaseのプロジェクトが作れます。

[database.new](https://database.new)

それが完了したら、テーブルを作りましょう。こちらのSQLをSupabaseのSQLエディターから走らせてください。`tasks`テーブルを作成し、そのテーブルに対してログインしたユーザーしかタスクを作成できない[RLS](https://supabase.com/docs/learn/auth-deep-dive/auth-row-level-security)の設定をしてくれます。

```sql
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が最初からインストールされているテンプレートを使いましょう。
```terminal
npx create-next-app -e with-tailwindcss --ts
```

それができたらアプリをお好きなコードエディターで開いて、必要なパッケージをインストールしていきましょう。

まずは[supabase-js](https://www.npmjs.com/package/@supabase/supabase-js)。今回は認証周りの機能だけ使います。
```terminal
npm i @supabase/supabase-js
```
そしてGraphQLクライアントとして[Apollo Client](https://www.npmjs.com/package/@apollo/client)
```terminal
npm i @apollo/client graphql
```

最後にTypescript用の型をGraphQLのquery, mutationから自動作成してくれるパッケージを入れます。ここら辺は[Apolloの公式Typescriptセットアップガイド](https://www.apollographql.com/docs/react/development-testing/static-typing/)から引っ張ってきました。
```terminal
npm i -D typescript @graphql-codegen/cli @graphql-codegen/client-preset
```

最後に`package.json`の`scripts`に`graphql-codegen`を追加しましょう。これで`npm run compile`でTypescript用の型が生成されるようになります。
```json:package.json
{
  "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
```

諸々下準備が整ったらいよいよコードを書いていきましょう。まずは各種定数を一箇所にまとめておく用のファイルを作ります。

```typescript:src/constants.ts

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](https://studio.apollographql.com/)を使って生成しました。Supabaseプロジェクトの場合、GraphQLのエンドポイントは`SUPABASE_URL/graphql/v1`になるので、ご自身のプロジェクトでApollo StudioなどのGraphQLツールを使う際はそちらのURLをお使いください。

では、型の生成に入りましょう。`npm run compile`をする前に、`codegen.ts`ファイルを作ってその中に今回使うGraphQLエンドポイントの情報や型ファイルの出力先を指定します。

```typescript:codegen.ts
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用の型ファイルを生成しちゃってください！

```terminal
npm run compile
```
`src/__generated__`にいくつかファイルが生成されたと思います。最後に`constants.ts`のインポートを編集して、今作ったファイル`gpl`を読み込むようにしてあげましょう。

👇このインポートを消して
```typescript:src/constants.ts
import { gql } from '@apollo/client'
```
👇こちらに書き換えます。これでアプリ内で型の効いた状態でGraphQLのレスポンスを扱うことができます
```typescript:src/constants.ts
import { gql } from './__generated__'
```

## Step 3: メインアプリの作成

改めて、今回作るアプリはこんな感じのものです。まず、ユーザーにログインしてもらって、ログインが完了すると下のようなタスク一覧画面に入ります。こちらの画面の下のテキストボックスから新しいタスクを作成することができ、作成したタスクは右の完了ボタンから完了にしたり未完に戻したりできます。

![Dec-31-2022 23-18-43.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/187071/2dffff54-5fcd-d9a7-b112-57dd6826d0b6.gif)

まず、`_app.tsx`から編集していきましょう。Apollo Clientのインスタンスを作り、`Provider`に渡します。その際、SupabaseのRLSを使うためにSupabase Authのアクセストークンも一緒にGraphQLのエンドポイントに`header`内で渡さないといけないので、[こちら](https://www.apollographql.com/docs/react/networking/authentication/#header)にあるコードを参考に毎回リクエスト前に最新のアクセストークンを渡すような形にしています。

```typescript:pages/_app.tsx
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`](https://www.apollographql.com/docs/react/data/queries/)を使ってデータを引っ張ってくる処理が書かれており、このコンポーネントが読み込まれた際に自動的にデータを引っ張ってくるような形になっております。そして、データを更新するための[`useMutation`](https://www.apollographql.com/docs/react/data/mutations/)も同じように定義されており、ユーザーがフォームを送信したり、ボタンを押した際に呼ばれるようになっております。mutationを使ってデータを更新した際には、`useQuery`で帰ってきた`refetchTasks`を読んで最新のデータを再読み込みするような形になっています。キャッシュをうまく使えばデータを再読み込みする必要はおそらくないのですが、今回はちょっとそこまで作り込まなくてもいいかなってことでこんな感じの若干雑な実装にしちゃいました笑

あとは最後に申し訳程度にヘッダーを単独コンポーネントとして抜き出したくらいです。こちらはログインしている状態だとログアウトボタンが表示されるようになっています。


```typescript:pages/index.tsx
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に乗せておいたので、「実際に自分で全部作るのは面倒だよ」って方はよかったら[こちら](https://graphql-example.vercel.app/)からみてみてください。

## 締め

いかがだったでしょうか？個人的には、今までGraphQLをちゃんと使ってアプリを作ったことがなかったので、若干苦労したところもありましたがいい感じにできて満足です。GraphQL派の人にもぜひSupabase使っていただきたいなと思いました！もし何かpg_graphqlに機能リクエストなどがあったらぜひジャンジャンコメントに書いちゃってください！



## 関連リンク
- [今回のアプリのソースコード](https://github.com/dshukertjr/graphql-example)
- [実際に動いてるアプリ](https://graphql-example.vercel.app/)
- [Supabase pg_graphql公式ドキュメンテーション](https://supabase.github.io/pg_graphql/)
- [pg_graphqlリリースブログ記事](https://supabase.com/blog/graphql-now-available)
- [pg_graphql v1.0発表ブログ記事](https://supabase.com/blog/pg-graphql-v1)