Next.jsによるWebアプリケーション開発概論

実践:投稿・編集・削除ができるミニCRUDアプリに仕上げる

10おすすめスポット共有アプリを作りながら学ぶ 画面遷移設計・状態管理・ユーザー入力処理
Next.jsTailwind CSSアプリ開発開発Web開発

はじめに

この節では、これまで作ってきたスポット共有アプリを、投稿・編集・削除ができるミニCRUDアプリ に仕上げます。

CRUD という言葉は少し難しく見えますが、意味はシンプルです。

Create  作る
Read    読む
Update  更新する
Delete  削除する

今回のアプリでは、次のように考えます。

Create  新しいスポットを追加する
Read    スポット一覧・詳細を見る
Update  投稿内容を編集する
Delete  投稿を削除する

ここまでできると、ただ画面を作るだけではなく、小さな実用アプリ にかなり近づきます。


この節のゴール

この節が終わると、次のことができるようになります。

  • スポットを追加できる
  • 投稿内容を編集できる
  • 投稿を削除できる
  • 追加・編集・削除した内容を一覧に反映できる
  • CRUD の基本的な流れを体験できる
  • localStorage を使って、ブラウザ内にデータを保存できる 今日の合言葉はこれです。

作る、見る、直す、消す。これがCRUDの基本。


1. 今回作るアプリの完成イメージ

今回の完成形では、次の流れができます。

新規投稿ページでスポットを追加する
↓
一覧ページに表示される
↓
詳細ページで内容を確認する
↓
編集ページで内容を変更する
↓
一覧や詳細に反映される
↓
不要になったら削除する
↓
一覧から消える

今回は本格的なデータベースは使いません。

まずは、ブラウザの localStorage に保存します。


2. localStorage とは

localStorage は、ブラウザの中にデータを保存する仕組みです。

たとえば、投稿したスポットを保存しておけば、ページを再読み込みしても残せます。

投稿する
↓
localStorage に保存する
↓
一覧で読み込む
↓
画面に表示する

ただし、これは学習用の保存方法です。

本格的なアプリでは、Supabase や Firebase などのデータベースを使います。


3. 今回のファイル構成

必要最低限で、次のファイルを作ります。

app
└─ spots
   ├─ page.tsx
   ├─ new
   │  └─ page.tsx
   └─ [id]
      ├─ page.tsx
      └─ edit
         └─ page.tsx

lib
├─ spot-types.ts
└─ spot-storage.ts

役割はこうです。

ファイル役割
app/spots/page.tsx一覧ページ
app/spots/new/page.tsx新規投稿ページ
app/spots/[id]/page.tsx詳細ページ・削除
app/spots/[id]/edit/page.tsx編集ページ
lib/spot-types.ts型をまとめる
lib/spot-storage.ts保存・取得・編集・削除をまとめる

4. 型を作る

まず、スポットの形を決めます。

lib/spot-types.ts

export const SPOT_CATEGORIES = [
  '図書館',
  'カフェ',
  '学食',
  '作業スペース',
  '休憩スポット',
] as const;

export type SpotCategory = (typeof SPOT_CATEGORIES)[number];

export type Spot = {
  id: string;
  name: string;
  description: string;
  category: string;
  location: string;
  imageUrl: string;
  createdAt: string;
  updatedAt: string;
};

export type SpotFormInput = {
  name: string;
  description: string;
  category: string;
  location: string;
  imageUrl: string;
};

ここでは、Spot という型を作っています。

id          どのスポットか見分ける番号
name        スポット名
description 説明文
category    カテゴリ
location    場所
imageUrl    画像URL
createdAt   作成日時
updatedAt   更新日時

5. 保存処理を作る

次に、localStorage に保存する処理を作ります。

lib/spot-storage.ts

import type { Spot, SpotFormInput } from '@/lib/spot-types';

const SPOTS_STORAGE_KEY = 'spot-share-spots';

