3
1

【爆速】Next.js × Supabase SaaS開発ツール「v1」導入ガイド

Posted at

image.png

【爆速】Next.js × Supabase SaaS開発ツール「v1」導入ガイド

はじめに

SaaSアプリケーションの開発において、迅速かつ堅牢な基盤を構築することは成功の鍵となります。

しかし、ゼロからすべてを構築するのは時間と労力がかかります。

そこで登場するのが、Middayが提供するオープンソースのスターターキット「v1」です。

最近、このフレームワークの存在を知り、せっかくなら全て使いこなしたいと思いまとめることにしました。

本記事では、v1の魅力や導入方法、具体的な活用例を通じて、SaaS開発をどのように加速させるかをご紹介します。

v1 とは?

URL: https://github.com/midday-ai/v1?tab=readme-ov-file

「v1」は、Next.js、Turborepo、Supabaseなどの最新技術を活用し、本番環境に対応したSaaSアプリケーションを迅速に構築するためのオープンソースフレームワークです。

Middayの豊富な開発経験に基づき、コードの再利用性ベストプラクティスを重視したスタックを採用しています。

主な特徴

  • 最新技術の活用: Next.js、Turborepo、Supabaseなど、現代のWeb開発をリードする技術スタックを採用。
  • モノレポ構造: 複数のアプリケーションと共有パッケージを一元管理し、コードの再利用性を高めます。
  • 包括的な機能: 認証、データベース、ストレージ、キャッシュ、レート制限、メール、分析、ジョブ管理など、SaaS構築に必要な機能を網羅。
  • 本番環境対応: スケーラブルで信頼性の高いアプリケーションを構築するための設定が初めから整備されています。
  • 学習リソース: 実践的なコード例とガイドラインを通じて、開発プロセスをサポートします。

開発環境のセットアップ

v1を使用して開発を始めるには、以下の手順に従って環境をセットアップします。

1. リポジトリのクローン

まず、v1のリポジトリをクローンします。

bunx degit midday-ai/v1 v1

2. 依存関係のインストール

次に、必要な依存関係をインストールします。

bun install

3. 環境変数の設定

各アプリケーション(api, app, web)の.env.example.envにコピーし、必要な環境変数を適切な値に更新します。

cp apps/api/.env.example apps/api/.env
cp apps/app/.env.example apps/app/.env
cp apps/web/.env.example apps/web/.env

4. 開発サーバーの起動

BunまたはTurboを使用して開発サーバーを起動します。

bun dev # 全てのアプリを開発モードで起動
bun dev:web # webアプリを開発モードで起動
bun dev:app # appを開発モードで起動
bun dev:api # apiを開発モードで起動
bun dev:email # emailアプリを開発モードで起動

# データベースのセットアップ
bun migrate # マイグレーションの実行
bun seed # シードデータの投入

アプリケーションの構造

v1はモノレポ構造を採用しており、プロジェクト全体を効率的に管理できます。以下は主なディレクトリ構成です。

v1/
├── apps/
│   ├── api/
│   ├── app/
│   └── web/
├── packages/
│   ├── analytics/
│   ├── email/
│   ├── jobs/
│   ├── kv/
│   ├── logger/
│   ├── supabase/
│   └── ui/
└── tooling/
    └── typescript/

アプリケーション

  • apps/api: Supabaseを使用したバックエンドアプリケーション。API、認証、ストレージ、リアルタイム通信、Edge Functionsを提供。
  • apps/app: 認証が必要な製品アプリケーション。ダッシュボードや投稿機能を含む。
  • apps/web: マーケティングサイト。製品紹介、ブログ、ニュースレター登録機能を提供。

共有パッケージ

  • packages/ui: Shadcn UIをベースにした共有UIコンポーネント(ボタン、入力フィールドなど)。
  • packages/supabase: Supabaseとのやり取りを抽象化するクライアント。
  • packages/jobs: Trigger.devを用いたバックグラウンドジョブ管理。
  • その他: analytics, email, kv など、アプリケーション間で共有される機能を提供するパッケージ。

