Skip to content

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 にアクセスして、以下の操作を試してください:

  1. トップページからアンケートに回答するボタンを押して、新規アンケートページに移動
  2. アンケートフォームに必要な情報を入力して送信
  3. アンケート一覧ページで、投稿したアンケートを確認
  4. アンケート詳細ページで、特定のアンケートの詳細を確認

アンケート投稿画面

データベースに登録されていることを確認するには、前回同様にPodmanのPostgresコンテナに接続します:

podman exec -it postgres psql -U postgres

そして、以下のSQLクエリを実行します:

select * from "Enquete";

まとめ#

Next.jsのApp Routerを使うことで、従来のReact RouterよりもシンプルなコードでMulti-pageアプリケーションが作成できました。主な利点は:

  1. ファイルベースルーティング: ファイル構造がそのままURLパスになるため、直感的
  2. サーバーコンポーネント: データベースアクセスをサーバー側で行えるため、高速でセキュア
  3. ビルトインのネストレイアウト: レイアウトの共有が簡単
  4. 型安全性: TypeScriptとの完全な統合
  5. 自動的な404ページ: 存在しないページへのアクセスを簡単に処理

次は、Googleなどからのログインを可能にするOAuth認証を実装していきましょう。