const DEFAULT_SPOTS: Spot[] = [
  {
    id: 'spot-1',
    name: '静かな図書館',
    description:
      '静かに勉強したいときに使いやすい図書館です。席数も多く、集中しやすい雰囲気があります。',
    category: '図書館',
    location: '名古屋駅 徒歩5分',
    imageUrl:
      'https://images.unsplash.com/photo-1521587760476-6c12a4b040da?auto=format&fit=crop&w=1200&q=80',
    createdAt: new Date().toISOString(),
    updatedAt: new Date().toISOString(),
  },
  {
    id: 'spot-2',
    name: 'おしゃれカフェ',
    description:
      '友人と話したり、少し作業したりするのに使いやすいカフェです。落ち着いた雰囲気があります。',
    category: 'カフェ',
    location: '栄駅 徒歩3分',
    imageUrl:
      'https://images.unsplash.com/photo-1501339847302-ac426a4a7cbb?auto=format&fit=crop&w=1200&q=80',
    createdAt: new Date().toISOString(),
    updatedAt: new Date().toISOString(),
  },
];

/**
 * 役割: ブラウザ環境かどうかを判定する
 * 入力: なし
 * 出力: ブラウザ環境なら true、それ以外なら false
 */
function isBrowser(): boolean {
  return typeof window !== 'undefined';
}

/**
 * 役割: localStorage からスポット一覧を取得する
 * 入力: なし
 * 出力: スポット一覧
 */
export function getAllSpots(): Spot[] {
  if (!isBrowser()) {
    return DEFAULT_SPOTS;
  }

  const storedValue = window.localStorage.getItem(SPOTS_STORAGE_KEY);

  if (storedValue === null) {
    window.localStorage.setItem(SPOTS_STORAGE_KEY, JSON.stringify(DEFAULT_SPOTS));
    return DEFAULT_SPOTS;
  }

  try {
    const parsedValue = JSON.parse(storedValue) as Spot[];

    if (!Array.isArray(parsedValue)) {
      return DEFAULT_SPOTS;
    }

    return parsedValue;
  } catch {
    return DEFAULT_SPOTS;
  }
}

/**
 * 役割: スポット一覧を localStorage に保存する
 * 入力: spots - 保存したいスポット一覧
 * 出力: なし
 */
function saveAllSpots(spots: Spot[]): void {
  if (!isBrowser()) {
    return;
  }

  window.localStorage.setItem(SPOTS_STORAGE_KEY, JSON.stringify(spots));
}

/**
 * 役割: 指定IDのスポットを取得する
 * 入力: spotId - 探したいスポットID
 * 出力: 見つかれば Spot、なければ null
 */
export function getSpotById(spotId: string): Spot | null {
  const spots = getAllSpots();

  const foundSpot = spots.find((spot) => {
    return spot.id === spotId;
  });

  return foundSpot ?? null;
}

/**
 * 役割: 新しいスポットを作成して保存する
 * 入力: values - フォーム入力値
 * 出力: 作成したスポットのID
 */
export function createSpot(values: SpotFormInput): string {
  const spots = getAllSpots();
  const now = new Date().toISOString();
  const nextId = `spot-${crypto.randomUUID()}`;

  const newSpot: Spot = {
    id: nextId,
    name: values.name,
    description: values.description,
    category: values.category,
    location: values.location,
    imageUrl: values.imageUrl,
    createdAt: now,
    updatedAt: now,
  };

  saveAllSpots([newSpot, ...spots]);

  return nextId;
}

/**
 * 役割: 既存スポットを更新する
 * 入力: spotId - 更新対象ID, values - 更新内容
 * 出力: 更新できたら true、できなければ false
 */
export function updateSpot(spotId: string, values: SpotFormInput): boolean {
  const spots = getAllSpots();

  const nextSpots = spots.map((spot) => {
    if (spot.id !== spotId) {
      return spot;
    }

    return {
      ...spot,
      ...values,
      updatedAt: new Date().toISOString(),
    };
  });

  saveAllSpots(nextSpots);

  return spots.some((spot) => {
    return spot.id === spotId;
  });
}

/**
 * 役割: 指定IDのスポットを削除する
 * 入力: spotId - 削除対象ID
 * 出力: 削除できたら true、できなければ false
 */
export function deleteSpot(spotId: string): boolean {
  const spots = getAllSpots();

  const nextSpots = spots.filter((spot) => {
    return spot.id !== spotId;
  });

  if (nextSpots.length === spots.length) {
    return false;
  }

  saveAllSpots(nextSpots);

  return true;
}

6. 一覧ページを作る