ツール

  • tooling/typescript: 全てのアプリケーションとパッケージで共有されるTypeScript設定。

主要コンポーネントと技術スタックの詳細

v1フレームワークは、多岐にわたる技術とコンポーネントを統合し、SaaSアプリケーションの開発を強力にサポートします。以下に、v1に含まれる主要なコンポーネントとその用途を詳しく解説します。

フレームワークとビルドシステム

  • Next.js - Framework

    • 用途: Reactベースのフレームワークで、サーバーサイドレンダリングや静的サイト生成をサポート。ルーティング、APIルート、画像最適化などの機能を提供。
    • 利点: 高いパフォーマンスとSEO最適化、開発者体験の向上。
  • Turborepo - Build system

    • 用途: モノレポ環境での効率的なビルドとキャッシュ管理を実現。複数のパッケージやアプリケーションの依存関係を最適化。
    • 利点: ビルド時間の短縮、キャッシュによる再ビルドの最小化。
  • Biome - Linter, formatter

    • 用途: コードの整形と品質チェックを自動化。統一されたコードスタイルを維持し、エラーや警告を早期に検出。
    • 利点: コードの一貫性、バグの減少、開発速度の向上。

スタイリングとUIコンポーネント

  • TailwindCSS - Styling

    • 用途: ユーティリティファーストのCSSフレームワークで、迅速なスタイリングとレスポンシブデザインを実現。
    • 利点: 高い柔軟性、カスタマイズの容易さ、CSSの冗長性の削減。
  • Shadcn - UI components

    • 用途: 高品質で再利用可能なReactコンポーネントを提供。デザインシステムと統合され、迅速なUI構築をサポート。
    • 利点: 一貫性のあるデザイン、開発速度の向上、アクセシビリティの向上。

言語と型安全性

  • TypeScript - Type safety
    • 用途: JavaScriptに型付けを追加した言語で、コードの安全性と可読性を向上。エディタの補完や型チェックを提供。
    • 利点: バグの早期発見、コードの自己文書化、開発効率の向上。

バックエンドとデータ管理

  • Supabase - Authentication, database, storage

    • 用途: Firebaseのオープンソース代替として、認証、リアルタイムデータベース、ストレージを提供。PostgreSQLを基盤としており、拡張性が高い。
    • 利点: フルマネージドなバックエンドサービス、リアルタイム機能の簡単な実装、強力なデータ管理。
  • Upstash - Cache and rate limiting

    • 用途: サーバーレス向けのRedisベースのキャッシュサービス。レート制限やセッション管理に利用。
    • 利点: 高速なデータアクセス、スケーラビリティ、簡単な導入。

メールと通知

  • React Email - Email templates

    • 用途: Reactコンポーネントとしてメールテンプレートを作成。柔軟でカスタマイズ可能なメールデザインを提供。
    • 利点: 再利用可能なコンポーネント、開発者フレンドリーなテンプレート作成。
  • Resend - Email delivery

    • 用途: 高信頼性のメール配送サービス。API経由でメールを送信し、配信状況を追跡。
    • 利点: 高速な配信、配信ステータスのリアルタイム追跡、スケーラブルなインフラ。

国際化とテーマ管理

  • i18n - Internationalization

    • 用途: アプリケーションの多言語対応を実装。翻訳ファイルの管理やローカライズ機能を提供。
    • 利点: グローバルなユーザーベースへの対応、ユーザー体験の向上。
  • next-themes - Theme manager

    • 用途: ダークモードやカスタムテーマの管理を簡単に実装。ユーザーのテーマ選択を保存・適用。
    • 利点: ユーザー体験の向上、テーマ切り替えの簡便化。

