はじめに
皆様、お疲れ様です。
株式会社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にてデプロイしていますので、全世界からこのアプリをお試しすることができます。
1. v0で基礎のUIを作成
Webデザイナーとしては初心者な自分がそのまま作成すると無骨なデザインになりかねないので、今回はv0というAIを利用し、作成することにしました。
v0について
AIを活用してWeb UIを素早くデザイン・構築できるツールです。ShadcnやTailwind CSSに対応しており、プロンプトを入力するだけでコンポーネントのコードを自動生成できます。
また、何回もAIに指示することで、自分の理想のUIより近づけることができます。
以下は今回のWebアプリの作成において自分がv0に指示をした際の、v0から提案されたアプリのスクリーンショットです。
全てのページのスクリーンショットを載せるのはあまりのも多すぎるので、今回はホーム画面だけを載せることにします。
1回目
GoogleBooksAPIから本を検索し、それを記録するアプリ「Reading Recorder」を作ります。本のLPとコンポーネントを作ってください
1回目の指示ではこのようなデザインが提案されました。
英語であるのと、他のページに遷移できないので、さらに指示します。
2回目
日本語で作成してください。また、他のページに飛べるようにヘッダーを追加してください
日本語になり、ヘッダーが追加されました!
ホーム画面は、記録した本の一覧を見られるようにしたいので、調整します。
3回目
検索したら、検索バーの下に本のコンポーネントを表示してください。 それをクリックしたら記録するページに飛びます。 1つ以上記録したらホームに記録した本一覧を表示します。
記録した本がホームページに追加されました!
ただし、記録した本がある場合は、紹介する文言を表示したくないので、さらに指示します。
4回目
右上の記録するはいらないのと、もし記録した本1つ以上があるなら、LPの表示は削除して本の表示だけにしてください(記録した本が0の場合は表示してください)
記録された本がある場合とない場合で、ホームページの内容が変更されるようになりました!
今回のWebアプリはこのデザインが基礎となりました。
2. 環境構築
アプリの環境構築はcreate-next-appを使用しました。
名前はreading-recorderにしました。
npx create-next-app reading-recorder
設定はすべてYesで進めました。
✔ 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に必要なライブラリなども自動でインストールしてくれます。
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.
npm run dev
開発用サーバーをnpm run devで開けます。
規定ではlocalhost 3000番ポートでサーバーにアクセスできます。
アクセスするとcreate-next-appで作成された初期の画面(app/page.tsx)が表示されます。
3. ホームページの実装
ホームページは次のような機能を持っています。
- localStorageにデータがなかったら、サイトの機能を紹介する
- localStorageにデータがあったら、RecordBooksコンポーネントにそのデータを渡す
以下が今回作成したホームページの全体のコードです。
"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からデータを持ってくる処理を実装しています。
const getRecordedBooks = async (): Promise<RecordedBook[]> => {
const existingRecords: string | null = localStorage.getItem("bookRecords");
const records: RecordedBook[] = existingRecords ? JSON.parse(existingRecords) : [];
return records;
};
localStorageにデータがなかった場合は、サイトの紹介を表示します。
<>
<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に本を渡します。
<>
<h1 className="text-4xl font-bold text-center mb-8">あなたの読書記録</h1>
<RecordBooks books={recordedBooks} />
</>
4. 検索ページの実装
検索ページは次のような機能を持っています。
- クエリ(q)に入力されたものをGoogle Books APIから取得し、それをSearchResultsコンポーネントに渡す
以下が検索ページ全体のコードです。
"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にて、検索するロジックが呼び出されています。
useEffect(() => {
if (initialQuery) {
handleSearch(initialQuery);
}
}, [initialQuery]);
handleSearchにて、searchBooksにquery(検索ワード)を投げています。
const handleSearch = async (query: string) => {
const results = await searchBooks(query);
setSearchResults(results);
};
検索する際は、/api/booksにアクセスしています。
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にアクセスしています。
ネットワークに接続する際はロジックを分離しています。
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でラップする必要があります。
<Suspense fallback={<div>Loading...</div>}>
<SearchPageContent />
</Suspense>
上のコードについて詳しく知りたい場合は、ドキュメントをご参照ください。
5. 記録ページの実装
記録ページは次のような機能を持っています。
- クエリパラメータのIDと同じidの本をlocalStorageから探し、加工し、BookFormコンポーネントに渡す
- 「保存」ボタンが押されたら、localStorageに新しい乱数ID(uuidv4)で保存する
以下が記録ページ全体のコードです。
"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を呼び出します。
useEffect(() => {
if (!bookId) return;
getBookDetails(bookId)
.then((book) => {
setBook(book);
})
.catch((err) => {
setError("本の詳細の取得に失敗しました");
console.error(err);
});
}, [bookId]);
getBookDetailにて、引数のidから本の詳細を取得しています。
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]でレスポンスを加工しています。
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が呼び出されます。
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のデータを削除する
以下が編集ページ全体のコードです。
"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が呼び出されます。
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が呼び出されます。
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章を参考に、自分なりに改良し作成いたしました。
本を作成していただいた山田 祥寛氏に敬意を評します。
作成してみた感想
アプリを作成開始してから終わるまで、1週間くらいかかりました。
このような規模のアプリを作成するのは初めてだったので、本当に疲れましたが、
一通りCRUDなどやUIライブラリのインストールなど、Webアプリの基礎となる部分を自分ひとりで実装できて良い学びになれたと思います。
また、わからないところがあるときはAIに聞くことで、調査にかかる時間を大幅に減らせたなと感じました。
Webアプリの作成方法が多種多様・複雑になりつつある今、AIを使ってプログラミングをするのはマストだなと感じました。
まだDBなどと通信するなどはしていませんが、今後はそちらに対しても挑戦していきたいと思います。
ありがとうございました!