空雲 Blog

Eye catchsupabase + GraphQL + Next.js + React18 で SSR 対応 Todo を作る

publication: 2022/04/15
update:2024/02/20

1.Supabase の GraphQL 対応

Firebase の代替として候補に挙がることの多い https://supabase.com/ が 2022/03/29 に GraphQL に対応しました。新規でプロジェクトを作るとエンドポイント/graphql/v1 から GraphQL の機能を利用可能となっています。

2.supabase-cli でローカル環境を整える

ということで早速使っていきたいと思います。まずはsupbase-cliを使って、ローカルに開発環境を作っていきましょう。

2.1 なんだと、動かん

supbase-cli をインストールし supabase start 実行後、拡張機能 pg_graphql を有効にしても、/graphql/v1のエンドポイントが正常に機能していません。issues を見ても、特にこれと言って何も見つかりませんでした。仕方が無いのでソースコードを fork して根本的な原因を調査しました。

2.2 色々バグってる

  • supabase/postgres のバージョンが低くて graphql.resolve が存在しない

  • kong のエンドポイントの設定で余計なスラッシュがついており、目的の場所へルーティングされない

  • graphql_public スキーマが存在しておらず、GraphQL の機能を利用するために必要な graphql_public.graphql ファンクションが無い

ぶっちゃけ動くわけが無いので、足りない機能を supbase-cli に追加しました

2022/04/15時点で公式版で動くようになりました。
最終的にGraphQLのエンドポイントだけは修正されなかったので、こちらでプルリクを出して直してもらいました。

2.3 supabase-cli によるローカル環境の起動

2.3.1 supabase の起動

起動は以下のコマンドになります。

1supabase init
2supabase start

以下の内容が表示され起動します

1 API URL: http://localhost:54321
2 DB URL: postgresql://postgres:postgres@localhost:54322/postgres
3 Studio URL: http://localhost:54323
4 Inbucket URL: http://localhost:54324
5 anon key: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24ifQ.625_WdcF3KHqz5amU0x2X5WWHP-OEs_4qj0ssLNHzTs
6service_role key: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSJ9.vI9obAHOGyVVKa3pD--kJlyxp-Z2zV9UUMAhKpNLAcU

ローカルではキーが固定値になっています。cli のソースコードに埋め込まれています。

2.3.2 GraphQL の動作確認

動作確認は ApolloStudio を使うと簡単です

https://studio.apollographql.com/sandbox/explorer

左上の設定をクリックして

1Endpoint http://localhost:54321/graphql/v1
2Default headers
3 apiKey eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24ifQ.625_WdcF3KHqz5amU0x2X5WWHP-OEs_4qj0ssLNHzTs

を入力してください。これで特にエラーが出なければ接続成功です。左メニューのスキーマアイコンをクリックすると、デフォルトのデータ型などが確認出来ます。

2.3.3 Supabase Studio の動作確認

Studio URLに表示されているとおり http://localhost:54323 へアクセスします。

3.Todo アプリの作成

3.1 supabase の操作

3.1.1 テーブルの作成

Supabase Studio のテーブルエディターを開きます。テーブルエディタを使わず SQL で直接作ることも可能です。直接書く場合の注意点として、GraphQで使うにはプライマリキーが必須という部分です。

以下のようにカラムを追加してテーブルを作成します

{"width":"824px","height":"865px"}

3.1.2 Postgre 上に GraphQL スキーマの作成

SQL エディタで以下のクエリを実行します

1select graphql.rebuild_schema();

supabase-cli の改造版では start 時に実行するようにしています。

3.1.3 マイグレーションファイルの作成

1supabase db commit create_todo

以上のコマンドで supabase/migrations にマイグレーションファイルが作成されます

3.2 Next.js でフロント部分の作成

ようやくフロント開発です。

3.2.1 必要なパッケージのインストール

1yarn add @apollo/client graphql next react react-dom sass
2yarn add -D @graphql-codegen/cli @graphql-codegen/introspection @graphql-codegen/typescript @graphql-codegen/typescript-operations @graphql-codegen/typescript-react-apollo @types/react @types/react-dom typescript

以上が今回の開発で必要なものです。

3.2.2 コードジェネレータの準備

GraphQL のスキーマーを TypeScript 用のコードに変換するための設定です

  • codegen.json

1{
2 "schema": {
3 "http://localhost:54321/graphql/v1": {
4 "headers": {
5 "apiKey": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24ifQ.625_WdcF3KHqz5amU0x2X5WWHP-OEs_4qj0ssLNHzTs"
6 }
7 }
8 },
9 "overwrite": true,
10 "documents": "src/**/*.graphql",
11 "generates": {
12 "src/generated/graphql.tsx": {
13 "plugins": [
14 "typescript",
15 "typescript-operations",
16 "typescript-react-apollo"
17 ]
18 }
19 }
20}

3.2.3 GraphQL 操作用の定義を作成

以下のファイルを作成し変換をかけます

  • src/graphql/todo.graphql

1fragment todo on Todo {
2 __typename
3 id
4 title
5 description
6 created_at
7}
8mutation insertTodo($value: TodoInsertInput!) {
9 insertIntoTodoCollection(objects: [$value]) {
10 records {
11 ...todo
12 }
13 }
14}
15query queryTodo {
16 todoCollection {
17 edges {
18 node {
19 ...todo
20 }
21 }
22 }
23}
24mutation deleteTodo($id: BigInt) {
25 deleteFromTodoCollection(filter: { id: { eq: $id } }) {
26 records {
27 ...todo
28 }
29 }
30}

  • 変換

