Next.jsでマルチページアプリケーション#
Next.jsとPrismaでWebアプリケーションを作る5回目です。 最初からやりたい場合はナビゲーションから初回を開いてください。
今回は複数の種類のページを持つアプリケーションを作っていきます。従来のReact+Viteでは、React Routerというライブラリを使って複数ページのルーティングを実装していましたが、Next.jsには組み込みのファイルベースのルーティングシステムがあります。
アンケートアプリケーションでは、アンケートを投稿するページと、アンケート結果を閲覧するページが必要です。Next.jsのApp Routerを使って、これらのページを簡単に作成していきましょう。
Next.jsのルーティングシステム#
Next.jsのApp Routerでは、app
ディレクトリ内のフォルダ構造がそのままURLパスになります。例えば、app/about/page.tsx
というファイルを作ると、/about
というURLでアクセスできるページになります。
ディレクトリ名を[...]
で囲むと動的ルートになります。例えば、app/enquete/[id]/page.tsx
というファイルを作ると、/enquete/123
のようなURLでアクセスでき、123
という値をパラメータとして取得できます。
アンケートの型定義#
まずは、アンケートを表す型を作成します。models/Enquete.ts
というファイルを作成しましょう:
export default class Enquete {
public id: number = 0
public point: number = 0
public message: string = ""
public mail: string = ""
public editing: boolean = true
}
アンケート登録ページの作成#
次に、アンケートを登録するページを作成します。App Routerでは、app/enquete/new/page.tsx
というファイルを作成することで、/enquete/new
というURLでアクセスできるページを作れます:
// このファイルは app/enquete/new/page.tsx に保存します
import EnqueteForm from '@/components/EnqueteForm';
export default function NewEnquetePage() {
return (
<div className="container mx-auto p-4">
<h1 className="text-2xl font-bold mb-6">アンケート登録</h1>
<EnqueteForm />
</div>
);
}
アンケートフォームのコンポーネントを別ファイルに作成します。src/components/EnqueteForm.tsx
というファイルを作成します:
'use client';
import { ChangeEvent, useState } from 'react';
import Enquete from '@/models/Enquete';
import { useRouter } from 'next/navigation';
export default function EnqueteForm() {
const router = useRouter();
const [enquete, setEnquete] = useState(new Enquete());
const [enqueteSended, setEnqueteSended] = useState(false);
function enqueteChanged(event: ChangeEvent<HTMLInputElement> | ChangeEvent<HTMLTextAreaElement>) {
setEnquete({...enquete, [event.target.name]: event.target.value});
}
async function sendEnquete() {
await fetch('/api/enquetes', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(enquete)
});
setEnqueteSended(true);
// 少し待ってからホームページにリダイレクト
setTimeout(() => {
router.push('/');
}, 3000);
}
let workArea;
if (enqueteSended) {
workArea = <p className="text-green-600 mt-4">アンケートの送信をありがとうございました。ホームページに戻ります...</p>
} else if (enquete.editing) {
workArea = <p className="mt-4">
<button
onClick={() => { setEnquete({ ...enquete, editing: false }) }}
className="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600"
>
確認
</button>
</p>
} else {
workArea = <p className="mt-4">
<button
onClick={() => { setEnquete({ ...enquete, editing: true }) }}
className="bg-gray-500 text-white px-4 py-2 rounded hover:bg-gray-600 mr-2"
>
キャンセル
</button>
<button
onClick={sendEnquete}
className="bg-green-500 text-white px-4 py-2 rounded hover:bg-green-600"
>
送信
</button>
</p>
}
return (
<div className="bg-white p-6 rounded shadow-md">
<div className="mb-4">
<label htmlFor="point" className="block text-sm font-medium text-gray-700">点数:</label>
<input
type="number"
id="point"
name="point"
min="1"
max="5"
onChange={enqueteChanged}
disabled={!enquete.editing}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-500 focus:ring-opacity-50"
/>
</div>
<div className="mb-4">
<label htmlFor="message" className="block text-sm font-medium text-gray-700">メッセージ:</label>
<textarea
id="message"
name="message"
onChange={enqueteChanged}
disabled={!enquete.editing}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-500 focus:ring-opacity-50"
rows={4}
></textarea>
</div>
<div className="mb-4">
<label htmlFor="mail" className="block text-sm font-medium text-gray-700">メールアドレス:</label>
<input
type="email"
id="mail"
name="mail"
onChange={enqueteChanged}
disabled={!enquete.editing}
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-500 focus:ring-opacity-50"
/>
</div>
{workArea}
</div>
);
}
アンケート一覧ページの作成#
アンケート結果を一覧表示するページも作成しましょう。app/enquete/list/page.tsx
というファイルを作成します:
import { prisma } from '@/lib/prisma';
import Link from 'next/link';
// このコンポーネントはサーバーコンポーネントとして動作し、ビルド時やリクエスト時にデータを取得します
export default async function EnqueteListPage() {
// Prismaを使ってデータベースからアンケート一覧を取得
const enquetes = await prisma.enquete.findMany({
orderBy: {
id: 'desc'
}
});
return (
<div className="container mx-auto p-4">
<h1 className="text-2xl font-bold mb-6">アンケート一覧</h1>
<div className="mb-4">
<Link
href="/enquete/new"
className="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600"
>
新規アンケート作成
</Link>
</div>
{enquetes.length === 0 ? (
<p>アンケートがまだありません。</p>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{enquetes.map(enquete => (
<div key={enquete.id} className="bg-white p-4 rounded shadow">
<h2 className="text-lg font-semibold">評価: {enquete.point}点</h2>
<p className="mt-2">{enquete.message}</p>
<p className="mt-2 text-gray-500 text-sm">投稿者: {enquete.mail}</p>
<Link
href={`/enquete/${enquete.id}`}
className="text-blue-500 hover:underline mt-2 inline-block"
>
詳細を見る
</Link>
</div>
))}
</div>
)}
</div>
);
}
アンケート詳細ページの作成#
個々のアンケートの詳細を表示するページも作成しましょう。app/enquete/[id]/page.tsx
というファイルを作成します:
import { prisma } from '@/lib/prisma';
import Link from 'next/link';
import { notFound } from 'next/navigation';
// URLのパラメータの型定義
interface EnqueteDetailPageProps {
params: {
id: string;
}
}
export default async function EnqueteDetailPage({ params }: EnqueteDetailPageProps) {
const id = parseInt(params.id);
// IDが数値でない場合は404エラーを表示
if (isNaN(id)) {
notFound();
}
// Prismaを使ってデータベースからアンケートを取得
const enquete = await prisma.enquete.findUnique({
where: { id }
});
// アンケートが見つからなかった場合は404エラーを表示
if (!enquete) {
notFound();
}
return (
<div className="container mx-auto p-4">
<h1 className="text-2xl font-bold mb-6">アンケート詳細</h1>
<div className="bg-white p-6 rounded shadow-md">
<h2 className="text-xl font-semibold">評価: {enquete.point}点</h2>
<p className="mt-4 whitespace-pre-wrap">{enquete.message}</p>
<p className="mt-4 text-gray-500">投稿者: {enquete.mail}</p>
<p className="mt-4 text-gray-400 text-sm">ID: {enquete.id}</p>
<div className="mt-6">
<Link
href="/enquete/list"
className="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600"
>
一覧に戻る
</Link>
</div>
</div>
</div>
);
}
404ページのカスタマイズ#
存在しないページにアクセスされた場合のカスタム404ページも作成しましょう。app/not-found.tsx
というファイルを作成します:
import Link from 'next/link';
export default function NotFound() {
return (
<div className="flex flex-col items-center justify-center min-h-screen text-center p-4">
<h1 className="text-4xl font-bold mb-4">404 - ページが見つかりません</h1>
<p className="text-xl mb-8">お探しのページは存在しないか、削除された可能性があります。</p>
<Link
href="/"
className="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600"
>
ホームページに戻る
</Link>
</div>
);
}
ナビゲーションの追加#
アプリケーションにナビゲーションバーを追加して、ページ間の移動を簡単にしましょう。src/components/Navbar.tsx
というファイルを作成します:
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
export default function Navbar() {
const pathname = usePathname();
return (
<nav className="bg-blue-600 text-white p-4">
<div className="container mx-auto flex justify-between items-center">
<Link href="/" className="text-xl font-bold">
アンケートアプリ
</Link>
<ul className="flex space-x-4">
<li>
<Link
href="/"
className={pathname === '/' ? 'underline font-bold' : 'hover:underline'}
>
ホーム
</Link>
</li>
<li>
<Link
href="/enquete/new"
className={pathname === '/enquete/new' ? 'underline font-bold' : 'hover:underline'}
>
新規作成
</Link>
</li>
<li>
<Link
href="/enquete/list"
className={pathname === '/enquete/list' ? 'underline font-bold' : 'hover:underline'}
>
一覧表示
</Link>
</li>
</ul>
</div>
</nav>
);
}
このナビゲーションバーをレイアウトに追加します。app/layout.tsx
を以下のように編集します:
import './globals.css';
import type { Metadata } from 'next';
import Navbar from '@/components/Navbar';
export const metadata: Metadata = {
title: 'アンケートアプリ',
description: 'Next.jsとPrismaで作成したアンケートアプリ',
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="ja">
<body>
<Navbar />
<main className="py-8">
{children}
</main>
</body>
</html>
);
}
トップページの更新#
最後に、トップページも更新しておきましょう。app/page.tsx
を以下のように編集します:
import Link from 'next/link';
export default function Home() {
return (
<div className="container mx-auto px-4">
<div className="text-center py-12">
<h1 className="text-4xl font-bold mb-6">アンケートアプリへようこそ!</h1>
<p className="text-xl mb-8">
このアプリでは、簡単なアンケートを投稿したり、他の人のアンケート結果を閲覧したりできます。
</p>
<div className="flex justify-center gap-4">
<Link
href="/enquete/new"
className="bg-blue-500 text-white px-6 py-3 rounded-lg text-lg font-semibold hover:bg-blue-600"
>
アンケートに回答する
</Link>
<Link
href="/enquete/list"
className="bg-green-500 text-white px-6 py-3 rounded-lg text-lg font-semibold hover:bg-green-600"
>
アンケート結果を見る
</Link>
</div>
</div>
</div>
);
}
動作確認#
開発サーバーを起動して、アプリケーションを確認してみましょう:
npm run dev
ブラウザで http://localhost:3000 にアクセスして、以下の操作を試してください:
- トップページからアンケートに回答するボタンを押して、新規アンケートページに移動
- アンケートフォームに必要な情報を入力して送信
- アンケート一覧ページで、投稿したアンケートを確認
- アンケート詳細ページで、特定のアンケートの詳細を確認
データベースに登録されていることを確認するには、前回同様にPodmanのPostgresコンテナに接続します:
podman exec -it postgres psql -U postgres
そして、以下のSQLクエリを実行します:
select * from "Enquete";
まとめ#
Next.jsのApp Routerを使うことで、従来のReact RouterよりもシンプルなコードでMulti-pageアプリケーションが作成できました。主な利点は:
- ファイルベースルーティング: ファイル構造がそのままURLパスになるため、直感的
- サーバーコンポーネント: データベースアクセスをサーバー側で行えるため、高速でセキュア
- ビルトインのネストレイアウト: レイアウトの共有が簡単
- 型安全性: TypeScriptとの完全な統合
- 自動的な404ページ: 存在しないページへのアクセスを簡単に処理
次は、Googleなどからのログインを可能にするOAuth認証を実装していきましょう。