エラーハンドリングと分析

  • Sentry - Error handling/monitoring

    • 用途: アプリケーションのエラーをリアルタイムで監視し、レポート。エラーの詳細なトラッキングと分析を提供。
    • 利点: バグの迅速な検出と修正、アプリケーションの信頼性向上。
  • OpenPanel - Analytics

    • 用途: ユーザー行動のトラッキングやイベントの送信を実装。リアルタイムのデータ分析を提供。
    • 利点: データ駆動型の意思決定、ユーザー体験の最適化。

ジョブ管理とリンク共有

  • Trigger.dev - Background jobs

    • 用途: バックグラウンドでのジョブ実行を管理。スケジュールされたタスクや非同期処理を容易に実装。
    • 利点: アプリケーションのパフォーマンス向上、複雑なタスクの管理簡便化。
  • Dub - Sharable links

    • 用途: 簡単に共有可能なリンクを生成・管理。ユーザーが生成したコンテンツへのアクセスをシンプルに提供。
    • 利点: ユーザーエクスペリエンスの向上、コンテンツ共有の促進。

その他の便利なツール

  • react-safe-action - Validated Server Actions

    • 用途: サーバーアクションのバリデーションとエラーハンドリングを強化。安全かつ信頼性の高いアクション実行をサポート。
    • 利点: セキュアなデータ操作、開発者の生産性向上。
  • nuqs - Type-safe search params state manager

    • 用途: 検索パラメータの管理を型安全に実装。URLクエリパラメータの操作を容易に。
    • 利点: バグの減少、コードの可読性向上。

デプロイ方法

v1のデプロイにはVercelが推奨されています。

Vercelへのデプロイは以下のボタンから簡単に行えます。

Deploy with Vercel

デプロイ時にはSupabaseアカウントとプロジェクトの作成が案内されるため、手順に従って設定を完了してください。

開発を進める上での修正ガイド

v1スターターキットを基に、用途に応じてプロジェクトをカスタマイズするためのガイドを以下に示します。

apps/api (Supabaseバックエンド)

  • 新しいテーブルとカラムの追加

    • apps/api/supabase/migrations に新しいSQLファイルを作成し、テーブル定義とリレーションシップを記述。
    • データベースをリセットし、マイグレーションを適用: supabase db reset
    • TypeScriptの型定義を更新: supabase gen types
  • Edge Functionsの追加

    • apps/api/supabase/functions に新しい関数を作成。
    • Supabase CLIを用いて関数をデプロイ。
  • 認証プロバイダーの追加

    • apps/api/supabase/config.toml に新しいプロバイダーの設定を追加。
    • Supabaseダッシュボードでプロバイダーを有効化。

apps/app (製品アプリケーション)

  • UIのカスタマイズ

    • packages/ui からコンポーネントをインポートし、apps/app/src で使用。
    • Tailwind CSSを用いてスタイルを調整。
    • 新しいUIコンポーネントをpackages/ui に追加。
  • 新しいページとルートの追加

    • apps/app/src/app に新しいページファイルを作成。
    • 必要に応じて、apps/app/src/middleware.ts で認証やリダイレクト処理を追加。
  • サーバーアクションの追加

    • apps/app/src/actions に新しいアクションファイルを作成。
    • react-safe-action を用いてバリデーションやエラー処理を実装。

apps/web (マーケティングサイト)

  • コンテンツの更新

    • apps/web/src/app 内のページファイルを編集。
    • アセットはapps/web/public に配置。
  • SEOの最適化

    • ページコンポーネントの metadata プロパティにタイトル、説明、OGPタグを設定。
  • コンタクトフォームの追加

    • apps/web/src/actions に新しいアクションファイルを作成。
    • フォームの送信処理やバリデーションを実装。

packages/ui (共有UIコンポーネント)

  • 新しいコンポーネントの追加

    • packages/ui/src/components に新しいコンポーネントファイルを作成。
    • Shadcn UIのスタイルガイドに沿って実装。
    • Storybookなどで動作確認とドキュメント化を推奨。
  • 既存コンポーネントのカスタマイズ

    • packages/ui/tailwind.config.ts でTailwind CSSの設定を変更。
    • コンポーネントのプロパティやスタイルを調整。

