🏃

【爆速】Next.js + Supabase + Refineでダッシュボードを作る

2024/10/21に公開

はじめに

この記事ではフレームワークに面倒な操作は全て丸投げして、最低限の変更でクラウドのDB&認証付きのダッシュボードを作成します。
https://refine.dev/

Refineとは

Refineは、Reactベースのフレームワークで、管理画面やダッシュボードの開発を高速化できる。フレームワークが提供する機能によって、認証やCRUD(作成、読み取り、更新、削除)を扱うアプリケーションを最低限のコードで構築できる。

プラットフォーム・認証・UI・データベースなどは自分が好きな技術を選べ、それらをつなぎ合わせる役割をRefineが担う。

画像引用:https://refine.dev/docs/#use-cases

今回やること

  • supabaseのデータベースとの紐付け
  • supabase上にサンプルデータの登録
  • Refine上でデータの読み込み
  • 読み込んだデータを自作の画面で表示する

data providerの概念について

データプロバイダーは、バックエンドとフロントエンドの間に入り、両者でのデータのやりとりを標準化してくれる存在である。

フロントエンドではuseListなどのRefineが用意した標準的なフックを使ってCRUDの操作を行う。(もちろんカスタムフックを作成することも可能である。)
これにより、フロントエンドから同じフックを使ってデータを取得する場合でも、裏でSupabaseが使われているか、REST API等の他の技術が使われているかは気にしなくても良い。

以下の例では、データプロバイダーにsupabaseClientを設定している。

import { Refine, AuthProvider } from "@refinedev/core";
import { supabaseClient, dataProvider, authProvider } from "refine-supabase";

const API_URL = "https://xyzcompany.supabase.co";

const App = () => {
    return (
        <Refine
            dataProvider={dataProvider(supabaseClient)} 
            resources={[{ name: "posts" }]}
        />
    );
};

フロントエンドでデータを取得したいときはフックを使って呼び出す。返り値としてdata、isLoading、errorなどのプロパティが得られる。

import { useList } from "@refinedev/core";

const PostList = () => {
    const { data, isLoading, error } = useList({
        resource: "posts",
        config: {
            pagination: {
                current: 1,
                pageSize: 10,
            },
        },
    });

    if (isLoading) return <div>Loading...</div>;
    if (error) return <div>Error: {error.message}</div>;

    return (
        <ul>
            {data?.data.map((post) => (
                <li key={post.id}>{post.title}</li>
            ))}
        </ul>
    );
};

データプロバイダー以外にも、認証やモーダル、通知などもプロバイダーが存在している。

プロジェクトの作成

まずはnpm create refine-app@latestでプロジェクトを作成する。
構成について聞かれるので、お好きなものを選ぶ。

今回は以下の設定にした。

  • プラットフォーム:Next.js
  • データベース:supabase
  • UI:Material UI
  • 認証:supabase
~/development/nextjs_pj % npm create refine-app@latest
✔ Choose a project template · refine-nextjs
✔ What would you like to name your project?: · refine-project
? Choose your backend service to connect: … 
? Choose your backend service to connect: … 
? Choose your backend service to connect: … 
? Choose your backend service to connect: … 
? Choose your backend service to connect: … 
? Choose your backend service to connect: … 
? Choose your backend service to connect: … 
✔ Choose your backend service to connect: · data-provider-supabase
✔ Do you want to use a UI Framework?: · mui
✔ Do you want to add example pages?: · mui-example
✔ Choose a package manager: · npm
✔ Mind sharing your email? (We reach out to developers for free priority support, events, and SWAG kits. We never spam.) · 



Success! Created refine-demo at ~/development/nextjs_pj/refine-project 🚀

Start developing by:

  › cd ~/development/nextjs_pj/refine-project
  › npm run dev

実行すると、このような初期画面が立ち上がる。

'--jsx'エラー解決

私の環境ではVScodeで「'--jsx' フラグが指定されていないと、JSX を使用できません」というエラーが出た。