保存されたスポットを一覧で表示します。

app/spots/page.tsx

'use client';

import Link from 'next/link';
import { useState } from 'react';
import { getAllSpots } from '@/lib/spot-storage';
import type { Spot } from '@/lib/spot-types';

/**
 * 役割: 保存済みスポットの一覧を表示する
 * 入力: なし
 * 出力: スポット一覧画面
 */
export default function SpotsPage(): JSX.Element {
  const [spots] = useState<Spot[]>(() => getAllSpots());

  return (
    <main style={{ maxWidth: '800px', margin: '0 auto', padding: '32px' }}>
      <h1>スポット一覧</h1>
      <p>投稿されたスポットがここに表示されます。</p>

      <div style={{ marginTop: '24px' }}>
        <Link href="/spots/new">+ 新しいスポットを投稿する</Link>
      </div>

      <section
        style={{
          display: 'grid',
          gap: '16px',
          marginTop: '24px',
        }}
      >
        {spots.map((spot) => {
          return (
            <article
              key={spot.id}
              style={{
                display: 'grid',
                gridTemplateColumns: '120px 1fr',
                gap: '16px',
                border: '1px solid #ddd',
                borderRadius: '12px',
                padding: '16px',
              }}
            >
              {spot.imageUrl.length > 0 ? (
                <img
                  src={spot.imageUrl}
                  alt={spot.name}
                  style={{
                    width: '120px',
                    height: '90px',
                    objectFit: 'cover',
                    borderRadius: '8px',
                  }}
                />
              ) : (
                <div
                  style={{
                    width: '120px',
                    height: '90px',
                    display: 'grid',
                    placeItems: 'center',
                    borderRadius: '8px',
                    background: '#f5f8fa',
                    color: '#666',
                  }}
                >
                  画像なし
                </div>
              )}

              <div>
                <h2>{spot.name}</h2>
                <p>{spot.category}・{spot.location}</p>
                <p>{spot.description}</p>
                <Link href={`/spots/${spot.id}`}>詳細を見る</Link>
              </div>
            </article>
          );
        })}
      </section>
    </main>
  );
}

7. 新規投稿ページを作る

フォームからスポットを追加します。

app/spots/new/page.tsx

'use client';

import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { createSpot } from '@/lib/spot-storage';
import { SPOT_CATEGORIES } from '@/lib/spot-types';
import type { SpotFormInput } from '@/lib/spot-types';

/**
 * 役割: 新しいスポットを追加するページを表示する
 * 入力: なし
 * 出力: 新規投稿フォーム画面
 */
