初めてでもできるNext.js(App Router) × Supabase【データベース編】

Featured image of the post

はじめに

初めてNext.js(App Router)でSupabaseを使ってみたとき、まだ解説が少なく苦戦した💦

この記事ではなるべく丁寧にNext.js × Supabaseの「導入方法」と「基本的な使い方」をまとめる😊

対象読者
  • App RouterでSupabaseを使ってみたい!
  • Supabaseを全然知らない!

この記事で解説すること
  • 環境構築
    • Next.js(App Router)のプロジェクトを新規作成する。
    • そこにSupabaseを導入する。

  • App RouterでSupabaseのデータベースを使う方法
    • その中でも最も基本的なCRUD操作のやり方を解説する。
    • これを見れば簡易なTodoアプリが作れるようになる✨

    (完成イメージ)

    Image in a image block

💡
一から丁寧に解説するので手順どおり真似すればOK!

【読み飛ばしOK】Supabaseって何?

最初にSupabaseをざっくり紹介する😊

すでに知っていれば読み飛ばしてOK!

💡
Supabaseとは
  • PostgreSQLを使ったBaaS。
  • CRUD操作や認証を素早く開発できる。

その他にも特徴はあるが今回は省略。

使用イメージ
直感的にデータベースが使える

こんな感じの自作したテーブルがあるとする。

Image in a image block

直感的にデータを取得できる✨

const { data, error } = await supabase
  .from('note')
  .select()

認証が簡単

たったこれだけでサインアップができる✨

await supabase.auth.signUp({email,password})

さらにメールでのログインだけでなく、GoogleやGitHubでのログインも簡単に実装できる!

💡
今回は認証は扱わない。

環境構築

使用する技術

事前にインストールしておくもの

  • Node.js
  • npm

今からインストールするもの

  • Next.js
  • Supabase

Next.jsのプロジェクトを新規作成

supabase-sample-appという名前のプロジェクトを作成する。

npx create-next-app supabase-sample-app --ts --no-tailwind --eslint --app --src-dir --import-alias '@/*'

作成するとこんな感じになる。

Image in a image block

プロジェクトに移動

supabase-sample-appに移動する。

cd supabase-sample-app

Supabaseのパッケージをインストール
  1. JavaScript上でSupabaseを使うためのパッケージをインストールする。
    npm install @supabase/supabase-js

  2. Next.jsでSupabaseを使うためのヘルパーをインストールする。
    npm install @supabase/ssr

  3. CLIのパッケージもインストールする。
    npm install supabase --save-dev

    ※Supabaseのローカル開発環境を作るとき、テーブルの型定義ファイルを生成するときなどに必要。

Supabaseのプロジェクトを作成

Supabaseのアカウントがない場合は先に作成しておく。

管理画面からプロジェクトを新規作成する。

Image in a image block

するとこのような画面が表示されるのでしばらく待つ(2〜3分?)

Image in a image block

作成が終わればOK!

Image in a image block

環境変数を設定

「Home」を開いて、少し下にスクロールすると「URL」と「API Key」がある。

Image in a image block

ルートディレクトリに「.env.local」という名前の空ファイルを作成する。

そこに「URL」と「API Key」を記載する。

NEXT_PUBLIC_SUPABASE_URL=先ほど確認したURL
NEXT_PUBLIC_SUPABASE_ANON_KEY=先ほど確認したanon Key

こんな感じ✨

Image in a image block

💡
これで環境構築が完了!

テーブルを作る

✅まずはTodoを保存するためのテーブルを作る。

💡
管理画面をポチポチするだけでテーブルが作れる!

テーブルを作成

「New table」からテーブルを新規作成。

Image in a image block

todosという名前のテーブルを作成する。

Image in a image block
【補足】外部キーは「Foreign Keys」から追加できる。

※今回は外部キーは不要だが念の為補足。

Image in a image block

todosテーブルが完成!

Image in a image block

💡
これでテーブルができた!

テーブルの型定義ファイルを生成

✅TypeScriptでtodosテーブルを使うために、型定義ファイルdatabase.types.tsが必要。

  1. 管理画面でReferense IDを調べる。
    Image in a image block

  2. ターミナル(コマンドプロンプト)を開いてSupabaseにログイン
    npx supabase login

  3. 型定義ファイルを生成する。

    [Referense ID]の部分は自分のリファレンスIDに置き換える)

    npx supabase gen types typescript --project-id [Reference ID] > database.types.ts

    実行した場所にdatabase.types.tsが生成される✨

    Image in a image block

  4. database.types.tsをsrc/types/database.types.tsに移動する。

    ※typesフォルダは新規作成。

    Image in a image block

