Google Books API + Next.js + v0を使ってアプリを作成してみる

# はじめに
皆様、お疲れ様です。
株式会社Techoesアプリ開発チームのKです！
今現在、Next.jsを実務で使う機会があり、
個人でアプリを作成・開発してみることで、よりNext.jsに対する理解が深まると思ったので、今回簡単なアプリを作成してみました。



### 使用する技術
Next.js - Webフレームワーク
Google Books API - Web API
v0 - UI構築用AI
shadcn/ui - UIコンポーネント

# Webアプリの構成
シンプルなアプリを作成するということで、
今回のWebアプリは以下の4ページで構成しました。

エンドポイント|役割
-|-
/          |ホームページ
/search    |検索ページ
/record    |記録ページ
/edit/[id] |編集ページ

また、自作コンポーネントも4つほど作成しました。
コンポーネント|役割
-|-
BookForm|記録・編集共通のフォーム
Header|ヘッダー(全ページ共通)
RecordBooks|記録した本の一覧表示(ホームページで使用)
SearchResults|検索した本の一覧表示(検索ページで使用)

# 紹介するアプリについて
作ったものはGithub上にて公開しています。
紹介していない部分のコンポーネントの詳細などについてもご覧いただけます。
また、Vercelにてデプロイしていますので、全世界からこのアプリをお試しすることができます。

https://github.com/Hideki-Kobayashi-Techoes/reading-recorder

https://reading-recorder-amber.vercel.app/

# 1. v0で基礎のUIを作成
Webデザイナーとしては初心者な自分がそのまま作成すると無骨なデザインになりかねないので、今回はv0というAIを利用し、作成することにしました。
### v0について
AIを活用してWeb UIを素早くデザイン・構築できるツールです。ShadcnやTailwind CSSに対応しており、プロンプトを入力するだけでコンポーネントのコードを自動生成できます。
また、何回もAIに指示することで、自分の理想のUIより近づけることができます。

以下は今回のWebアプリの作成において自分がv0に指示をした際の、v0から提案されたアプリのスクリーンショットです。

全てのページのスクリーンショットを載せるのはあまりのも多すぎるので、今回はホーム画面だけを載せることにします。

**1回目**
`GoogleBooksAPIから本を検索し、それを記録するアプリ「Reading Recorder」を作ります。本のLPとコンポーネントを作ってください`
![スクリーンショット 2025-02-03 17.15.04.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3775505/7e4b53ea-360e-604c-1b5a-37735c4b0cfa.png)
1回目の指示ではこのようなデザインが提案されました。
英語であるのと、他のページに遷移できないので、さらに指示します。


**2回目**
`日本語で作成してください。また、他のページに飛べるようにヘッダーを追加してください`
![スクリーンショット 2025-02-03 17.17.20.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3775505/104a70c9-c467-5551-ee95-2dba1f759382.png)
日本語になり、ヘッダーが追加されました！
ホーム画面は、記録した本の一覧を見られるようにしたいので、調整します。

**3回目**
`検索したら、検索バーの下に本のコンポーネントを表示してください。 それをクリックしたら記録するページに飛びます。 1つ以上記録したらホームに記録した本一覧を表示します。`
![スクリーンショット 2025-02-03 17.17.55.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3775505/18025323-e2c0-3f81-03b6-dd46a9853821.png)
記録した本がホームページに追加されました！
ただし、記録した本がある場合は、紹介する文言を表示したくないので、さらに指示します。

**4回目**
`右上の記録するはいらないのと、もし記録した本1つ以上があるなら、LPの表示は削除して本の表示だけにしてください(記録した本が0の場合は表示してください)`
![スクリーンショット 2025-02-03 17.18.19.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3775505/ba7ba25c-fc2b-584d-41d7-45c63b1b68b2.png)
記録された本がある場合とない場合で、ホームページの内容が変更されるようになりました！
今回のWebアプリはこのデザインが基礎となりました。