その他

  • 多言語対応の追加

    • i18n を用いて多言語対応を実装。
    • 翻訳ファイルをapps/app/src/locales または apps/web/src/locales に配置。
  • エラー監視

    • Sentryなどを用いてエラー監視とレポート機能を追加。
  • テスト

    • Jestなどでユニットテストと統合テストを作成。
    • テストカバレッジを計測し、品質を維持。

具体的なカスタマイズ例

ここでは、v1スターターキットを基に具体的な機能を追加・カスタマイズする例を紹介します。

例1: 製品アプリケーション (apps/app) に「いいね」機能を追加

1. データベースの変更 (apps/api)

新しい「いいね」テーブルを追加し、RLSポリシーを設定します。

-- Likesテーブルを作成
CREATE TABLE likes (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  post_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- likesテーブルにRLSポリシーを設定
ALTER TABLE likes ENABLE ROW LEVEL SECURITY;

-- 認証済みユーザーが自分のいいねを追加・削除できるポリシー
CREATE POLICY "Authenticated users can insert their own likes" ON likes FOR INSERT WITH CHECK (auth.uid() = user_id);
CREATE POLICY "Authenticated users can delete their own likes" ON likes FOR DELETE USING (auth.uid() = user_id);

-- インデックスを作成
CREATE INDEX idx_likes_user_id ON likes(user_id);
CREATE INDEX idx_likes_post_id ON likes(post_id);

マイグレーションを適用し、型定義を更新します。

supabase db reset
supabase gen types

2. サーバーアクションの追加 (apps/app/src/actions/post/like-post-action.ts)

「いいね」機能のロジックを実装します。

"use server";

import { authActionClient } from "@/actions/safe-action";
import { z } from "zod";

const likePostSchema = z.object({
  postId: z.string(),
});

export const likePostAction = authActionClient
  .schema(likePostSchema)
  .metadata({
    name: "like-post",
    track: {
      event: "post_liked",
      channel: "app",
    },
  })
  .action(async ({ parsedInput: { postId }, ctx: { supabase, user } }) => {
    // すでにいいね済みか確認
    const { data: existingLike } = await supabase
      .from("likes")
      .select("id")
      .eq("user_id", user.id)
      .eq("post_id", postId)
      .single();

    if (existingLike) {
      // いいね済みなら削除
      await supabase
        .from("likes")
        .delete()
        .eq("user_id", user.id)
        .eq("post_id", postId);
    } else {
      // いいねを追加
      await supabase.from("likes").insert({
        user_id: user.id,
        post_id: postId,
      });
    }

    // 更新された投稿のいいね数を返す
    const { data: updatedPost, error } = await supabase
      .from("posts")
      .select("*, likes:likes(count)")
      .eq("id", postId)
      .single();

    if (error) {
      throw error;
    }

    return updatedPost.likes[0].count;
  });

3. UIの更新 (apps/app/src/app/[locale]/(dashboard)/posts/page.tsx)

「いいね」ボタンを追加し、アクションを呼び出します。

"use client";

import { likePostAction } from "@/actions/post/like-post-action";
import { getPosts } from "@v1/supabase/queries";
import { useState } from "react";
import { Button } from "@v1/ui/button";

export default async function Page() {
  const { data: posts } = await getPosts();
  const [likes, setLikes] = useState(posts?.map(post => post.likes[0].count));

  const handleLike = async (postId: string, index: number) => {
    const newLikesCount = await likePostAction({ postId });
    const updatedLikes = [...likes];
    updatedLikes[index] = newLikesCount;
    setLikes(updatedLikes);
  };

  return (
    <div className="p-4">
      <h1 className="text-2xl font-bold mb-4">Posts</h1>
      <ul className="space-y-4">
        {posts?.map((post, index) => (
          <li key={post.id} className="flex flex-col space-y-2">
            <h3 className="text-xl font-semibold">{post.title}</h3>
            <p>{post.content}</p>
            <div className="flex items-center space-x-2">
              <span>Likes: {likes[index]}</span>
              <Button onClick={() => handleLike(post.id, index)}>Like</Button>
            </div>
          </li>
        ))}
      </ul>
    </div>
  );
}

4. 分析イベントの追加 (packages/analytics/src/events.ts)

新しいイベントpost_likedを追加します。

// ... 他のイベント

export const POST_LIKED = "post_liked";

例2: 共有UIコンポーネント (packages/ui) にアバターコンポーネントを追加

1. アバターコンポーネントの作成 (packages/ui/src/components/Avatar.tsx)

import Image from "next/image";
import { cn } from "../utils";

interface AvatarProps {
  src: string;
  size?: "sm" | "md" | "lg";
}

export const Avatar = ({ src, size = "md" }: AvatarProps) => {
  const sizeClass =
    size === "sm"
      ? "h-6 w-6"
      : size === "md"
      ? "h-8 w-8"
      : "h-12 w-12";

  return (
    <div className={cn("relative rounded-full overflow-hidden", sizeClass)}>
      <Image src={src} alt="Avatar" fill />
    </div>
  );
};

2. コンポーネントの利用

apps/appapps/webなどのアプリケーションからインポートして使用します。

import { Avatar } from "@v1/ui/avatar";

export default function UserProfile() {
  return (
    <div className="flex items-center space-x-4">
      <Avatar src="/path/to/avatar.jpg" size="lg" />
      <p>ユーザー名</p>
    </div>
  );
}

CRUD UIとAPI作成例: 「メモ」機能の追加

ここでは、apps/appに「メモ」機能を追加する例を通じて、CRUD(作成、読み取り、更新、削除)操作の実装方法を具体的に説明します。

1. データベースの変更 (apps/api)

a. テーブル作成

apps/api/supabase/migrations20241027_create_notes_table.sqlを作成します。

-- Notesテーブルを作成
CREATE TABLE notes (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  content TEXT NOT NULL,
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- NotesテーブルにRLSポリシーを設定
ALTER TABLE notes ENABLE ROW LEVEL SECURITY;

-- 認証済みユーザーが自身のメモにアクセスできるポリシー
CREATE POLICY "Authenticated users can access their own notes" ON notes FOR ALL USING (auth.uid() = user_id);

-- インデックスを作成
CREATE INDEX idx_notes_user_id ON notes(user_id);

-- updated_at カラムを更新するトリガー関数
CREATE OR REPLACE FUNCTION update_updated_at()
RETURNS TRIGGER AS $$
BEGIN
  NEW.updated_at = NOW();
  RETURN NEW;
END;
$$ language plpgsql;

-- トリガーを作成
CREATE TRIGGER update_notes_updated_at
BEFORE UPDATE ON notes
FOR EACH ROW
EXECUTE FUNCTION update_updated_at();

マイグレーションを適用し、型定義を更新します。

supabase db reset
supabase gen types

b. APIルートの作成

apps/api/supabase/functions/notes/index.tsを作成し、CRUD操作を実装します。

import { createClient } from "@v1/supabase/server";
import { serve } from "https://deno.land/std@0.188.0/http/server.ts";

serve(async (req) => {
  const supabase = createClient();
  const {
    data: { user },
  } = await supabase.auth.getUser();

  if (!user) {
    return new Response(JSON.stringify({ error: "Unauthorized" }), {
      status: 401,
    });
  }

  const url = new URL(req.url);
  const path = url.pathname.split("/").slice(3); // /functions/notes/ 以降のパスを取得

  if (req.method === "GET" && path.length === 0) {
    // 全てのメモを取得
    const { data: notes, error } = await supabase
      .from("notes")
      .select("*")
      .eq("user_id", user.id);

    if (error) {
      return new Response(JSON.stringify({ error: error.message }), {
        status: 500,
      });
    }

    return new Response(JSON.stringify({ notes }), { status: 200 });
  } else if (req.method === "GET" && path.length === 1) {
    // 特定のメモを取得
    const noteId = path[0];
    const { data: note, error } = await supabase
      .from("notes")
      .select("*")
      .eq("id", noteId)
      .eq("user_id", user.id)
      .single();

    if (error) {
      return new Response(JSON.stringify({ error: error.message }), {
        status: 500,
      });
    }

    if (!note) {
      return new Response(JSON.stringify({ error: "Note not found" }), {
        status: 404,
      });
    }

    return new Response(JSON.stringify({ note }), { status: 200 });
  } else if (req.method === "POST" && path.length === 0) {
    // 新しいメモを作成
    const { content } = await req.json();
    const { data: newNote, error } = await supabase.from("notes").insert({
      user_id: user.id,
      content,
    }).single();

    if (error) {
      return new Response(JSON.stringify({ error: error.message }), {
        status: 500,
      });
    }

    return new Response(JSON.stringify({ note: newNote }), { status: 201 });
  } else if (req.method === "PUT" && path.length === 1) {
    // メモを更新
    const noteId = path[0];
    const { content } = await req.json();
    const { data: updatedNote, error } = await supabase
      .from("notes")
      .update({ content })
      .eq("id", noteId)
      .eq("user_id", user.id)
      .single();

    if (error) {
      return new Response(JSON.stringify({ error: error.message }), {
        status: 500,
      });
    }

    if (!updatedNote) {
      return new Response(JSON.stringify({ error: "Note not found" }), {
        status: 404,
      });
    }

    return new Response(JSON.stringify({ note: updatedNote }), { status: 200 });
  } else if (req.method === "DELETE" && path.length === 1) {
    // メモを削除
    const noteId = path[0];
    const { error } = await supabase
      .from("notes")
      .delete()
      .eq("id", noteId)
      .eq("user_id", user.id);

    if (error) {
      return new Response(JSON.stringify({ error: error.message }), {
        status: 500,
      });
    }

    return new Response(null, { status: 204 });
  } else {
    return new Response(JSON.stringify({ error: "Not found" }), {
      status: 404,
    });
  }
});

Supabase CLIを用いて関数をデプロイし、設定を追加します。

supabase functions deploy notes

apps/api/supabase/config.tomlに関数の設定を追加します。

[functions]
  [functions.notes]
    enabled = true
    name = "notes"

2. UIの作成 (apps/app)

a. メモ一覧ページの作成

apps/app/src/app/[locale]/(dashboard)/notes/page.tsxを作成します。

"use client";

import { useState, useEffect } from "react";
import { Input } from "@v1/ui/input";
import { Button } from "@v1/ui/button";

interface Note {
  id: string;
  content: string;
}

export default function NotesPage() {
  const [notes, setNotes] = useState<Note[]>([]);
  const [newNoteContent, setNewNoteContent] = useState("");

  // メモを取得
  useEffect(() => {
    const fetchNotes = async () => {
      const res = await fetch("/api/notes");
      const data = await res.json();
      setNotes(data.notes);
    };

    fetchNotes();
  }, []);

  // メモ作成
  const handleCreateNote = async () => {
    const res = await fetch("/api/notes", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ content: newNoteContent }),
    });
    const data = await res.json();
    setNotes([...notes, data.note]);
    setNewNoteContent("");
  };

  // メモ更新
  const handleUpdateNote = async (id: string, newContent: string) => {
    await fetch(`/api/notes/${id}`, {
      method: "PUT",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ content: newContent }),
    });
    setNotes(notes.map((note) => (note.id === id ? { ...note, content: newContent } : note)));
  };

  // メモ削除
  const handleDeleteNote = async (id: string) => {
    await fetch(`/api/notes/${id}`, {
      method: "DELETE",
    });
    setNotes(notes.filter((note) => note.id !== id));
  };

  return (
    <div className="p-4">
      <h1 className="text-2xl font-bold mb-4">Notes</h1>
      <ul className="space-y-4">
        {notes.map((note) => (
          <li key={note.id} className="flex items-center space-x-4">
            <Input
              value={note.content}
              onChange={(e) => handleUpdateNote(note.id, e.target.value)}
              className="flex-1"
            />
            <Button onClick={() => handleDeleteNote(note.id)}>Delete</Button>
          </li>
        ))}
      </ul>
      <div className="mt-6 flex space-x-4">
        <Input
          value={newNoteContent}
          onChange={(e) => setNewNoteContent(e.target.value)}
          placeholder="New note content"
          className="flex-1"
        />
        <Button onClick={handleCreateNote}>Create Note</Button>
      </div>
    </div>
  );
}