App RouterでSupabaseのDBを使う

✅先ほどインストールした「@supabase/ssr」を使えば簡単にtodosテーブルを操作できる✨

Supabaseとやり取りする変数を準備する
プログラム上でSupabaseを使うには?

「Supabaseクライアント」と言われる変数を介してSupabaseとやりとりする。

App Routerの注意点

2種類のSupabaseクライアントを使い分ける必要がある!

  • サーバー用のSupabaseクライアント
    💡
    使用タイミング
    • サーバーコンポーネント
    • Server Actions
    • ルートハンドラ

  • クライアント用のSupabaseクライアント
    💡
    使用タイミング
    • クライアントコンポーネント

Supabaseクライアントを生成(サーバーコンポーネント用)

createClient(…)でSupabaseクライアントが生成できる。

Image in a image block

src/app/utils/supabase/server.ts

※utils、supabaseフォルダは新規作成。

import { createServerClient, type CookieOptions } from "@supabase/ssr";
import { cookies } from "next/headers";
import { Database } from "@/types/database.types";

export const createClient = () => {
  const cookieStore = cookies();  

  return createServerClient<Database>(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        get(name: string) {
          return cookieStore.get(name)?.value;
        },
        set(name: string, value: string, options: CookieOptions) {
          try {
            cookieStore.set({ name, value, ...options });
          } catch (error) {
            // The `set` method was called from a Server Component.
            // This can be ignored if you have middleware refreshing
            // user sessions.
          }
        },
        remove(name: string, options: CookieOptions) {
          try {
            cookieStore.set({ name, value: "", ...options });
          } catch (error) {
            // The `delete` method was called from a Server Component.
            // This can be ignored if you have middleware refreshing
            // user sessions.
          }
        },
      },
    },
  );
};

公式ドキュメントのとおりコピペすればOK。ただしTypeScriptの特性を活かすために戻り値は明示的にDatabase型を指定するよう変更した。

Supabaseクライアントを生成(クライアントコンポーネント用)

✅先ほどと同様createClient(…)でSupabaseクライアントが生成できる。

Image in a image block

src/app/utils/supabase/server.ts

import { createBrowserClient } from "@supabase/ssr";
import { Database } from "@/types/database.types";

export const createClient = () =>
  createBrowserClient<Database>(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
  );

公式ドキュメントのとおりコピペすればOK。ただしTypeScriptの特性を活かすために戻り値は明示的にDatabase型を指定するよう変更した。

💡
この2種類の「Supabaseクライアント」を介して、プログラムからSupabaseを操作する!

データ挿入
使用する関数

supabase.from(テーブル名).insert(データ)でデータを挿入できる。

const { error } = await supabase
  .from('todos')               // todosテーブルに
  .insert({ text: "テスト" })   // 「text=テスト」のデータを挿入
公式ドキュメント

💡
実際にApp Routerでデータを挿入してみる!

完成イメージ
Image in a image block

【手順1】入力フォームを作成

✅シンプルな「テキスト入力」と「ボタン」のフォームを作る。

Image in a image block

Image in a image block

src/app/insert/page.tsx

※utils、supabaseフォルダは新規作成。

'use client'

import { useState } from 'react';
import { insertData } from "./actions"; // 後で作るので今はエラーになる

const Page = () => {
  // 挿入するデータ
  const [text, setText] = useState('');

  return (
    <main>
      <form action={insertData}>
        <input type='text' value={text} name='text' onChange={ (e: React.ChangeEvent<HTMLInputElement>)=>setText(e.target.value) } />
        <button type='submit'>追加</button>
      </form>
    </main>
  );
}

export default Page;

※元々あるpage.tsxは削除してOK。

【手順2】データ挿入処理を作成

✅クリック時のデータ挿入処理(Server Actions)を作る。

Image in a image block

src/app/insert/actions.ts

'use server'

// サーバー側の処理なので、サーバー側のSupabaseクライアントを使用
import { createClient } from '@/utils/supabase/server'

/**
 * データ挿入
 * @param formData - フォームデータ
 */
export async function insertData(formData: FormData) {
  // Supabaseクライアントを作成
  const supabase = await createClient()

  // フォームから入力値を取得
  const inputs = {
    text: formData.get('text') as string,
  }

  // データ挿入
  const { error } = await supabase
    .from('todos')                  // todosテーブルに
    .insert({ text: inputs.text })  // 入力されたテキストを挿入

  // エラーが発生した場合
  if (error) {
    // ...
  }
}