# 2. 環境構築
アプリの環境構築はcreate-next-appを使用しました。
名前はreading-recorderにしました。
```
npx create-next-app reading-recorder
```
設定はすべてYesで進めました。
```terminal
✔ Would you like to use TypeScript? … No / Yes
✔ Would you like to use ESLint? … No / Yes
✔ Would you like to use Tailwind CSS? … No / Yes
✔ Would you like your code inside a `src/` directory? … No / Yes
✔ Would you like to use App Router? (recommended) … No / Yes
✔ Would you like to use Turbopack for `next dev`? … No / Yes
✔ Would you like to customize the import alias (`@/*` by default)? … No / Yes
✔ What import alias would you like configured? … @/*
```
Next.jsに必要なライブラリなども自動でインストールしてくれます。

```terminal
Using npm.

Initializing project with template: app-tw 


Installing dependencies:
- react
- react-dom
- next

Installing devDependencies:
- typescript
- @types/node
- @types/react
- @types/react-dom
- postcss
- tailwindcss
- eslint
- eslint-config-next
- @eslint/eslintrc


added 376 packages, and audited 377 packages in 26s

143 packages are looking for funding
  run `npm fund` for details

found 0 vulnerabilities
Initialized a git repository.

```
```terminal
npm run dev
```
開発用サーバーをnpm run devで開けます。
規定ではlocalhost 3000番ポートでサーバーにアクセスできます。

アクセスするとcreate-next-appで作成された初期の画面(app/page.tsx)が表示されます。

![Screenshot 2025-01-31 at 16.06.32.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3775505/dc9bdc09-6424-efee-fa4c-124f1610887f.png)



# 3. ホームページの実装
ホームページは次のような機能を持っています。
- localStorageにデータがなかったら、サイトの機能を紹介する
- localStorageにデータがあったら、RecordBooksコンポーネントにそのデータを渡す

以下が今回作成したホームページの全体のコードです。

```tsx:page.tsx
"use client";

import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { useSearchStore } from "@/store/searchStore";
import RecordBooks from "@/components/RecordBooks";

const getRecordedBooks = async (): Promise<RecordedBook[]> => {
  const existingRecords: string | null = localStorage.getItem("bookRecords");
  const records: RecordedBook[] = existingRecords ? JSON.parse(existingRecords) : [];
  return records;
};

export default function HomePage() {
  const [recordedBooks, setRecordedBooks] = useState<RecordedBook[]>([]);
  const { setSearchVisible, focusSearchInput } = useSearchStore();

  useEffect(() => {
    getRecordedBooks().then(setRecordedBooks);
  }, []);

  const handleSearchClick = () => {
    if (window.innerWidth >= 768) {
      focusSearchInput();
    } else {
      setSearchVisible(true);
    }
  };

  return (
    <div className="min-h-screen bg-gradient-to-b from-blue-100 to-white pt-16">
      <main className="container mx-auto px-4 py-16">
        {/* 読書記録が0件の場合LPを表示 */}
        {recordedBooks.length === 0 ? (
          <>
            <h1 className="text-4xl font-bold text-center mb-8">Reading Recorder</h1>
            <p className="text-xl text-center mb-12">簡単に本を検索し、あなたの読書の旅を記録しましょう。</p>
            <div className="space-y-8">
              <section className="text-center">
                <h2 className="text-2xl font-semibold mb-4">機能</h2>
                <ul className="list-disc list-inside text-left max-w-md mx-auto">
                  <li>Google Books APIを使用した本の検索</li>
                  <li>詳細な本の情報の表示</li>
                  <li>読んだ本の記録</li>
                  <li>読書の進捗管理</li>
                </ul>
              </section>
              <section className="text-center">
                <h2 className="text-2xl font-semibold mb-4">始めましょう</h2>
                <Button size="lg" onClick={handleSearchClick}>本を検索する</Button>
              </section>
            </div>
          </>
        ) : (
          <>
            <h1 className="text-4xl font-bold text-center mb-8">あなたの読書記録</h1>
            <RecordBooks books={recordedBooks} />
          </>
        )}
      </main>
    </div>
  );
}