b. APIルートの作成

apps/app/src/app/api/notes/route.tsを作成します。

import { createClient } from "@v1/supabase/server";
import { NextResponse } from "next/server";

export async function GET(request: Request) {
  const supabase = createClient();
  const {
    data: { user },
  } = await supabase.auth.getUser();

  if (!user) {
    return new NextResponse(JSON.stringify({ error: "Unauthorized" }), {
      status: 401,
    });
  }

  const { data: notes, error } = await supabase
    .from("notes")
    .select("*")
    .eq("user_id", user.id);

  if (error) {
    return new NextResponse(JSON.stringify({ error: error.message }), {
      status: 500,
    });
  }

  return NextResponse.json({ notes });
}

export async function POST(request: Request) {
  const supabase = createClient();
  const {
    data: { user },
  } = await supabase.auth.getUser();

  if (!user) {
    return new NextResponse(JSON.stringify({ error: "Unauthorized" }), {
      status: 401,
    });
  }

  const { content } = await request.json();
  const { data: newNote, error } = await supabase.from("notes").insert({
    user_id: user.id,
    content,
  }).single();

  if (error) {
    return new NextResponse(JSON.stringify({ error: error.message }), {
      status: 500,
    });
  }

  return NextResponse.json({ note: newNote }, { status: 201 });
}