export default function NewSpotPage(): JSX.Element {
  const router = useRouter();

  const [name, setName] = useState<string>('');
  const [description, setDescription] = useState<string>('');
  const [category, setCategory] = useState<string>(SPOT_CATEGORIES[0]);
  const [location, setLocation] = useState<string>('');
  const [imageUrl, setImageUrl] = useState<string>('');

  /**
   * 役割: フォーム入力値をまとめる
   * 入力: なし
   * 出力: SpotFormInput
   */
  const buildFormInput = (): SpotFormInput => {
    return {
      name: name.trim(),
      description: description.trim(),
      category,
      location: location.trim(),
      imageUrl: imageUrl.trim(),
    };
  };

  /**
   * 役割: 新しいスポットを保存して詳細ページへ移動する
   * 入力: event - フォーム送信イベント
   * 出力: なし
   */
  const handleSubmit = (event: React.FormEvent<HTMLFormElement>): void => {
    event.preventDefault();

    const createdSpotId = createSpot(buildFormInput());

    router.push(`/spots/${createdSpotId}`);
  };

  return (
    <main style={{ maxWidth: '640px', margin: '0 auto', padding: '32px' }}>
      <h1>新しいスポットを投稿する</h1>
      <p>入力した内容を保存して、一覧に追加します。</p>

      <form
        onSubmit={handleSubmit}
        style={{
          display: 'grid',
          gap: '16px',
          marginTop: '24px',
        }}
      >
        <label style={{ display: 'grid', gap: '8px' }}>
          <span>スポット名</span>
          <input
            type="text"
            value={name}
            onChange={(event) => {
              setName(event.target.value);
            }}
            placeholder="例)静かな図書館"
            style={{ padding: '12px', border: '1px solid #ccc', borderRadius: '8px' }}
          />
        </label>

        <label style={{ display: 'grid', gap: '8px' }}>
          <span>説明文</span>
          <textarea
            value={description}
            onChange={(event) => {
              setDescription(event.target.value);
            }}
            rows={5}
            placeholder="例)集中して勉強しやすい場所です。"
            style={{ padding: '12px', border: '1px solid #ccc', borderRadius: '8px' }}
          />
        </label>

        <label style={{ display: 'grid', gap: '8px' }}>
          <span>カテゴリ</span>
          <select
            value={category}
            onChange={(event) => {
              setCategory(event.target.value);
            }}
            style={{ padding: '12px', border: '1px solid #ccc', borderRadius: '8px' }}
          >
            {SPOT_CATEGORIES.map((spotCategory) => {
              return (
                <option key={spotCategory} value={spotCategory}>
                  {spotCategory}
                </option>
              );
            })}
          </select>
        </label>

        <label style={{ display: 'grid', gap: '8px' }}>
          <span>場所</span>
          <input
            type="text"
            value={location}
            onChange={(event) => {
              setLocation(event.target.value);
            }}
            placeholder="例)名古屋駅 徒歩5分"
            style={{ padding: '12px', border: '1px solid #ccc', borderRadius: '8px' }}
          />
        </label>

        <label style={{ display: 'grid', gap: '8px' }}>
          <span>画像URL</span>
          <input
            type="url"
            value={imageUrl}
            onChange={(event) => {
              setImageUrl(event.target.value);
            }}
            placeholder="例)https://example.com/image.jpg"
            style={{ padding: '12px', border: '1px solid #ccc', borderRadius: '8px' }}
          />
        </label>

        <button
          type="submit"
          style={{
            padding: '12px 16px',
            border: 'none',
            borderRadius: '8px',
            background: '#08131a',
            color: '#fff',
            fontWeight: 700,
            cursor: 'pointer',
          }}
        >
          保存する
        </button>
      </form>
    </main>
  );
}

8. 詳細ページを作る

詳細ページでは、1件のスポットを表示します。

ここに、編集ボタンと削除ボタンも置きます。

app/spots/[id]/page.tsx

'use client';

import Link from 'next/link';
import { notFound, useRouter } from 'next/navigation';
import { use, useMemo, useState } from 'react';
import { deleteSpot, getSpotById } from '@/lib/spot-storage';
import type { Spot } from '@/lib/spot-types';

type SpotDetailPageProps = {
  params: Promise<{
    id: string;
  }>;
};

/**
 * 役割: スポット詳細ページを表示し、削除処理を行う
 * 入力: params - URLに含まれるスポットID
 * 出力: スポット詳細画面
 */
export default function SpotDetailPage({
  params,
}: SpotDetailPageProps): JSX.Element {
  const router = useRouter();
  const { id } = use(params);

  const [spot] = useState<Spot | null>(() => getSpotById(id));

  const formattedDate = useMemo(() => {
    if (spot === null) {
      return '';
    }

    return new Date(spot.updatedAt).toLocaleString('ja-JP');
  }, [spot]);

  /**
   * 役割: 表示中のスポットを削除して一覧へ戻る
   * 入力: なし
   * 出力: なし
   */
  const handleDelete = (): void => {
    const shouldDelete = window.confirm('このスポットを削除しますか?');

    if (!shouldDelete) {
      return;
    }

    deleteSpot(id);
    router.push('/spots');
  };

  if (spot === null) {
    notFound();
  }

  return (
    <main style={{ maxWidth: '720px', margin: '0 auto', padding: '32px' }}>
      <Link href="/spots">← 一覧へ戻る</Link>

      <article style={{ marginTop: '24px' }}>
        {spot.imageUrl.length > 0 ? (
          <img
            src={spot.imageUrl}
            alt={spot.name}
            style={{
              width: '100%',
              height: '260px',
              objectFit: 'cover',
              borderRadius: '12px',
            }}
          />
        ) : null}

        <p
          style={{
            display: 'inline-block',
            marginTop: '16px',
            padding: '4px 10px',
            borderRadius: '999px',
            background: '#e6f6f2',
            color: '#1e7b65',
            fontWeight: 700,
          }}
        >
          {spot.category}
        </p>

        <h1>{spot.name}</h1>
        <p>場所: {spot.location}</p>
        <p>更新日: {formattedDate}</p>
        <p style={{ lineHeight: 1.8 }}>{spot.description}</p>

        <div style={{ display: 'flex', gap: '12px', marginTop: '24px' }}>
          <Link href={`/spots/${spot.id}/edit`}>編集する</Link>

          <button
            type="button"
            onClick={handleDelete}
            style={{
              border: 'none',
              background: '#fdf3f3',
              color: '#b22323',
              cursor: 'pointer',
              fontWeight: 700,
            }}
          >
            削除する
          </button>
        </div>
      </article>
    </main>
  );
}