```

getRecordedBooksでlocalStorageからデータを持ってくる処理を実装しています。
```ts
const getRecordedBooks = async (): Promise<RecordedBook[]> => {
  const existingRecords: string | null = localStorage.getItem("bookRecords");
  const records: RecordedBook[] = existingRecords ? JSON.parse(existingRecords) : [];
  return records;
};
```

localStorageにデータがなかった場合は、サイトの紹介を表示します。
```tsx
          <>
            <h1 className="text-4xl font-bold text-center mb-8">Reading Recorder</h1>
            <p className="text-xl text-center mb-12">簡単に本を検索し、あなたの読書の旅を記録しましょう。</p>
            <div className="space-y-8">
              <section className="text-center">
                <h2 className="text-2xl font-semibold mb-4">機能</h2>
                <ul className="list-disc list-inside text-left max-w-md mx-auto">
                  <li>Google Books APIを使用した本の検索</li>
                  <li>詳細な本の情報の表示</li>
                  <li>読んだ本の記録</li>
                  <li>読書の進捗管理</li>
                </ul>
              </section>
              <section className="text-center">
                <h2 className="text-2xl font-semibold mb-4">始めましょう</h2>
                <Button size="lg" onClick={handleSearchClick}>本を検索する</Button>
              </section>
            </div>
          </>
```
データがあった場合、RecordBooksに本を渡します。
```tsx
          <>
            <h1 className="text-4xl font-bold text-center mb-8">あなたの読書記録</h1>
            <RecordBooks books={recordedBooks} />
          </>
```

# 4. 検索ページの実装
検索ページは次のような機能を持っています。
- クエリ(q)に入力されたものをGoogle Books APIから取得し、それをSearchResultsコンポーネントに渡す


以下が検索ページ全体のコードです。

```tsx:search/page.tsx
"use client";

import { useState, useEffect } from "react";
import { useSearchParams } from "next/navigation";
import SearchResults from "@/components/SearchResults";
import { Suspense } from "react";
// Google Books APIを使用して本を検索する
const searchBooks = async (query: string) => {
  try {
    const response = await fetch(`/api/books?q=${encodeURIComponent(query)}`);
    const data = await response.json();

    if (!data.items) return [];
    return data.items;
  } catch (error) {
    console.error("検索中にエラーが発生しました:", error);
    return [];
  }
};

const SearchPageContent = () => {
  const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
  const searchParams = useSearchParams();
  const initialQuery = searchParams.get("q") || "";

  useEffect(() => {
    if (initialQuery) {
      handleSearch(initialQuery);
    }
  }, [initialQuery]);

  const handleSearch = async (query: string) => {
    const results = await searchBooks(query);
    setSearchResults(results);
  };

  return (
    <div className="container mx-auto px-4 py-8 pt-24">
      <h1 className="text-3xl font-bold mb-8">本を検索</h1>
      <SearchResults books={searchResults} />
    </div>
  );
};

export default function SearchPage() {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <SearchPageContent />
    </Suspense>
  );
}

```

useEffectにて、検索するロジックが呼び出されています。
```tsx
  useEffect(() => {
    if (initialQuery) {
      handleSearch(initialQuery);
    }
  }, [initialQuery]);
```

handleSearchにて、searchBooksにquery(検索ワード)を投げています。
```tsx
  const handleSearch = async (query: string) => {
    const results = await searchBooks(query);
    setSearchResults(results);
  };
```

検索する際は、/api/booksにアクセスしています。
```tsx
const searchBooks = async (query: string) => {
  try {
    const response = await fetch(`/api/books?q=${encodeURIComponent(query)}`);
    const data = await response.json();

    if (!data.items) return [];
    return data.items;
  } catch (error) {
    console.error("検索中にエラーが発生しました:", error);
    return [];
  }
};
```
/api/booksからGoogle Books APIにアクセスしています。
ネットワークに接続する際はロジックを分離しています。
```tsx:api/books/route.ts
import { NextResponse } from "next/server";