動作確認
  1. アプリを起動する。
    npm run dev

  2. http://localhost:3000/insertを開き、適当なデータを追加してみる。
    Image in a image block

  3. Supabaseの管理画面で結果を確認する。
    Image in a image block

    💡
    正常に動作していることが確認できた!

  4. この後の動作確認をしやすくするために3件データを追加しておく。
    Image in a image block

データ取得
使用する関数

supabase.from(テーブル名).select()でデータを取得できる。

const { data, error } = await supabase
  .from('todos')               // todosテーブルの
  .select()                    // 全データを取得する
公式ドキュメント

💡
実際にApp Routerでデータを取得してみる!

完成イメージ
Image in a image block

【手順1】データを表示するページを作成

todosテーブルの全データを表示するページを作る。

Image in a image block

src/app/select/page.tsx

※selectフォルダは新規作成。

// サーバー側の処理なので、サーバー側のSupabaseクライアントを使用
import { createClient } from '@/utils/supabase/server'

// このページをSSRにする(これがないと本番環境でこのページはSSGになる。その結果データベースを更新しても反映されなくなる。※supabaseとは関係なく、App Routerのお話)
export const revalidate = 0;

const Page = async () => {
  // Supabaseクライアントを作成
  const supabase = createClient();

  // Todoのリストを取得
  const { data: todos, error } = await supabase
    .from('todos')
    .select()

  // エラーが発生した場合
  if (error) {
    return <div>Todoの取得でエラーが発生しました</div>
  }
  
  return (
    <main>
      {todos.length > 0 &&
        <ul>
          {todos.map(todo => (
            <li key={todo.id}>{todo.text}</li>
          ))}
        </ul>
      }
    </main>
  );
}

export default Page

動作確認
  1. アプリを起動する。
    npm run dev

  2. http://localhost:3000/selectを開くと全データが表示される。
    Image in a image block

データ更新
使用する関数

supabase.from(テーブル名).update(データ).eq(対象データ)でデータを更新できる。

const { error } = await supabase
  .from('todos')               // todosテーブルの
  .update({ text: 'あああ' })   // 対象データを「text=あああ」に更新する
  .eq('id', 1)                 // 対象は「id=1」のデータ
公式ドキュメント

💡
実際にApp Routerでデータを更新してみる!

完成イメージ
Image in a image block
Image in a image block

【手順1】データ更新できるフォームを作成

✅全データの「テキスト入力」と「ボタン」のフォームを作る。

Image in a image block

Image in a image block

src/app/update/page.tsx

※updateフォルダは新規作成。

import { updateData } from './actions' // 後で作るので今はエラーになる
// サーバー側の処理なので、サーバー側のSupabaseクライアントを使用
import { createClient } from '@/utils/supabase/server'

// このページをSSRにする(App Routerの仕様で、これがないと本番環境でこのページはSSGになる。その結果データベースを更新しても反映されなくなる。)
export const revalidate = 0;

const Page = async () => {
  // Supabaseクライアントを作成
  const supabase = createClient();

  // Todoのリストを取得
  const { data: todos, error } = await supabase
    .from('todos')
    .select()

  // エラーが発生した場合
  if (error) {
    return <div>Todoの取得でエラーが発生しました</div>
  }
  
  return (
    <main>
      {todos.length > 0 &&
        <ul>
          {/* データの数だけフォームを用意 */}
          {todos.map(todo => (
            <li key={todo.id}>
              <form action={updateData}>
                <input type='text' defaultValue={todo.text!} name='text' />
                <input type='number' defaultValue={todo.id} name='id' hidden />
                <button type='submit'>更新</button>
              </form>
            </li>
          ))}
        </ul>
      }
    </main>
  );
}

export default Page

※今回はuseStateによる入力値の状態管理はしていない。必要に応じてuseStateを使ってもいい。ただしその場合はクライアントコンポーネントになるので、クライアント側のSupabaseクライアントに置き換える。(使い方は同じ)

【手順2】データ更新処理を作成

✅クリック時のデータ更新処理(Server Actions)を作る。

Image in a image block

src/app/insert/actions.ts

'use server'

// サーバー側の処理なので、サーバー側のSupabaseクライアントを使用
import { createClient } from '@/utils/supabase/server'

/**
 * データ更新
 * @param formData - フォームデータ
 */
