1
0

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.

【Next.js 15】App Router + Server Actions + Supabaseを使って簡単な読書記録アプリを作成してみた

1
Last updated at Posted at 2025-06-27

はじめに

株式会社Techoesアプリ開発チームのKです!
Next.js 15ではServer Actionsというのが利用できます。
私が前回作成したアプリですが、最新のApp Routerの機能(Server Actions)などを活かしきって製作できていませんでした。
そこで、今回はServer Actionsを利用して前回作成したアプリを変更してみました。
また、前回はデータの保存先がlocalStorageだったのですが、今回はSupabaseを利用し、クラウド上に保存するようにしました。
Supabaseを使いログイン機能も追加しました。

前回の記事はこちらです↓

Github上にソースコードを公開しておりますので、今回紹介していないコンポーネントなどを含め、どなたでもソースコードをご覧いただけます。

また、このアプリはVercel上にデプロイされていますのでどなたでもご利用可能です。

Server Actionsについて

Server Actionsを使うことで、従来useEffectなどを使用していたものを使わずに直接コンポーネント内で、データを送信/フェッチすることができます。

App Routerについて

App RouterはNext.js 13から導入された新しいルーティング機能です。
Pages Routerとは違い、ルーティングがディレクトリベースになったことなど、変更された点が数多くあります。
変更された点の詳細については割愛させていただきますので、他のページの方をご参照お願いいたします。

Supabaseについて

Supabaseは、Firebase代替として注目を集めているフルスタックバックエンドサービスです。

  • 認証機能 - 認証機能が標準搭載!
  • APIの自動生成 - DBを作成するだけで自動的にAPIが作成される!
  • 開発者向けSDKが豊富に用意!

他にも数多くの機能があります。
今回、これを使うことでAPIを別途で作成することなく、データベースとアプリを通信することができました。

Webアプリの構成

タイトル 説明
/ 読書記録一覧表示 / Welcomeページ
/search 検索結果表示ページ
/record 検索結果の本か既存の読書記録の編集ページ

前回は検索結果の本と編集ページは別のページでしたが、今回、同じGoogleBooksIDのページは2つ以上作成できなくしましたので、同じページにしております。

DB構成

タイトル 説明
books GoogleBooksAPIの本
book_authors 本の著者(booksと関連付け)
reading_records 読書記録(booksと関連付け)

booksは必ずしも保存する必要がありませんが、今回Google Books APIとの通信を減らすためにキャッシュとして保存しております。

Supabaseの始め方

まずは、Supabase公式のページにアクセスします。

Screenshot 2025-06-27 at 10.32.58.png

Start Your Project(緑色のボタン)を押すと、ログイン画面に飛びます。

こちらのページで新規登録 or ログインします。GitHubのアカウントを持っている場合は、そちらを使うと便利かもしれません。

Screenshot 2025-06-27 at 10.31.49.png

アカウントを作成した後は、Organizationを作成します。
Organization NameとType, Planを設定します。
私個人の場合、TypeはPresonal、PlanはFreeに設定しました。

Screenshot 2025-06-27 at 10.47.06.png

Organizationを作成した後は、Projectを作成する必要があります。
Orgnization, Project Name, Database Password, Regionを設定します。
Regionは日本在住の場合は東京に設定すると良いと思います。

Screenshot 2025-06-27 at 10.50.52.png

これで準備は完了です。
各自でデータベースの設定をすることでSupabaseを使うことができます。

Screenshot 2025-06-27 at 11.11.11.png

SupabaseにてDBを作成する

SupabaseではテーブルはGUIで作成することもできますが、SQLでも作成することができます。

以下のSQLは実際に使用したSQLの一部です。(RLSの設定など一部は省略しています。)
こちらをSupabaseのSQL Editorに打ち込み、Runボタンを押すと、データベースがSupabase上に作成されます。

CREATE TABLE books (
  id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
  google_book_id TEXT NOT NULL UNIQUE,
  title TEXT NOT NULL,
  publisher TEXT,
  published_date TEXT,
  thumbnail TEXT,
  price TEXT,
  created_at TIMESTAMPTZ DEFAULT NOW(),
  updated_at TIMESTAMPTZ DEFAULT NOW()
);

CREATE TABLE book_authors (
  id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
  book_id UUID NOT NULL REFERENCES books(id),
  author_name TEXT NOT NULL,
  created_at TIMESTAMPTZ DEFAULT NOW(),
  updated_at TIMESTAMPTZ DEFAULT NOW()
);

CREATE TABLE reading_records (
  id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
  user_id UUID NOT NULL REFERENCES auth.users(id),
  book_id UUID NOT NULL REFERENCES books(id),
  status TEXT NOT NULL,
  rating INTEGER NOT NULL,
  review TEXT,
  created_at TIMESTAMPTZ DEFAULT NOW(),
  updated_at TIMESTAMPTZ DEFAULT NOW(),
  CONSTRAINT unique_user_book UNIQUE(user_id, book_id)
);

トップページの実装

アクセスして最初に表示されるページです。
今回は、ログインしていないユーザーはWelcomeページ(機能を紹介するページ)を表示、
ログインしているユーザーは読書記録を直接表示するようにしました。

page.tsx
import { getBookRecords } from "@/app/lib/records";
import { getCurrentUser } from "@/app/lib/auth";
import BookRecordList from "@/components/BookRecordList";
import WelcomePage from "@/components/WelcomePage";