export async function GET(request: Request): Promise<NextResponse> {
  const { searchParams } = new URL(request.url);
  const query = searchParams.get("q");

  if (!query) {
    return NextResponse.json({ items: [] });
  }

  try {
    const response = await fetch(
      `https://www.googleapis.com/books/v1/volumes?q=${encodeURIComponent(query)}&maxResults=20`
    );
    const data = await response.json();

    if (!data.items) {
      return NextResponse.json({ items: [] });
    }

    const formattedData = data.items.map((item: GoogleBookItem): SearchResult => ({
      id: item.id,
      title: item.volumeInfo.title,
      authors: item.volumeInfo.authors || ["-"],
      thumbnail: item.volumeInfo.imageLinks?.thumbnail || "/placeholder.svg",
      price: item.saleInfo.listPrice?.amount || "-",
      publishedDate: item.volumeInfo.publishedDate || "-",
      publisher: item.volumeInfo.publisher || "-",
    }));

    return NextResponse.json({ items: formattedData });
  } catch (error) {
    console.error("APIエラー:", error);
    return NextResponse.json(
      { error: "検索中にエラーが発生しました" },
      { status: 500 }
    );
  }
}

```

useSearchParamsを使う場合は、Suspenseでラップする必要があります。
```tsx
<Suspense fallback={<div>Loading...</div>}>
  <SearchPageContent />
</Suspense>
```
上のコードについて詳しく知りたい場合は、ドキュメントをご参照ください。

https://nextjs.org/docs/messages/missing-suspense-with-csr-bailout

# 5. 記録ページの実装
記録ページは次のような機能を持っています。
- クエリパラメータのIDと同じidの本をlocalStorageから探し、加工し、BookFormコンポーネントに渡す
- 「保存」ボタンが押されたら、localStorageに新しい乱数ID(uuidv4)で保存する


以下が記録ページ全体のコードです。

```tsx:record/page.tsx
"use client";

import { useState, useEffect, Suspense } from "react";
import { useSearchParams } from "next/navigation";
import { useRouter } from "next/navigation";
import BookForm from "@/components/BookForm";

const getBookDetails = async (id: string): Promise<SearchResult> => {
  const response = await fetch(`/api/books/${id}`);
  if (!response.ok) {
    throw new Error("本の詳細の取得に失敗しました");
  }
  return response.json();
};

const RecordPageContent = () => {
  const searchParams = useSearchParams();
  const router = useRouter();
  const bookId = searchParams.get("id");
  const [book, setBook] = useState<SearchResult | null>(null);
  const [status, setStatus] = useState("");
  const [rating, setRating] = useState("");
  const [review, setReview] = useState("");
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    if (!bookId) return;

    getBookDetails(bookId)
      .then((book) => {
        setBook(book);
      })
      .catch((err) => {
        setError("本の詳細の取得に失敗しました");
        console.error(err);
      });
  }, [bookId]);

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();

    try {
      if (!book) {
        throw new Error("本の詳細が取得できません");
      }

      const existingRecords = localStorage.getItem("bookRecords");
      const records = existingRecords ? JSON.parse(existingRecords) : [];

      const record: RecordedBook = {
        id: self.crypto.randomUUID(),
        title: book?.title,
        authors: book?.authors,
        thumbnail: book?.thumbnail,
        price: book?.price,
        publisher: book?.publisher,
        publishedDate: book?.publishedDate,
        status,
        rating,
        review,
        createdAt: new Date().toISOString(),
      };

      records.push(record);
      localStorage.setItem("bookRecords", JSON.stringify(records));
      router.push("/");
    } catch (error) {
      console.error("エラー:", error);
      setError("記録の送信に失敗しました");
    }
  };

  if (error) return <div className="text-red-500">{error}</div>;
  if (!book) return <div>読み込み中...</div>;

  return (
    <BookForm
      book={book}
      status={status}
      rating={rating}
      review={review}
      onStatusChange={setStatus}
      onRatingChange={setRating}
      onReviewChange={(e) => setReview(e.target.value)}
      onSubmit={handleSubmit}
      pageTitle="読書記録に保存"
      submitButtonText="保存"
    />
  );
};