c. メモ詳細ページの作成

apps/app/src/app/[locale]/(dashboard)/notes/[noteId]/page.tsxを作成します。

"use client";

import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { Input } from "@v1/ui/input";
import { Button } from "@v1/ui/button";

interface Note {
  id: string;
  content: string;
}

export default function NoteDetailPage({ params: { noteId } }: { params: { noteId: string } }) {
  const [note, setNote] = useState<Note | null>(null);
  const [content, setContent] = useState("");
  const router = useRouter();

  // メモを取得
  useEffect(() => {
    const fetchNote = async () => {
      const res = await fetch(`/api/notes/${noteId}`);
      const data = await res.json();
      setNote(data.note);
      setContent(data.note.content);
    };

    fetchNote();
  }, [noteId]);

  // メモ更新
  const handleUpdateNote = async () => {
    await fetch(`/api/notes/${noteId}`, {
      method: "PUT",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ content }),
    });
    router.push(`/dashboard/notes`); // メモ一覧ページにリダイレクト
  };

  // メモ削除
  const handleDeleteNote = async () => {
    await fetch(`/api/notes/${noteId}`, {
      method: "DELETE",
    });
    router.push(`/dashboard/notes`); // メモ一覧ページにリダイレクト
  };

  if (!note) {
    return <div>Loading...</div>;
  }

  return (
    <div className="p-4">
      <h1 className="text-2xl font-bold mb-4">Edit Note</h1>
      <Input value={content} onChange={(e) => setContent(e.target.value)} className="mb-4" />
      <div className="flex space-x-4">
        <Button onClick={handleUpdateNote}>Update Note</Button>
        <Button onClick={handleDeleteNote} variant="danger">Delete Note</Button>
      </div>
    </div>
  );
}