export default async function Home() {
  // 認証チェック
  const user = await getCurrentUser();
  
  // ログインしていない場合はウェルカムページを表示
  if (!user) {
    return <WelcomePage />;
  }
  
  try {
    // サーバーアクションを使用して読書記録を取得
    const bookRecords = await getBookRecords();
    
    // 読書記録がある場合
    return (
      <div className="min-h-screen bg-gradient-to-b from-blue-100 to-white">
        <main className="container mx-auto px-4 py-16">
          <div className="flex justify-between items-center mb-8">
            <h1 className="text-3xl font-bold">あなたの読書記録</h1>
          </div>
          
          {/* 読書状態ごとの集計 */}
          <div className="grid grid-cols-3 gap-4 mb-8">
            <div className="bg-white p-4 rounded-lg shadow text-center">
              <p className="text-lg font-semibold">未読</p>
              <p className="text-2xl">{bookRecords.filter(bookRecord => bookRecord.status === "未読").length}</p>
            </div>
            <div className="bg-white p-4 rounded-lg shadow text-center">
              <p className="text-lg font-semibold">読書中</p>
              <p className="text-2xl">{bookRecords.filter(bookRecord => bookRecord.status === "読書中").length}</p>
            </div>
            <div className="bg-white p-4 rounded-lg shadow text-center">
              <p className="text-lg font-semibold">読了</p>
              <p className="text-2xl">{bookRecords.filter(bookRecord => bookRecord.status === "読了").length}</p>
            </div>
          </div>
          
          {/* 読書記録一覧 */}
          <BookRecordList bookRecords={bookRecords} />
        </main>
      </div>
    );
  } catch (error) {
    // エラーが発生した場合はエラーメッセージを表示
    return (
      <div className="min-h-screen bg-gradient-to-b from-blue-100 to-white">
        <main className="container mx-auto px-4 py-16">
          <div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded relative" role="alert">
            <strong className="font-bold">エラー: </strong>
            <span className="block sm:inline">読書記録の取得中にエラーが発生しました。再度お試しください。</span>
          </div>
        </main>
      </div>
    );
  }
}

検索ページの実装

検索すると表示されるページです。
検索結果がない場合の表示も用意しました。

search/page.tsx
import SearchResults from "@/components/SearchResults";
import { searchBooks } from "@/app/lib/google_books";

interface SearchPageProps {
  searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}

// メインの検索ページ
export default async function SearchPage({ searchParams }: SearchPageProps) {
  const paramsData = await searchParams;
  const query = paramsData.q as string;
  
  try {
    const books = await searchBooks(query);
    
    // 検索結果がない場合
    if (books.length === 0) {
      return (
        <div className="container mx-auto px-4 py-8">
          <h1 className="text-3xl font-bold mb-8">{query}」の検索結果</h1>
          <div className="text-center py-8">
            <p>検索結果がありません。別のキーワードをお試しください。</p>
          </div>
        </div>
      );
    }
    
    // 検索結果がある場合
    return (
      <div className="container mx-auto px-4 py-8">
        <h1 className="text-3xl font-bold mb-8">{query}」の検索結果</h1>
        <SearchResults books={books} />
      </div>
    );
  } catch (error) {
    return (
      <div className="container mx-auto px-4 py-8">
        <div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded relative" role="alert">
          <strong className="font-bold">エラー: </strong>
          <span className="block sm:inline">検索処理中にエラーが発生しました</span>
        </div>
      </div>
    );
  }
}

記録/編集ページの実装

検索した本や、記録した本をクリックすると表示されるページです。
前回は記録ページと編集ページは別のページとして実装していましたが、
今回、Google Books APIごとに1つのページとしましたので、重複しないように同じページにしました。

record/page.tsx
import { getBookDetails } from "@/app/lib/google_books";
import { getBookRecord } from "@/app/lib/records";
import BookFormClient from "@/components/BookFormClient";
import BookFormEditClient from "@/components/BookFormEditClient";

interface RecordPageProps {
  searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}

// 記録ページ(新規作成と編集を統合)
export default async function RecordPage({ searchParams }: RecordPageProps) {
  const paramsData = await searchParams;
  const bookId = paramsData.id as string;

  // 本のIDがない場合はエラー
  if (!bookId) {
    throw new Error("本のIDが指定されていません");
  }

  // Google Book IDで既存の読書記録があるか確認
  const existingRecord = await getBookRecord(bookId);

  // 既存の記録がある場合は編集モード
  if (existingRecord) {
    return (
      <div className="container mx-auto px-4 py-8">
        <h1 className="text-3xl font-bold mb-8">読書記録の編集</h1>
        <BookFormEditClient bookRecord={existingRecord} />
      </div>
    );
  }

  try {
    // 既存の記録がない場合は新規作成モード
    // 本の詳細をGoogle Books APIから取得
    const book = await getBookDetails(bookId);

    return (
      <div className="container mx-auto px-4 py-8">
        <h1 className="text-3xl font-bold mb-8">読書記録に保存</h1>
        <BookFormClient book={book} />
      </div>
    );
  } catch (error) {
    return (
      <div className="container mx-auto px-4 py-8">
        <div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded relative" role="alert">
          <strong className="font-bold">エラー: </strong>
          <span className="block sm:inline">本の詳細または読書記録の取得に失敗しました</span>
        </div>
      </div>
    );
  }
}

参考になったページについて

App Routerのデータフェッチや設計について以下のページがよく参考になりました。

また、Supabaseに関してはこれらの記事が参考になりました。

記事を作成していただいた執筆者の方々には敬意を評します。

感想

Server Actionsを使うことで、以前よりもロジックがすっきりまとまった印象を受けました。
今からNext.jsを使ってアプリを作成するときは、ServerActionsを使うことでよりよいコードを書くことができると思います。
また、Supabaseを使うことで、DBを作成するだけでAPIが自動で作成され、すごく便利なので、よければぜひお使いいただければと思います。
ここまでお読みいただきありがとうございましたm(_ _)m

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

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?