export default function RecordPage() {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <RecordPageContent />
    </Suspense>
  );
}

```

useEffectにて、本の詳細を取得するgetBookDetailsを呼び出します。
```tsx
useEffect(() => {
    if (!bookId) return;

    getBookDetails(bookId)
      .then((book) => {
        setBook(book);
      })
      .catch((err) => {
        setError("本の詳細の取得に失敗しました");
        console.error(err);
      });
  }, [bookId]);
```

getBookDetailにて、引数のidから本の詳細を取得しています。
```tsx
const getBookDetails = async (id: string): Promise<SearchResult> => {
  const response = await fetch(`/api/books/${id}`);
  if (!response.ok) {
    throw new Error("本の詳細の取得に失敗しました");
  }
  return response.json();
};
```

/api/books/[id]でレスポンスを加工しています。
```tsx:/api/books/[id]/route.ts
import { NextResponse } from "next/server";

export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }): Promise<NextResponse> {
  const id = (await params).id;

  try {
    const response = await fetch(`https://www.googleapis.com/books/v1/volumes/${id}`);
    const data = await response.json();

    if (!data || data.error) {
      return NextResponse.json(
        { error: "本が見つかりませんでした" },
        { status: 404 },
      );
    }

    const formattedData: SearchResult = {
      id: data.id,
      title: data.volumeInfo.title,
      authors: data.volumeInfo.authors || ["-"],
      thumbnail: data.volumeInfo.imageLinks?.thumbnail || "/placeholder.svg",
      price: data.saleInfo.listPrice?.amount || "-",
      publishedDate: data.volumeInfo.publishedDate || "-",
      publisher: data.volumeInfo.publisher || "-",
    };

    return NextResponse.json(formattedData);
  } catch (error) {
    console.error("APIエラー:", error);
    return NextResponse.json(
      { error: "本の詳細の取得に失敗しました" },
      { status: 500 }
    );
  }
}

```

「保存」ボタンを押すと、handleSubmitが呼び出されます。
```tsx
const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();

    try {
      if (!book) {
        throw new Error("本の詳細が取得できません");
      }

      const existingRecords = localStorage.getItem("bookRecords");
      const records = existingRecords ? JSON.parse(existingRecords) : [];

      const record: RecordedBook = {
        id: self.crypto.randomUUID(),
        title: book?.title,
        authors: book?.authors,
        thumbnail: book?.thumbnail,
        price: book?.price,
        publisher: book?.publisher,
        publishedDate: book?.publishedDate,
        status,
        rating,
        review,
        createdAt: new Date().toISOString(),
      };

      records.push(record);
      localStorage.setItem("bookRecords", JSON.stringify(records));
      router.push("/");
    } catch (error) {
      console.error("エラー:", error);
      setError("記録の送信に失敗しました");
    }
  };
```

# 6. 編集・削除ページの実装
編集ページは次のような機能を持っています。
- パスパラメータのIDと同じidの本をlocalStorageから探し、加工し、BookFormコンポーネントに渡す
- 「更新」ボタンが押されたら、localStorageから同じidのデータを探し、更新する
- 「削除」ボタンが押されたら、localStorageから同じidのデータを削除する


以下が編集ページ全体のコードです。

```tsx:edit/[id]/page.tsx
"use client";

import { useState, useEffect } from "react";
import { useRouter, useParams } from "next/navigation";
import BookForm from "@/components/BookForm";

const getBookDetails = async (id: string) => {
  const records = JSON.parse(localStorage.getItem("bookRecords") || "[]");
  const record = records.find((record: RecordedBook) => record.id === id);
  return record;
};