d. APIルートの作成

apps/app/src/app/api/notes/[noteId]/route.tsを作成します。

import { createClient } from "@v1/supabase/server";
import { NextResponse } from "next/server";

export async function GET(
  request: Request,
  { params: { noteId } }: { params: { noteId: string } }
) {
  const supabase = createClient();
  const {
    data: { user },
  } = await supabase.auth.getUser();

  if (!user) {
    return new NextResponse(JSON.stringify({ error: "Unauthorized" }), {
      status: 401,
    });
  }

  const { data: note, error } = await supabase
    .from("notes")
    .select("*")
    .eq("id", noteId)
    .eq("user_id", user.id)
    .single();

  if (error) {
    return new NextResponse(JSON.stringify({ error: error.message }), {
      status: 500,
    });
  }

  if (!note) {
    return new NextResponse(JSON.stringify({ error: "Note not found" }), {
      status: 404,
    });
  }

  return NextResponse.json({ note });
}

export async function PUT(
  request: Request,
  { params: { noteId } }: { params: { noteId: string } }
) {
  const supabase = createClient();
  const {
    data: { user },
  } = await supabase.auth.getUser();

  if (!user) {
    return new NextResponse(JSON.stringify({ error: "Unauthorized" }), {
      status: 401,
    });
  }

  const { content } = await request.json();
  const { data: updatedNote, error } = await supabase
    .from("notes")
    .update({ content })
    .eq("id", noteId)
    .eq("user_id", user.id)
    .single();

  if (error) {
    return new NextResponse(JSON.stringify({ error: error.message }), {
      status: 500,
    });
  }

  if (!updatedNote) {
    return new NextResponse(JSON.stringify({ error: "Note not found" }), {
      status: 404,
    });
  }

  return NextResponse.json({ note: updatedNote });
}