9. 編集ページを作る

編集ページでは、既存の内容を入力欄に入れた状態で表示します。

保存すると、既存データを更新します。

app/spots/[id]/edit/page.tsx

'use client';

import { notFound, useRouter } from 'next/navigation';
import { use, useState } from 'react';
import { getSpotById, updateSpot } from '@/lib/spot-storage';
import { SPOT_CATEGORIES } from '@/lib/spot-types';
import type { Spot, SpotFormInput } from '@/lib/spot-types';

type EditSpotPageProps = {
  params: Promise<{
    id: string;
  }>;
};

/**
 * 役割: スポット編集ページを表示する
 * 入力: params - URLに含まれるスポットID
 * 出力: 編集フォーム画面
 */
export default function EditSpotPage({
  params,
}: EditSpotPageProps): JSX.Element {
  const router = useRouter();
  const { id } = use(params);

  const [spot] = useState<Spot | null>(() => getSpotById(id));

  if (spot === null) {
    notFound();
  }

  return <EditSpotForm spot={spot} onSaved={() => router.push(`/spots/${id}`)} />;
}

type EditSpotFormProps = {
  spot: Spot;
  onSaved: () => void;
};

/**
 * 役割: 既存スポットの編集フォームを表示し、保存する
 * 入力: spot - 編集対象, onSaved - 保存後に実行する関数
 * 出力: 編集フォームUI
 */
function EditSpotForm({ spot, onSaved }: EditSpotFormProps): JSX.Element {
  const [name, setName] = useState<string>(spot.name);
  const [description, setDescription] = useState<string>(spot.description);
  const [category, setCategory] = useState<string>(spot.category);
  const [location, setLocation] = useState<string>(spot.location);
  const [imageUrl, setImageUrl] = useState<string>(spot.imageUrl);

  /**
   * 役割: フォーム入力値をまとめる
   * 入力: なし
   * 出力: SpotFormInput
   */
  const buildFormInput = (): SpotFormInput => {
    return {
      name: name.trim(),
      description: description.trim(),
      category,
      location: location.trim(),
      imageUrl: imageUrl.trim(),
    };
  };

  /**
   * 役割: 編集内容を保存する
   * 入力: event - フォーム送信イベント
   * 出力: なし
   */
  const handleSubmit = (event: React.FormEvent<HTMLFormElement>): void => {
    event.preventDefault();

    updateSpot(spot.id, buildFormInput());
    onSaved();
  };

  return (
    <main style={{ maxWidth: '640px', margin: '0 auto', padding: '32px' }}>
      <h1>スポットを編集する</h1>
      <p>内容を変更して保存すると、一覧や詳細に反映されます。</p>

      <form
        onSubmit={handleSubmit}
        style={{
          display: 'grid',
          gap: '16px',
          marginTop: '24px',
        }}
      >
        <label style={{ display: 'grid', gap: '8px' }}>
          <span>スポット名</span>
          <input
            type="text"
            value={name}
            onChange={(event) => {
              setName(event.target.value);
            }}
            style={{ padding: '12px', border: '1px solid #ccc', borderRadius: '8px' }}
          />
        </label>

        <label style={{ display: 'grid', gap: '8px' }}>
          <span>説明文</span>
          <textarea
            value={description}
            onChange={(event) => {
              setDescription(event.target.value);
            }}
            rows={5}
            style={{ padding: '12px', border: '1px solid #ccc', borderRadius: '8px' }}
          />
        </label>

        <label style={{ display: 'grid', gap: '8px' }}>
          <span>カテゴリ</span>
          <select
            value={category}
            onChange={(event) => {
              setCategory(event.target.value);
            }}
            style={{ padding: '12px', border: '1px solid #ccc', borderRadius: '8px' }}
          >
            {SPOT_CATEGORIES.map((spotCategory) => {
              return (
                <option key={spotCategory} value={spotCategory}>
                  {spotCategory}
                </option>
              );
            })}
          </select>
        </label>

        <label style={{ display: 'grid', gap: '8px' }}>
          <span>場所</span>
          <input
            type="text"
            value={location}
            onChange={(event) => {
              setLocation(event.target.value);
            }}
            style={{ padding: '12px', border: '1px solid #ccc', borderRadius: '8px' }}
          />
        </label>

        <label style={{ display: 'grid', gap: '8px' }}>
          <span>画像URL</span>
          <input
            type="url"
            value={imageUrl}
            onChange={(event) => {
              setImageUrl(event.target.value);
            }}
            style={{ padding: '12px', border: '1px solid #ccc', borderRadius: '8px' }}
          />
        </label>

        <button
          type="submit"
          style={{
            padding: '12px 16px',
            border: 'none',
            borderRadius: '8px',
            background: '#08131a',
            color: '#fff',
            fontWeight: 700,
            cursor: 'pointer',
          }}
        >
          保存する
        </button>
      </form>
    </main>
  );
}