1graphql-codegen --config codegen.json

これで GraphQL の操作に必要なファイルは完成です

3.2.4 Next.js のコードを記述

  • .env.local

1NEXT_PUBLIC_SUPABASE_URL=http://localhost:54321/graphql/v1
2NEXT_PUBLIC_SUPABASE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24ifQ.625_WdcF3KHqz5amU0x2X5WWHP-OEs_4qj0ssLNHzTs

フロントからアクセスするための環境変数です

  • pages/_app.tsx

1import {
2 ApolloClient,
3 ApolloProvider,
4 InMemoryCache,
5 NormalizedCacheObject,
6} from "@apollo/client";
7import { getMarkupFromTree } from "@apollo/client/react/ssr";
8import { AppContext, AppProps } from "next/app";
9import React, { useMemo } from "react";
10const { renderToReadableStream } = require("react-dom/server.browser");
11const URI_ENDPOINT = process.env.NEXT_PUBLIC_SUPABASE_URL;
12const ApiKey = process.env.NEXT_PUBLIC_SUPABASE_KEY;
13
14const App = (
15 props: AppProps & {
16 cache?: NormalizedCacheObject;
17 memoryCache?: InMemoryCache;
18 }
19) => {
20 const { Component, cache, memoryCache } = props;
21 const client = useMemo(
22 () =>
23 new ApolloClient({
24 uri: URI_ENDPOINT,
25 cache: memoryCache || new InMemoryCache().restore(cache || {}),
26 headers: { apiKey: ApiKey! },
27 }),
28 []
29 );
30 return (
31 <ApolloProvider client={client}>
32 <Component />
33 </ApolloProvider>
34 );
35};
36
37App.getInitialProps = async ({ Component, router }: AppContext) => {
38 if (typeof window !== "undefined") return {};
39 const memoryCache = new InMemoryCache();
40 await getMarkupFromTree({
41 tree: (
42 <App
43 Component={Component}
44 pageProps={undefined}
45 router={router}
46 cache={{}}
47 memoryCache={memoryCache}
48 />
49 ),
50 renderFunction: renderToReadableStream,
51 }).catch(() => {});
52 return { cache: memoryCache.extract() };
53};
54export default App;

Next.js で SSR を行うための設定です。SSR がいらない場合は getInitialProps を消すと CSR になります。apollo-client で普通に SSR をすると React18 がご乱心遊ばされるので、少し細工をしています。

  • src/pages/index.tsx

1import { TodoContainer } from "../components/TodoContainer";
2
3const Page = () => {
4 return <TodoContainer />;
5};
6export default Page;

  • src/components/TodoContener/TodoContainer.tsx

1import { useMemo } from "react";
2import { TodoList } from "../TodoList";
3import {
4 useDeleteTodoMutation,
5 useInsertTodoMutation,
6 useQueryTodoQuery,
7} from "../../generated/graphql";
8import styled from "./index.module.scss";
9
10export const TodoContainer = () => {
11 const { data, refetch } = useQueryTodoQuery();
12 const [insertTodo] = useInsertTodoMutation();
13 const [deleteTodo] = useDeleteTodoMutation();
14 const todoList = useMemo(() => {
15 return data?.todoCollection?.edges
16 .map((v) => v.node!)
17 .sort((a, b) => (a.created_at > b.created_at ? -1 : 1));
18 }, [data]);
19
20 const handleSubmit: React.FormEventHandler<HTMLFormElement> = (e) => {
21 const form = e.target as HTMLFormElement & {
22 title: HTMLInputElement;
23 description: HTMLFormElement;
24 };
25 const title = form.title.value;
26 const description = form.description.value;
27 insertTodo({
28 variables: { value: { title, description } },
29 update: () => {
30 (e.target as HTMLFormElement).reset();
31 refetch();
32 },
33 });
34 e.preventDefault();
35 };
36 const handleDelete = (id: number) => {
37 deleteTodo({
38 variables: { id },
39 update: () => {
40 refetch();
41 },
42 });
43 };
44 return (
45 <div className={styled.root}>
46 <form onSubmit={handleSubmit}>
47 <button>Insert</button>
48 <div>
49 <input className={styled.title} id="title" placeholder="Title" />
50 </div>
51 <textarea
52 className={styled.description}
53 id="description"
54 placeholder="Description"
55 />
56 </form>
57 <TodoList todoList={todoList} onDelete={handleDelete} />
58 </div>
59 );
60};

useQueryTodoQuery などはジェネレータで自動生成されているので、簡単にプログラムを組むことが出来ます。

4.まとめ

根本的な問題があるとすると、cli から GraphQL の機能がまともに使えないということです。それさえ解消すれば GraphQL の利点を生かして Firebase よりも遙かに高い開発効率が得られます。特に TypeScript では型の恩恵が大きいので、その効果は絶大です。

今回は supabase で GraphQL を使うことが主題なので、セキュリティや認証などは全く考慮していません。その辺りをきっちり実装していくといろいろな辛みは出てきそうですが、それもプログラミングの醍醐味です。