はじめに
株式会社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公式のページにアクセスします。
Start Your Project(緑色のボタン)を押すと、ログイン画面に飛びます。
こちらのページで新規登録 or ログインします。GitHubのアカウントを持っている場合は、そちらを使うと便利かもしれません。
アカウントを作成した後は、Organizationを作成します。
Organization NameとType, Planを設定します。
私個人の場合、TypeはPresonal、PlanはFreeに設定しました。
Organizationを作成した後は、Projectを作成する必要があります。
Orgnization, Project Name, Database Password, Regionを設定します。
Regionは日本在住の場合は東京に設定すると良いと思います。
これで準備は完了です。
各自でデータベースの設定をすることでSupabaseを使うことができます。
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ページ(機能を紹介するページ)を表示、
ログインしているユーザーは読書記録を直接表示するようにしました。
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>
);
}
}
検索ページの実装
検索すると表示されるページです。
検索結果がない場合の表示も用意しました。
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つのページとしましたので、重複しないように同じページにしました。
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