export default function EditPage() {
  const { id } = useParams();
  const router = useRouter();
  const [book, setBook] = useState<RecordedBook | null>(null);
  const [status, setStatus] = useState("");
  const [rating, setRating] = useState("");
  const [review, setReview] = useState("");

  useEffect(() => {
    getBookDetails(id as string).then((bookDetails) => {
      setBook(bookDetails);
      setStatus(bookDetails.status);
      setRating(bookDetails.rating);
      setReview(bookDetails.review);
    });
  }, [id]);

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    const records = JSON.parse(localStorage.getItem("bookRecords") || "[]");
    const recordIndex = records.findIndex(
      (record: RecordedBook) => record.id === id
    );

    if (recordIndex !== -1) {
      records[recordIndex] = {
        ...records[recordIndex],
        status,
        rating,
        review,
        createdAt: new Date().toISOString(),
      };

      localStorage.setItem("bookRecords", JSON.stringify(records));
      router.push("/");
    }
  };

  const handleDelete = () => {
    if (confirm("本当に削除してもよろしいですか？")) {
      const records = JSON.parse(localStorage.getItem("bookRecords") || "[]");
      const filteredRecords = records.filter(
        (record: RecordedBook) => record.id !== id
      );
      localStorage.setItem("bookRecords", JSON.stringify(filteredRecords));
      router.push("/");
    }
  };

  if (!book) return <div>読み込み中...</div>;

  return (
    <BookForm
      book={book}
      status={status}
      rating={rating}
      review={review}
      onStatusChange={setStatus}
      onRatingChange={setRating}
      onReviewChange={(e) => setReview(e.target.value)}
      onSubmit={handleSubmit}
      onDelete={handleDelete}
      pageTitle="読書記録の編集"
      submitButtonText="更新"
    />
  );
}

```
記録ページとレイアウトの変更はほとんどありません。(タイトルやボタンのテキストなど若干の変更があります)

「更新」ボタンを押すと、handleSubmitが呼び出されます。
```tsx
const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    const records = JSON.parse(localStorage.getItem("bookRecords") || "[]");
    const recordIndex = records.findIndex(
      (record: RecordedBook) => record.id === id
    );

    if (recordIndex !== -1) {
      records[recordIndex] = {
        ...records[recordIndex],
        status,
        rating,
        review,
        createdAt: new Date().toISOString(),
      };

      localStorage.setItem("bookRecords", JSON.stringify(records));
      router.push("/");
    }
  };
```

「削除」ボタンを押すと、handleDeleteが呼び出されます。
```tsx
  const handleDelete = () => {
    if (confirm("本当に削除してもよろしいですか？")) {
      const records = JSON.parse(localStorage.getItem("bookRecords") || "[]");
      const filteredRecords = records.filter(
        (record: RecordedBook) => record.id !== id
      );
      localStorage.setItem("bookRecords", JSON.stringify(filteredRecords));
      router.push("/");
    }
  };
```

# 参考になった書籍
このアプリはこちらの本の11章を参考に、自分なりに改良し作成いたしました。
本を作成していただいた山田 祥寛氏に敬意を評します。

https://www.sbcr.jp/product/4815619480/

# 作成してみた感想
アプリを作成開始してから終わるまで、1週間くらいかかりました。
このような規模のアプリを作成するのは初めてだったので、本当に疲れましたが、
一通りCRUDなどやUIライブラリのインストールなど、Webアプリの基礎となる部分を自分ひとりで実装できて良い学びになれたと思います。

また、わからないところがあるときはAIに聞くことで、調査にかかる時間を大幅に減らせたなと感じました。
Webアプリの作成方法が多種多様・複雑になりつつある今、AIを使ってプログラミングをするのはマストだなと感じました。

まだDBなどと通信するなどはしていませんが、今後はそちらに対しても挑戦していきたいと思います。
ありがとうございました！