10. 動かして確認する

開発サーバーを起動します。

npm run dev

ブラウザで開きます。

http://localhost:3000/spots

次の順番で試してください。

1. 一覧ページを見る
2. 新しいスポットを投稿する
3. 詳細ページで確認する
4. 編集する
5. 一覧へ戻って内容が変わったか確認する
6. 詳細ページから削除する
7. 一覧から消えたか確認する

これができれば、CRUD の基本は完成です。


11. CRUD の流れをもう一度整理する

今回のコードでは、次のように対応しています。

操作関数画面
作るcreateSpot新規投稿ページ
読むgetAllSpots, getSpotById一覧・詳細
更新するupdateSpot編集ページ
削除するdeleteSpot詳細ページ

つまり、画面と関数がセットになっています。


12. よくあるつまずき

1. 投稿しても一覧に出ない

createSpot の中で保存できているか確認してください。

saveAllSpots([newSpot, ...spots]);

また、一覧ページを開き直して確認してください。


2. 編集しても変わらない

updateSpot が呼ばれているか確認します。

updateSpot(spot.id, buildFormInput());

保存後に詳細ページへ戻っているかも確認してください。


3. 削除しても残る

deleteSpot の中で filter できているか確認します。

const nextSpots = spots.filter((spot) => {
  return spot.id !== spotId;
});

4. 詳細ページが表示されない

URLのIDと、保存されているスポットのIDが合っているか確認してください。

/spots/spot-1
/spots/spot-xxxxxxxx

今回の新規投稿では、crypto.randomUUID() を使ってIDを作っています。


13. ここまでのまとめ

この節では、ミニCRUDアプリを作りました。

できるようになったことは、次の5つです。

  • スポットを追加する
  • スポットを一覧に表示する
  • スポット詳細を見る
  • 投稿内容を編集する
  • 投稿を削除する

これで、Webアプリの基本の流れをかなり体験できました。

CRUD は、ほとんどのアプリで使われます。

メモアプリ
ブログ
商品管理
予約管理
投稿サービス
タスク管理

どれも、基本は「作る・見る・直す・消す」です。


練習問題

問題1

CRUD の C は何を意味しますか。

問題2

スポット一覧を取得する関数は何ですか。

問題3

新しいスポットを追加する関数は何ですか。

問題4

削除するときに使った配列メソッドは何ですか。


回答

問題1の回答

Create

新しく作る、という意味です。

問題2の回答

getAllSpots

問題3の回答

createSpot

問題4の回答

filter

次に進む前のチェック

次へ進む前に、次の5つを確認してください。

  • 新しいスポットを追加できる
  • 追加したスポットが一覧に出る
  • 詳細ページを開ける
  • 編集して保存できる
  • 削除すると一覧から消える ここまでできればOKです。

次は、お気に入り機能を追加して、よりアプリらしい操作を作っていきます。

教材トップへ戻る