node_modules/@refinedev/mui/tsconfig.jsonに記述を追加すると解決する。

プロジェクト全体のtsconfig.jsonではないので注意する。

  "compilerOptions": {
    "jsx": "react", // ここを追加

(追記:node_modulesの中を書き換えるのは本来望ましくないので、他にエラーの修正方法があるかもしれない。エラーを無視しても動作するので、修正しなくても以下の操作は進められる。)

supabaseとの接続

初期状態ではRefineのデモ用APIと接続されている。
これを自身のものに書き換えるだけですぐに接続が完了する。

まずはsrc/utils/supabase/constants.tsを書き換える。

export const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL!;
export const SUPABASE_KEY = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;

// 環境変数が設定されていない場合のエラーチェック
if (!SUPABASE_URL || !SUPABASE_KEY) {
  throw new Error(
    "NEXT_PUBLIC_SUPABASE_URL と NEXT_PUBLIC_SUPABASE_ANON_KEY を .env ファイルに設定してください。"
  );
}

supabaseをブラウザで開き、新しいプロジェクトを作成した後、右上のconnentボタンから、認証情報をコピーする。それをプロジェクト直下に置いた.envにペーストする。

これで自分自身のsupabase環境と接続できた。

補足:認証について

ここまで言及していなかったが、初期状態でデータベース以外に認証でもsupabaseを利用しており、URLとKEYを設定したことで、ログイン機能は使えるようになっている。

しかし、新規登録機能はうまく動作しない。前提としてsupabaseでメアドを使った新規登録をするには届いたメールを開いて行う認証が必要なのだが、そのメール送信元のSMTP(Simple Mail Transfer Protocol)サーバー設定が必須となったためである。

そのため、新規登録をする際はsupabase上で手動で登録する。

https://zenn.dev/miyamoto610/articles/faf212aafd0997

supabase上のデータ作成

今回はオンライン学習の管理画面をイメージして以下の4つのテーブルを作成する。

  • courses
  • lessons
  • users
  • progress

ChatGPTの力を借りてSQL文を作成して、一括でsupabaseのデータベースにダミーデータを登録する。
authのユーザーとデータベースのusersをidで紐付けているため、先にSupabase Authenticationで以下の5人のユーザーを作成しておく。

  • test1@example.com
  • test2@example.com
  • test3@example.com
  • test4@example.com
  • test5@example.com

以下のSQLエディターから実行する。

SQL文
-- トランザクションの開始
BEGIN;

-- 必要な拡張機能の有効化(gen_random_uuid を使用する場合)
-- ほとんどの場合、Supabaseでは既に有効化されていますが、念のため
CREATE EXTENSION IF NOT EXISTS "pgcrypto";

-- 1. Courses テーブルの作成
CREATE TABLE IF NOT EXISTS courses (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  title TEXT NOT NULL,
  description TEXT,
  created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);

-- 2. Lessons テーブルの作成
CREATE TABLE IF NOT EXISTS lessons (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  course_id UUID NOT NULL REFERENCES courses(id) ON DELETE CASCADE,
  title TEXT NOT NULL,
  content TEXT,
  created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);

-- 3. Users テーブルの作成
CREATE TABLE IF NOT EXISTS users (
  id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
  full_name TEXT NOT NULL,
  avatar_url TEXT,
  role TEXT DEFAULT 'user',
  created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);

-- 4. Progress テーブルの作成
CREATE TABLE IF NOT EXISTS progress (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  lesson_id UUID NOT NULL REFERENCES lessons(id) ON DELETE CASCADE,
  status TEXT NOT NULL CHECK (status IN ('not_started', 'in_progress', 'completed')),
  completed_at TIMESTAMP WITH TIME ZONE,
  created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);

-- Courses テーブルのダミーデータ挿入
INSERT INTO courses (title, description) VALUES 
('JavaScript入門', 'JavaScriptの基本概念を学ぶためのコースです。'),
('React応用', 'Reactを使ったアプリケーション開発の応用技術を学びます。'),
('Pythonプログラミング', 'Pythonの基本から応用までを網羅したコースです。'),
('データベース設計基礎', 'データベースの設計とその基本理論を学びます。'),
('機械学習入門', '機械学習の基本概念とアルゴリズムを学ぶコースです。');

-- Lessons テーブルのダミーデータ挿入
INSERT INTO lessons (course_id, title, content) VALUES 
-- JavaScript入門のレッスン
((SELECT id FROM courses WHERE title = 'JavaScript入門'), '変数とデータ型', 'JavaScriptにおける変数とデータ型について説明します。'),
((SELECT id FROM courses WHERE title = 'JavaScript入門'), '関数とスコープ', 'JavaScriptの関数とスコープについて学びます。'),
-- React応用のレッスン
((SELECT id FROM courses WHERE title = 'React応用'), 'Hooksの使い方', 'React Hooksを使って状態管理を行う方法を学びます。'),
((SELECT id FROM courses WHERE title = 'React応用'), 'Context APIの使い方', 'Context APIを使用してコンポーネント間で状態を共有する方法を学びます。'),
-- Pythonプログラミングのレッスン
((SELECT id FROM courses WHERE title = 'Pythonプログラミング'), 'リストと辞書', 'Pythonのリストと辞書を操作する方法について説明します。'),
((SELECT id FROM courses WHERE title = 'Pythonプログラミング'), 'クラスとオブジェクト指向', 'Pythonのオブジェクト指向プログラミングの基礎を学びます。'),
-- データベース設計基礎のレッスン
((SELECT id FROM courses WHERE title = 'データベース設計基礎'), 'ER図の作成', 'データベースの設計におけるER図の重要性を学びます。'),
((SELECT id FROM courses WHERE title = 'データベース設計基礎'), '正規化の基本', 'データベース正規化の基礎について説明します。'),
-- 機械学習入門のレッスン
((SELECT id FROM courses WHERE title = '機械学習入門'), '教師あり学習', '教師あり学習の基本アルゴリズムを学びます。'),
((SELECT id FROM courses WHERE title = '機械学習入門'), '教師なし学習', '教師なし学習のアルゴリズムとその応用について説明します。');

-- Users テーブルのダミーデータ挿入
INSERT INTO users (id, full_name, avatar_url, role) VALUES 
((SELECT id FROM auth.users WHERE email = 'test1@example.com'), '佐藤 一郎', 'https://example.com/avatar1.png', 'user'),
((SELECT id FROM auth.users WHERE email = 'test2@example.com'), '鈴木 次郎', 'https://example.com/avatar2.png', 'admin'),
((SELECT id FROM auth.users WHERE email = 'test3@example.com'), '田中 三郎', 'https://example.com/avatar3.png', 'user'),
((SELECT id FROM auth.users WHERE email = 'test4@example.com'), '山田 花子', 'https://example.com/avatar4.png', 'user'),
((SELECT id FROM auth.users WHERE email = 'test5@example.com'), '高橋 美智子', 'https://example.com/avatar5.png', 'admin');

-- Progress テーブルのダミーデータ挿入
INSERT INTO progress (user_id, lesson_id, status, completed_at) VALUES 
-- 佐藤 一郎の進捗
((SELECT id FROM users WHERE full_name = '佐藤 一郎'), (SELECT id FROM lessons WHERE title = '変数とデータ型'), 'completed', NOW()),
-- 鈴木 次郎の進捗
((SELECT id FROM users WHERE full_name = '鈴木 次郎'), (SELECT id FROM lessons WHERE title = 'Hooksの使い方'), 'in_progress', NULL),
-- 田中 三郎の進捗
((SELECT id FROM users WHERE full_name = '田中 三郎'), (SELECT id FROM lessons WHERE title = 'リストと辞書'), 'not_started', NULL),
-- 山田 花子の進捗
((SELECT id FROM users WHERE full_name = '山田 花子'), (SELECT id FROM lessons WHERE title = 'ER図の作成'), 'completed', NOW()),
-- 高橋 美智子の進捗
((SELECT id FROM users WHERE full_name = '高橋 美智子'), (SELECT id FROM lessons WHERE title = '教師あり学習'), 'in_progress', NULL);

-- トランザクションのコミット
COMMIT;

Refineのresourceを登録する

RefineにはInferencerという便利機能がある。これを使うと、Next.js側のCRUD用ページを自動で作成してくれる。

~/development/nextjs_pj/refine-project % npx refine add resource
? Resource Name (users, products, orders etc.) courses
? Select Actions list, create, edit, show

Select Actionsは必要なものだけで良いが、一旦4つ全てを選択しておいた。

https://qiita.com/noritsune/items/98266e88b47de6f720a9
https://refine.dev/docs/packages/inferencer/#example-usage

なお、このままではサイドバーがなくなってしまうので、layout.tsx/src/app/blog-postsからそのままコピーする。
layout.tsxの中にThemedLayoutV2というmuiのコンポーネントがあり、そこでサイドバーを描画している。

残りのlessons, users, progressについても同様の作業を行う。

                    resources={[
                      {
                        name: "courses",
                        list: "/courses",
                        create: "/courses/create",
                        edit: "/courses/edit/:id",
                        show: "/courses/show/:id",
                      },
                      {
                        name: "lessons",
                        list: "/lessons",
                        create: "/lessons/create",
                        edit: "/lessons/edit/:id",
                        show: "/lessons/show/:id",
                      },
                      {
                        name: "users",
                        list: "/users",
                        create: "/users/create",
                        edit: "/users/edit/:id",
                        show: "/users/show/:id",
                      },
                      {
                        name: "progress",
                        list: "/progress",
                        create: "/progress/create",
                        edit: "/progress/edit/:id",
                        show: "/progress/show/:id",
                      },
                    ]}

()を使って、ルーティングに関係ない形でリソースのフォルダをまとめた。

再度ビルドすると先ほど作成したsupabaseのデータが表示されている。
自分自身は全くコードを書いていないにもかかわらず、簡単にデータを表示させることができる。

具体的なコードを見ると、この時点ではInferencerが動的にコードを生成している状態である。

src/app/(resources)/courses/page.tsx
import { CourseList } from "@/components/courses/list";

export default function CoursesListPage() {
  return <CourseList />;
}
src/components/courses/list.tsx
"use client";

import { MuiInferencer } from "@refinedev/inferencer/mui";

export const LessonsList = () => {
    return <MuiInferencer />;
};

Inferencerによって生成されたページでは、ページ下部のポップアップから実際に生成されたコードをコピーすることができるので、src/components/courses/list.tsxに貼り付ける。
これをベースに自分好みにデザインを変えていくとよい。

なお、コピペしただけではimportのパスが適切でなかったり、ファイル名が微妙に違ったりして動かないことが多いので、手動で修正する。


(追記:progressは不可算名詞であるから、スクショ内のサイドバーで表示されているprogressesとは不適切な表記。自動生成された際に修正し忘れていた。こういう間違いからimportのエラーも起きやすい。)

デザインを修正する

以下のようにuseListなどのフックを使ってグローバルなresourceからデータを取得できる。
他にもuseOneやuseManyなどのRefineで事前に準備されたフックがある。
データを取得した後、表示は自身で自由に作成できる。

(追記:ステータスの色がリストと円グラフの間で一致していないのは単純に設定ミス)

  const { data, isLoading } = useList({
    resource: "progress",
    pagination: { pageSize: 10 },
  });

  const { data: userData } = useList({ resource: "users" });
  const { data: lessonData } = useList({ resource: "lessons" });

https://refine.dev/docs/data/hooks/use-list/

src/components/progresses/list.tsxのコード全文
src/components/progresses/list.tsx
"use client";

import React from "react";
import { useList } from "@refinedev/core";
import {
  Box,
  Card,
  CardContent,
  Typography,
  Grid,
  Chip,
  Avatar,
} from "@mui/material";
import { School, Person } from "@mui/icons-material";
import { PieChart, Pie, Cell, ResponsiveContainer, Legend } from "recharts";

export const ProgressList = () => {
  const { data, isLoading } = useList({
    resource: "progress",
    pagination: { pageSize: 10 },
  });

  const { data: userData } = useList({ resource: "users" });
  const { data: lessonData } = useList({ resource: "lessons" });

  const statusData = React.useMemo(() => {
    if (!data?.data) return [];
    const counts = data.data.reduce((acc, item) => {
      acc[item.status] = (acc[item.status] || 0) + 1;
      return acc;
    }, {});
    return Object.entries(counts).map(([name, value]) => ({ name, value }));
  }, [data?.data]);

  const COLORS = ["#0088FE", "#00C49F", "#FFBB28", "#FF8042"];

  if (isLoading) return <Typography>読み込み中...</Typography>;

  return (
    <Box>
      <Typography variant="h4" gutterBottom>
        進捗状況
      </Typography>
      <Grid container spacing={3}>
        <Grid item xs={12} md={8}>
          {data?.data.map((item) => (
            <Card key={item.id} sx={{ mb: 2 }}>
              <CardContent>
                <Grid container spacing={2} alignItems="center">
                  <Grid item xs={12} sm={4}>
                    <Box display="flex" alignItems="center">
                      <Avatar sx={{ mr: 1 }}>
                        <Person />
                      </Avatar>
                      <Typography>
                        {
                          userData?.data?.find(
                            (user) => user.id === item.user_id
                          )?.full_name
                        }
                      </Typography>
                    </Box>
                  </Grid>
                  <Grid item xs={12} sm={4}>
                    <Box display="flex" alignItems="center">
                      <School sx={{ mr: 1, color: "primary.main" }} />
                      <Typography>
                        {
                          lessonData?.data?.find(
                            (lesson) => lesson.id === item.lesson_id
                          )?.title
                        }
                      </Typography>
                    </Box>
                  </Grid>
                  <Grid item xs={12} sm={4}>
                    <Chip
                      label={item.status === "completed" ? "完了" : "進行中"}
                      color={
                        item.status === "completed" ? "success" : "warning"
                      }
                      variant="outlined"
                    />
                  </Grid>
                </Grid>
              </CardContent>
            </Card>
          ))}
        </Grid>
        <Grid item xs={12} md={4}>
          <Card>
            <CardContent>
              <Typography variant="h6" gutterBottom>
                ステータス分布
              </Typography>
              <ResponsiveContainer width="100%" height={300}>
                <PieChart>
                  <Pie
                    data={statusData}
                    cx="50%"
                    cy="50%"
                    labelLine={false}
                    outerRadius={80}
                    fill="#8884d8"
                    dataKey="value">
                    {statusData.map((entry, index) => (
                      <Cell
                        key={`cell-${index}`}
                        fill={COLORS[index % COLORS.length]}
                      />
                    ))}
                  </Pie>
                  <Legend />
                </PieChart>
              </ResponsiveContainer>
            </CardContent>
          </Card>
        </Grid>
      </Grid>
    </Box>
  );
};

まとめ

今回はRefineを使って認証付きのダッシュボードアプリを作成した。
Refineを使うと、ダッシュボード作成で必須となる機能をフレームワークに任せられるため、高速で開発ができる。またフロントエンドやバックエンドは自分が好きなものを利用できるため開発の自由度も高い。

Refineの良い点として、公式ドキュメントが充実している点も好印象だった。私の感覚としては、とりあえず動かすよりもまずドキュメントを一読してRefineのコンセプトを理解してから触った方が理解が進んだ。

この記事で紹介してない、モーダルや通知などのさまざまな機能が紹介されているので、ぜひ公式のドキュメントを読んでみて欲しい。

参考

https://qiita.com/noritsune/items/98266e88b47de6f720a9
https://qiita.com/noritsune/items/98266e88b47de6f720a9
https://zenn.dev/sasatech_sasaki/scraps/c2bcc92296fa39

Discussion