export async function DELETE(
  request: Request,
  { params: { noteId } }: { params: { noteId: string } }
) {
  const supabase = createClient();
  const {
    data: { user },
  } = await supabase.auth.getUser();

  if (!user) {
    return new NextResponse(JSON.stringify({ error: "Unauthorized" }), {
      status: 401,
    });
  }

  const { error } = await supabase
    .from("notes")
    .delete()
    .eq("id", noteId)
    .eq("user_id", user.id);

  if (error) {
    return new NextResponse(JSON.stringify({ error: error.message }), {
      status: 500,
    });
  }

  return new NextResponse(null, { status: 204 });
}

3. 実行

開発サーバーを起動し、ブラウザで機能を確認します。

bun dev

http://localhost:3000/[locale]/dashboard/notes にアクセスして、メモ機能の動作を確認してください。

学習リソース

v1スターターキットは、Middayの開発経験から得られた知見を基に構築されています。以下のリソースを活用して、さらに深く学習し、独自のアプリケーションを構築してください。

  • コードリーディング: 各コンポーネントやパッケージのコードを詳細に確認し、機能の実装方法を理解。
  • 認証フロー: Supabaseを用いた認証機能の実装例を学習。
  • データベース操作: Supabaseクエリやミューテーションの実装方法を学ぶ。
  • UIコンポーネント: Shadcn UIを活用した共有コンポーネントの作成方法を習得。
  • デプロイメントガイド: Vercelを用いたデプロイ手順や設定の詳細を学習。

まとめ

v1は、Next.jsSupabaseなどの最新技術を駆使した本番環境対応のSaaSアプリケーション開発を支援する強力なスターターキットです。

モノレポ構造や共有パッケージにより、コードの再利用性開発効率を大幅に向上させます。

この記事で紹介したセットアップ方法やカスタマイズガイドを参考に、v1を活用して迅速かつ堅牢なアプリケーション開発を進めてください。

v1の詳細や最新情報は、公式リポジトリ midday-ai/v1 をご覧ください。コミュニティへの貢献やフィードバックも大歓迎です!

関連リンク

おわりに

v1を活用することで、SaaSアプリケーションの開発が一段と効率的かつ効果的になります。

豊富な機能と柔軟なカスタマイズ性を備えたv1をぜひ試してみてください。


免責事項

本記事の情報は2024年9月時点のものです。

技術の進化やプロジェクトの変更により内容が異なる場合があります。

最新情報は公式リポジトリやドキュメントを参照してください。

3
1
2

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
3
1