36
25

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

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

Last updated at Posted at 2023-01-02

どうも、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リストアプリを作ってみます!

Dec-31-2022 23-18-43.gif

また、今回作るアプリは完成形をこちらに置いておくのでよかったら合わせてみてみてください。

今回使うスタック

Step1: Supabaseの準備

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

database.new

それが完了したら、テーブルを作りましょう。こちらの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.jsonscriptsgraphql-codegenを追加しましょう。これでnpm run compileでTypescript用の型が生成されるようになります。

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

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

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

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

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用の型ファイルを生成しちゃってください!

npm run compile

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

👇このインポートを消して

src/constants.ts
import { gql } from '@apollo/client'

👇こちらに書き換えます。これでアプリ内で型の効いた状態でGraphQLのレスポンスを扱うことができます

src/constants.ts
import { gql } from './__generated__'

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

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

Dec-31-2022 23-18-43.gif

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

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を使ってクライアントサイドでしか走らないようになっています。

TodoListLoginFormというコンポーネントをファイルの下の方で定義しており、ユーザーの認証状態に応じてそれらを出し分けています。初めてアプリを開いたユーザーはログインフォームが表示されて、ログインが完了するとTodoリストが表示される形ですね。

TodoList内にはまずuseQueryを使ってデータを引っ張ってくる処理が書かれており、このコンポーネントが読み込まれた際に自動的にデータを引っ張ってくるような形になっております。そして、データを更新するためのuseMutationも同じように定義されており、ユーザーがフォームを送信したり、ボタンを押した際に呼ばれるようになっております。mutationを使ってデータを更新した際には、useQueryで帰ってきたrefetchTasksを読んで最新のデータを再読み込みするような形になっています。キャッシュをうまく使えばデータを再読み込みする必要はおそらくないのですが、今回はちょっとそこまで作り込まなくてもいいかなってことでこんな感じの若干雑な実装にしちゃいました笑

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

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に乗せておいたので、「実際に自分で全部作るのは面倒だよ」って方はよかったらこちらからみてみてください。

締め

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

関連リンク

36
25
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
36
25

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?