export async function updateData(formData: FormData) {
  // Supabaseクライアントを作成
  const supabase = await createClient()

  // フォームから入力値を取得
  const inputs = {
    id: formData.get('id') as string,
    text: formData.get('text') as string,
  }

  // データ更新
  const { error } = await supabase
    .from('todos')                  // todosテーブルを
    .update({ text: inputs.text })  // 入力されたテキストに更新する
    .eq('id', parseInt(inputs.id))  // 対象はidが一致するデータ
    
  // エラーが発生した場合
  if (error) {
    // ...
  }
}

動作確認
  1. アプリを起動する。
    npm run dev

  2. http://localhost:3000/updateを開き、適当なデータを更新してみる。
    Image in a image block

  3. Supabaseの管理画面で結果を確認する。
    Image in a image block

データ削除
使用する関数

supabase.from(テーブル名).delete().eq(対象データ)でデータを削除できる。

const { error } = await supabase
  .from('todos')               // todosテーブルから
  .delete()                    // 対象データを削除する
  .eq('id', 1)                 // 対象は「id=1」のデータ
公式ドキュメント

💡
実際にApp Routerでデータを削除してみる!

完成イメージ
Image in a image block
Image in a image block
Image in a image block

【手順1】データ削除できるフォームを作成

✅全データの「テキスト」と「ボタン」のフォームを作る。

Image in a image block

Image in a image block

src/app/delete/page.tsx

※deleteフォルダは新規作成。

import { deleteData } from './actions' // 後で作るので今はエラーになる
// サーバー側の処理なので、サーバー側のSupabaseクライアントを使用
import { createClient } from '@/utils/supabase/server'

// このページをSSRにする(これがないと本番環境でこのページはSSGになる。その結果データベースを更新しても反映されなくなる。)
export const revalidate = 0;

const Page = async () => {
  // Supabaseクライアントを作成
  const supabase = createClient();

  // Todoのリストを取得
  const { data: todos, error } = await supabase
    .from('todos')
    .select()

  // エラーが発生した場合
  if (error) {
    return <div>Todoの取得でエラーが発生しました</div>
  }
  
  return (
    <main>
      {todos.length > 0 &&
        <ul>
          {/* データの数だけフォームを用意 */}
          {todos.map(todo => (
            <li key={todo.id}>
              <form action={deleteData}>
                <input type='text' defaultValue={todo.text!} name='text' disabled />
                <input type='number' defaultValue={todo.id} name='id' hidden />
                <button type='submit'>削除</button>
              </form>
            </li>
          ))}
        </ul>
      }
    </main>
  );
}

export default Page

【手順2】データ削除処理を作成

✅クリック時のデータ削除処理(Server Actions)を作る。

Image in a image block

src/app/delete/actions.ts

'use server'

import { revalidatePath } from "next/cache";
// サーバー側の処理なので、サーバー側のSupabaseクライアントを使用
import { createClient } from '@/utils/supabase/server'

/**
 * データ削除
 * @param formData - フォームデータ
 */
export async function deleteData(formData: FormData) {
  // Supabaseクライアントを作成
  const supabase = await createClient()

  // フォームから入力値を取得
  const inputs = {
    id: formData.get('id') as string,
    text: formData.get('text') as string,
  }

  // データ削除
  const { error } = await supabase
    .from('todos')                  // todosテーブルから
    .delete()                       // 対象データを削除する
    .eq('id', parseInt(inputs.id))  // 対象はidが一致するデータ
    
  // エラーが発生した場合
  if (error) {
    // ...
  }

	// ページを再検証する(最新のデータを取得しなおす)
	revalidatePath("/delete");
}

動作確認
  1. アプリを起動する。
    npm run dev

  2. http://localhost:3000/deleteを開き、適当なデータをしてみる。
    Image in a image block

  3. Supabaseの管理画面で結果を確認する。
    Image in a image block

応用的なデータベース操作をするには

✅他にもさまざまな関数が用意されている。

応用的な操作をしたい場合は公式ドキュメントを参照する!

まとめ

App RouterでSupabaseを使ってみた。

今回は「Server Actions」を使って実装したが、他にも「ルートハンドラ」や「クリックイベント」でSupabaseを使うパターンもありそう💭

CRUD操作まとめ
// 挿入
supabase.from(テーブル名).insert(データ)

// 取得
supabase.from(テーブル名).select()

// 更新
supabase.from(テーブル名).update(データ).eq(対象データ)

// 削除
supabase.from(テーブル名).delete().eq(対象データ)

参考サイト