[ Next.js15 + Ruby on Rails8 ] フルスタックBlogアプリ開発（CRUD編）

# 概要
　今回はフロントエンドに `Next.js`、バックエンドに `Ruby on Rails` を用いたフルスタックの Blog アプリを作成します。
 
　今回の記事では、基本的な CRUD 操作のみを行いますが、次回以降の記事では、認証機能（ログイン・ログアウト）やバリデーション機能の追加を行います。
 
　また、この記事の対象者としては、各種環境構築が終了している方を想定しています。環境構築がまだの方は、各自環境設定から宜しくお願いいたします。
　
# 開発手順
#### 1. バックエンド構築
- *プロジェクト作成*
- *controller 作成*
- *model 作成*
- *マイグレーション*
- *ルーティング設定*
- *アクション追加*
- *API テスト*
- *ポート番号変更*
#### 2. フロントエンド構築
- *プロジェクト作成*
- *ブログ取得*
- *ブログ表示*
- *新規ブログ投稿*
- *ブログ詳細表示*
- *ブログ編集*
- *ブログ削除*
　
# 実装
### 1. バックエンド編
#### **➀ プロジェクト作成**
 　まずは適当なディレクトリを作成し、そこに移動します。
```:ターミナル
mkdir blog
cd blog
 ```
　
　次に、blog ディレクトリに rails のひな型を作成します。
　今回はフロントエンドを Next.js で書くため --api オプションを付け、API モードでひな型を作成します。
　また、テストファイルは今回無しで進めるので、-T オプションも付けておきます。
```:ターミナル
rails new backend --api -T
 ```
　
　作成が完了したら、backend ディレクトリに移動します。
```:ターミナル
cd backend
 ```
　
#### **➁ controller 作成**
　続いて、コントローラーを作成します。
　コントローラー名は複数形にしましょう。
　また、Api : : Blogsとすることで、Api ディレクトリの中に Blogs のコントローラーを作成します。
```:ターミナル
rails g controller Api::Blogs index show create update destroy
 ```
　
#### **➂ model 作成**
　続いて、モデルを作成します。
　モデル名は単数形にしましょう。
　また、今回は３つのカラムを作成します。
```:ターミナル
rails g model Blog title:string author:string content:text
 ```
　
#### **➃ マイグレーション**
　③で作成したモデルをマイグレーションします。
```:ターミナル
rails db:migrate
 ```
　
#### **➄ ルーティング設定**
　ここでは、リクエストに対して適切なコントローラーのアクションに振り分けます。
　今回はフロントエンドを Next.js で書くため、views に関するアクションは追加しません。
```ruby:blog / backend / config / routes.rb:
Rails.application.routes.draw do
  namespace :api do
    # resources :blogsとすることで BlogsController を参照
    # api/blogsというURLをリクエストしたとき(HTTPメソッドはGET)、indexアクションが実行
    # （以下略）
    # api/blogs/:id　GET    -> showアクション
    # api/blogs      POST   -> createアクション
    # api/blogs/:id  PUT    -> updateアクション
    # api/blogs/:id  DELETE -> destroyアクション
    resources :blogs, only: [:index, :show, :create, :update, :destroy]
  end
end
 ```
　
#### **➅ アクション追加**
　続いて、アクションの詳しい挙動を定義します。
```ruby:blog / backend / app / controllers / api / blogs_controller.rb
class Api::BlogsController < ApplicationController
  # 一覧表示
  def index
    # Blog.all の Blog は、model の名前
    blogs = Blog.all
    # render でクライアント側にレスポンスを返す
    # json: でJSON形式に変換
    render json: blogs
  end
  
  # 詳細表示
  def show
    # params[:id]で、URLのid部分を取り出す
    # Blog.find -> idを基に、特定のデータをdbから取り出す
    blog = Blog.find(params[:id])
    render json: blog
  end

  # 新規投稿
  def create
    # blog_params -> ストロングパラメーター（コード下部で定義）
    blog = Blog.new(blog_params)
    # if blog.save -> blog 変数が保存されたとき
    if blog.save
      # status: -> ステータスコードを定義
      render json: blog, status: :created
    else
      render json: blog.errors, status: :unprocessable_entity
    end
  end

  # 更新
  def update
    blog = Blog.find(params[:id])
    # if blog.update -> blog 変数が更新されたとき
    if blog.update(blog_params)
      render json: blog, status: :ok
    else
      render json: blog.errors, status: :unprocessable_entity
    end
  end

  # 削除
  def destroy
    blog = Blog.find(params[:id])
    blog.destroy
  end

  # ストロングパラメーターのメソッド（blog_params）は private 以下に書く
  private
  def blog_params
    # params -> クライアントからのリクエストが入ったオブジェクト
    # require の引数には、リクエストのキーを入れる(デフォルトでは、モデル名)
    # permit の引数には、サーバー側で利用するリクエストのデータを指定
    params.require(:blog).permit(:title, :author, :content)
  end
end
 ```
　
#### **➆ API テスト**
　次に、ローカルサーバーを立てて、実際に API が正しく動くのかをテストします。
　まずは、ローカルサーバーを立ち上げます。
 ```:ターミナル
 rails s
 ```
　
　また、テストには Postman を使用します。
　手順は以下の通りです。（インストールがまだの方は、先にインストールをお願いします。）
 
![スクリーンショット 2025-01-28 113146.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3949612/acd538b0-2cbd-ba01-e21b-3becdef1525a.png)
　
　
　まずは、データを追加してみましょう。POST メソッドを選択し、エンドポイント（ルーティング設定時に自動作成されたルート）を設定します。
　そして、Body -> raw -> JSON を選択し、追加するデータを JSON 形式で定義します。
　この時、コントローラーのストロングパラメーターで定義したカラムのみデータとして定義します。
　最後に、Send ボタンを押すと画面右側、あるいは画面下部に送信されたデータが表示されます。
　これで、ポストできたことが分かりました！ API もしっかり叩けているみたいです。
 
![スクリーンショット 2025-01-28 114836.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3949612/04c85abe-b43e-89c0-4279-af3c000adaf6.png)
　
　
　続いては、このポストしたデータを取得します。
　GET メソッドに変更し、エンドポイントはそのままで Send ボタンを押すと、しっかりデータを取得できています。
 
 ![スクリーンショット 2025-01-28 115150.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3949612/fc917bad-74f2-19de-ccaf-be3c15c460a7.png)
　
　
　更新も行っておきましょう。
　PUT メソッドに変更し、エンドポイントには id を指定しましょう。
　POST の API を叩いた時と同様に、変更後のデータを入力し、Send ボタンを押すと、データが更新されました。
　念のため、先ほどと同じ手順で GET の API を叩いてみてください。取得したデータは更新されているはずです。
 
 ![スクリーンショット 2025-01-28 115608.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3949612/7addc574-5d98-0053-2d72-f812b46d0a3e.png)
　
　
　同じ要領で、show アクション（詳細表示）や、destroy アクション（削除）もテストしてみてください。
　エラーが吐かれなければ、バックエンドの構築は終了です、と言いたいところですが、フロントエンドの開発を行う前にポート番号を変更しておきます。
　
#### **⑧ ポート番号変更**
　フロントエンド構築を行う前に、ポート番号を変更します。
　なぜなら、フロントエンド側でサーバーを立てた際、ポート番号が被ってしまうからです。
　そのため、バックエンド側のポート番号を 3001 に変更しておきましょう。
　まずは、gem "rack-cors" のコメントアウトを外します。
```ruby:blog / backend / Gemfile
# Use Rack CORS for handling Cross-Origin Resource Sharing (CORS), making cross-origin Ajax possible
gem "rack-cors"
```
　
　その後、以下のコマンドを実行します。
 ```:ターミナル
bundle i
```
　
　続いて、Rails.application. ～ 以下のコメントアウトを外し、origins の値を "localhost:3000" に変更します。
 ```ruby:blog / backend / config / initializers / cors.rb
# Be sure to restart your server when you modify this file.

# Avoid CORS issues when API is called from the frontend app.
# Handle Cross-Origin Resource Sharing (CORS) in order to accept cross-origin Ajax requests.

# Read more: https://github.com/cyu/rack-cors

Rails.application.config.middleware.insert_before 0, Rack::Cors do
  allow do
    origins "localhost:3000"

    resource "*",
      headers: :any,
      methods: [:get, :post, :put, :patch, :delete, :options, :head]
  end
end
```
　
　最後にローカルサーバーを立ちあげます。-p 3001 はポート番号を 3001 番に変更するコマンドです。
 ```:ターミナル
rails s -p 3001
```
　
　これで、バックエンド構築は終了です。お疲れさまでした。
　
### 2. フロントエンド編
#### **➀ プロジェクト作成**
　まずはターミナルを分割して（ api 側のサーバーは立ち上げたまま）、backend ディレクトリから、blog ディレクトリに移動します。
```:ターミナル
cd ..
```
　
　バックエンド編と同様に、blog ディレクトリに Next.js のひな型を作成します。
　設定の選択を促されますが、すべてデフォルトのままで構いません。
```:ターミナル
npx create-next-app frontend
 ```
　
　作成が完了したら、frontend ディレクトリに移動します。
```:ターミナル
cd frontend
 ```
　
 #### **➁ ブログ取得**
　フロントエンド側で、API を叩いくことでデータの取得を行います。
　エンドポイントの URL が localhost:3001 になっていることに気を付けましょう。
 ```typescript:blog / frontend / src / app / page.tsx
export default async function Home() {
  const getAllBlogs = async () => {
    const res = await fetch("http://localhost:3001/api/blogs", {
      // SSRでデータを取りに行く
      cache: "no-store"
    });
    // JSON形式に変換
    return res.json();
  };
  const blogs = await getAllBlogs();
  console.log("取得したデータは、", blogs);
}
```
　
　ローカルサーバーを立ち上げて、コンソールへの出力を確認しましょう。
　以下のようになっていれば、しっかりとデータの取得ができています！次は、この取得したデータを表示させましょう。
 ```:ターミナル
 npm run dev
 ```
![スクリーンショット 2025-01-28 120420.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3949612/603ef204-4690-f84e-853d-d4c276830a9f.png)
　
#### **➂ ブログ表示**
　次は、先ほど取得したデータを表示させます。まずその前に、blogs 変数の型定義をしておきましょう。以下のディレクトリ構成で、types ファイルを作成します。
```typescript:blog / frontend / src / types / types.ts
// （ blog / backend / db / schema.rb ）を参照
// schema.rb に id は定義されていないが、デフォルトで定義されるため、型宣言する必要あり
export interface Blog {
  id: number;
  title: string;
  author: string;
  content: string;
  created_at: Date;
  updated_at: Date;
};
```
　
また、CSSファイルも編集しておきましょう。
```:blog / frontend / src / app / globals.css
@tailwind base;
@tailwind components;
@tailwind utilities;
```
　
　あとは、blog を表示する部分をコンポーネント化し、クライアントコンポーネントとサーバーコンポーネントをそれぞれ独立させます。とりあえず、ひな型だけ作っておきましょう。
```react:blog / frontend / src / components / BlogList.tsx
export default function BlogList() {
  return (
    <></>
  );
}
```
　
　最後に、ページの遷移先のディレクトリを作成しましょう。
　新規投稿ページ、編集ページ、投稿詳細ページの、計３ページ作成します。
　編集ページと投稿詳細ページは動的ルーティングを実現するために、[id] ディレクトリを作成します。
 ```react:blog / frontend / src / app / manage / create / page.tsx
export default function CreateBlog() {
  return (
    <></>
  );
}
```
 ```react:blog / frontend / src / app / manage / edit / [id] / page.tsx
export default function EditBlog() {
  return (
    <></>
  );
}
```
 ```react:blog / frontend / src / app / manage / [id] / page.tsx
export default function SpecificBlog() {
  return (
    <></>
  );
}
```
　
　お待たせしました。では、実際に表示させましょう。
 ```react:blog / frontend / src / app / page.tsx
import BlogList from "@/components/BlogList";
import { Blog } from "@/types/types";
import Link from "next/link";

export default async function Home() {
  const getAllBlogs = async () => {
    const res = await fetch("http://localhost:3001/api/blogs", {
      // SSRでデータを取りに行く
      cache: "no-store"
    });
    // JSON形式に変換
    return res.json();
  };
  // 型指定（複数データを取得するため、配列型にしておく）
  const blogs: Blog[] = await getAllBlogs();
  // console.log("取得したデータは、", blogs);

  return (
    <div className="min-h-screen bg-gray-50">
      <div className="bg-blue-600 text-white py-4 shadow-md">
        <div className="container mx-auto px-6 flex justify-between items-center">
          <span className="text-3xl font-bold">Blog</span>
          <div>
            <Link
              href={"manage/create"}
              className="bg-green-500 text-white py-2 px-4 rounded-lg hover:bg-green-600 transition-all"
            >
              新規投稿
            </Link>
          </div>
        </div>
      </div>
      <div className="container mx-auto px-6 py-8">
        {/* BlogList コンポーネント に blogs を渡す*/}
        <BlogList blogs={blogs} />
      </div>
    </div>
  );
}
```
　
　今回 HTTP リクエストには、axios を使用し、トースト通知には、react-hot-toast を使用します。
　トースト通知を行うために、layout.tsx を少しいじります。
```:ターミナル
npm i axios react-hot-toast
```
```react:blog / frontend / src / app / layout.tsx
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { Toaster } from "react-hot-toast";

const geistSans = Geist({
  variable: "--font-geist-sans",
  subsets: ["latin"],
});

const geistMono = Geist_Mono({
  variable: "--font-geist-mono",
  subsets: ["latin"],
});

export const metadata: Metadata = {
  // 任意のタイトル・説明に変更
  title: "Blog App",
  description: "Generated by using next.js & ruby on rails",
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    // en から ja に
    <html lang="ja">
      <body
        className={`${geistSans.variable} ${geistMono.variable} antialiased`}
      >
        {/* トースター通知をさせるために必要 */}
        <Toaster />
        {children}
      </body>
    </html>
  );
}
```
　
　ここで、BlogList コンポーネントの中身を作成します。
　プロップスを受け取って、それを map 関数で展開しましょう。
```react:blog / frontend / src / components / BlogList.tsx
// クライアントコンポーネント
"use client";

import { Blog } from "@/types/types";
import axios from "axios";
import Link from "next/link";
import { useRouter } from "next/navigation";
import toast from "react-hot-toast";

// props オブジェクトから、blogs プロパティを分割代入で直接取得
export default function BlogList({ blogs }: { blogs: Blog[] }) {
  // ページ遷移に利用
  const router = useRouter();
  const handleDelete = async ( id: number ) => {
    // ➅ ブログの削除で実装
  };

  return (
    <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6 p-6">
      {blogs.map((blog: Blog) => (
        <div 
          key={blog.id} 
          className="bg-white rounded-lg shadow-lg hover:shadow-xl p-6 transition-all transform hover:scale-105 flex flex-col"
        >
          <div className="mb-4">
            <Link href={`/manage/${blog.id}`}>
              <h2 className="text-2xl font-semibold text-gray-800 mb-2 hover:text-blue-600 transition-colors hover:underline">
                {blog.title}
              </h2>
            </Link>
            <div className="text-sm text-gray-500">{blog.author}</div>
          </div>
          <div className="text-gray-700 text-base mb-4 line-clamp-3">
            {blog.content}
          </div>
          <div className="text-sm text-gray-400 text-right mb-4">
            {new Date(blog.created_at).toLocaleDateString()}
          </div>
          <div className="flex justify-between items-center">
            <Link 
              href={`manage/edit/${blog.id}`} 
              className="text-green-500 hover:text-green-600 hover:underline"
            >
              Edit
            </Link>
            <button 
              className="text-red-500 hover:text-red-600 hover:underline"
              onClick={() => handleDelete(blog.id)}
            >
              Delete
            </button>
          </div>
        </div>
      ))}
    </div>
  );
}
```
　
　これで、投稿が表示されるはずです。
　ローカルサーバーを立ち上げて、確認してみましょう。
 ```:ターミナル
 npm run dev
 ```
 ![スクリーンショット 2025-01-28 123133.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3949612/0c3da004-fd9a-7556-07b4-f956b5527a83.png)
　
　上記のようになっていたら OK です。続いては、ブログ記事の投稿を行いましょう。
　
#### **➃ ブログの新規投稿**
　次は、ブログの新規投稿機能を追加します。

```react:frontend / src / app / manage / create / page.tsx
"use client";

import axios from "axios";
import { useRouter } from "next/navigation";
import { FormEvent, useState } from "react";
import toast from "react-hot-toast";

export default function CreateBlog() {
  const [title, setTitle] = useState<string>("");
  const [author, setAuthor] = useState<string>("");
  const [content, setContent] = useState<string>("");
  const router = useRouter();

  const handleSubmit = async (e: FormEvent) => {
    e.preventDefault();
    const loadingToast = toast.loading("投稿中...");
    try {
      // 第1引数にエンドポイント、第2引数に投稿データ
      await axios.post("http://localhost:3001/api/blogs", {
        title: title,
        author: author,
        content: content
      });
      toast.success("投稿に成功しました！", { id: loadingToast });
      // トースト通知を十分に表示したのちに、ルートディレクトリに遷移
      setTimeout(() => {
        toast.dismiss(loadingToast);
        router.push("/");
      }, 1000);
    } catch (error) {
      toast.error(`投稿に失敗しました。　エラー：${error}`,  { id: loadingToast })
    }
  };

  return (
    <>
      <div className="min-h-screen bg-gray-100 py-12 px-4 sm:px-6 lg:px-8">
        <div className="max-w-lg mx-auto bg-white p-8 rounded-lg shadow-lg">
          <h2 className="text-2xl font-semibold text-gray-700 text-center mb-6">ブログ投稿フォーム</h2>
          <form onSubmit={handleSubmit}>
            <div className="space-y-4">
              <div>
                <label htmlFor="title" className="block text-sm font-medium text-gray-600">タイトル</label>
                <input 
                  id="title"
                  type="text"
                  onChange={(e) => setTitle(e.target.value)}
                  className="mt-1 p-2 w-full border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
                  placeholder="タイトルを入力してください"
                />
              </div>
              <div>
                <label htmlFor="author" className="block text-sm font-medium text-gray-600">名前</label>
                <input 
                  id="author"
                  type="text"
                  onChange={(e) => setAuthor(e.target.value)}
                  className="mt-1 p-2 w-full border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
                  placeholder="名前を入力してください"
                />
              </div>
              <div>
                <label htmlFor="content" className="block text-sm font-medium text-gray-600">内容</label>
                <textarea
                  id="content"
                  onChange={(e) => setContent(e.target.value)}
                  className="mt-1 p-2 w-full h-32 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
                  placeholder="ブログ内容を入力してください"
                />
              </div>
              <div className="mt-6 text-center">
                <button
                  type="submit"
                  className="px-6 py-2 text-white bg-blue-600 rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
                >
                  投稿する
                </button>
              </div>
            </div>
          </form>
        </div>
      </div>
    </>
  );
}
```
　
　これで、ブログの投稿フォームが作成できました。
　ブログの内容を入力して投稿すると、しっかりホーム画面に投稿が追加されましたね！続いては、ブログを詳細表示できるようにします。
 
 ![スクリーンショット 2025-01-28 145426.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3949612/a4d7281e-fca0-1bfa-1c5e-1b23bc3e2f11.png)
　
　
#### **➄ ブログの詳細表示**
　次は、ブログの詳細を表示するページを作りましょう。
　ブログのタイトルを押すと、詳細画面に遷移するようにします。
```react:blog / frontend / src / app / manage / [id] / page.tsx
"use client";

import { Blog } from "@/types/types";
import { use, useEffect, useState } from "react";

// URLのパラメータ取得（詳しくは前回の記事参照）
export default function SpecificBlog({ params }: { params: Promise<{ id: number }> }) {
  const [blog, setBlog] = useState<Blog>();
  const [loading, setLoading] = useState<boolean>(true);
  // Promiseデータのラッピング
  const wrapParams = use(params);
  const getSpecificBlog = async ( id: number ) => {
    const res = await fetch(`http://localhost:3001/api/blogs/${id}`,{
      // ISRで取得（30秒ごとにページを再生成）
      next: {revalidate: 30}
    });
    return res.json();
  };

  // 初期値の入れ込み
  useEffect(() => {
    const fetchSpecificBlog = async () => {
      const blog = await getSpecificBlog(wrapParams.id);
      setBlog(blog);
      setLoading(false);
    }
    fetchSpecificBlog();
  }, [wrapParams.id]);

  // ローディング画面
  if (loading) {
    return (
      <div className="flex justify-center items-center h-screen">
        <div className="text-xl text-gray-600">Loading...</div>
      </div>
    );
  }

  if (!blog) {
    return (
      <div className="flex justify-center items-center h-screen">
        <div className="text-xl text-red-500">ブログ記事は存在しません。</div>
      </div>
    );
  }

  return (
    <div className="max-w-3xl mx-auto p-6 bg-white shadow-xl rounded-lg my-8 border border-gray-200">
      <h1 className="text-4xl font-bold text-gray-900 mb-6 leading-tight">
        {blog.title}
      </h1>
      <div className="flex items-center justify-between text-sm text-gray-500 mb-8">
        <div className="flex items-center space-x-2">
          <span className="font-medium">{blog.author}</span>
          <span className="text-gray-400">|</span>
          <span>{new Date(blog.created_at).toLocaleDateString()}</span>
        </div>
      </div>
      <p className="text-gray-700 text-lg leading-relaxed">
        {blog.content}
      </p>
    </div>
  );
}
```
　
　これで、ブログの詳細が表示されました！
　あとは、編集と削除だけです。頑張りましょう！

![スクリーンショット 2025-01-28 151105.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3949612/04090b84-c248-3312-9bcc-51dcb0fd1c6a.png)
　
　
#### **➅ ブログの編集**
　ブログを編集できるようにします。
　詳細表示機能と大差ないので、サクっと作ります！
```react:blog / frontend / src / app / manage / edit / [id] / page.tsx
"use client";

import axios from "axios";
import { useRouter } from "next/navigation";
import { FormEvent, use, useEffect, useState } from "react";
import toast from "react-hot-toast";

export default function EditBlog({ params }: { params: Promise<{ id: number }> }) {
  const [title, setTitle] = useState<string>("");
  const [author, setAuthor] = useState<string>("");
  const [content, setContent] = useState<string>("");
  const wrapParams = use(params);
  const router = useRouter();

  const getSpecficBlog = async ( id: number ) => {
    const res = await fetch(`http://localhost:3001/api/blogs/${id}`);
    return res.json();
  };

  useEffect(() => {
    const fetchSpecificBlog = async () => {
      const blog = await getSpecficBlog(wrapParams.id);
      setTitle(blog.title);
      setAuthor(blog.author);
      setContent(blog.content);
    };
    fetchSpecificBlog();
  }, [wrapParams.id]);

  const handleSubmit = async ( e: FormEvent ) => {
    e.preventDefault();
    const loadingToast = toast.loading("投稿中...");
    try {
      await axios.put(`http://localhost:3001/api/blogs/${wrapParams.id}`, {
        title: title,
        author: author,
        content:content
      });
      toast.success("更新しました！", { id: loadingToast });
      setTimeout(() => {
        toast.dismiss(loadingToast);
        router.push("/");
      }, 1000);
    } catch (error) {
      toast.error(`更新に失敗しました。${error}`, { id: loadingToast });
    }
  };

  return (
    <div className="min-h-screen bg-gray-100 py-12 px-4 sm:px-6 lg:px-8">
      <div className="max-w-lg mx-auto bg-white p-8 rounded-lg shadow-lg">
        <h2 className="text-2xl font-semibold text-gray-700 text-center mb-6">ブログ編集フォーム</h2>
        <form onSubmit={handleSubmit}>
          <div className="space-y-4">
            <div>
              <label htmlFor="title" className="block text-sm font-medium text-gray-600">タイトル</label>
              <input 
                id="title"
                type="text"
                onChange={(e) => setTitle(e.target.value)}
                className="mt-1 p-2 w-full border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
                value={title}
              />
            </div>
            <div>
              <label htmlFor="author" className="block text-sm font-medium text-gray-600">名前</label>
              <input 
                id="author"
                type="text"
                onChange={(e) => setAuthor(e.target.value)}
                className="mt-1 p-2 w-full border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
                value={author}
              />
            </div>
            <div>
              <label htmlFor="content" className="block text-sm font-medium text-gray-600">内容</label>
              <textarea
                id="content"
                onChange={(e) => setContent(e.target.value)}
                className="mt-1 p-2 w-full h-32 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
                value={content}
              />
            </div>
            <div className="mt-6 text-center">
              <button
                type="submit"
                className="px-6 py-2 text-white bg-blue-600 rounded-lg hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
              >
                更新する
              </button>
            </div>
          </div>
        </form>
      </div>
    </div>
  );
}
```
　
　これで、編集ができるようになりました。
　早速２つ目の投稿を編集してみましょう！
 
 ![スクリーンショット 2025-01-28 151854.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3949612/eaec029c-cee0-01e1-a9ca-40f03d0afd74.png)
　
　
#### **➆ ブログの削除**
　最後にブログの削除を行います！
```react:blog / frontend / src / app / page.tsx
"use client";

import { Blog } from "@/types/types";
import axios from "axios";
import Link from "next/link";
import { useRouter } from "next/navigation";
import toast from "react-hot-toast";

// props オブジェクトから、blogs プロパティを分割代入で直接取得
export default function BlogList({ blogs }: { blogs: Blog[] }) {
  const router = useRouter();
  const handleDelete = async ( id: number ) => {
    // 削除確認ダイアログの表示
    const deleteChecker = window.confirm("本当にこの投稿を削除しますか？");
    // 削除キャンセルが選択された場合、処理を中断
    if (!deleteChecker) {
      return;
    }
    try {
      await axios.delete(`http://localhost:3001/api/blogs/${id}`);
      // ページをリロード（削除ボタン押下後、ページをリロードしないと、投稿が削除されないから）
      router.refresh();
    } catch (error) {
      // id に一意の識別子を割り当てることで、トースト通知の重複を防ぐ
      toast.error(`削除に失敗しました。　エラー：${error}`, {id: "a"});
    }
  };

  return (
    <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6 p-6">
      {blogs.map((blog: Blog) => (
        <div 
          key={blog.id} 
          className="bg-white rounded-lg shadow-lg hover:shadow-xl p-6 transition-all transform hover:scale-105 flex flex-col"
        >
          <div className="mb-4">
            <Link href={`/manage/${blog.id}`}>
              <h2 className="text-2xl font-semibold text-gray-800 mb-2 hover:text-blue-600 transition-colors hover:underline">
                {blog.title}
              </h2>
            </Link>
            <div className="text-sm text-gray-500">{blog.author}</div>
          </div>
          <div className="text-gray-700 text-base mb-4 line-clamp-3">
            {blog.content}
          </div>
          <div className="text-sm text-gray-400 text-right mb-4">
            {new Date(blog.created_at).toLocaleDateString()}
          </div>
          <div className="flex justify-between items-center">
            <Link 
              href={`manage/edit/${blog.id}`} 
              className="text-green-500 hover:text-green-600 hover:underline"
            >
              Edit
            </Link>
            <button 
              className="text-red-500 hover:text-red-600 hover:underline"
              onClick={() => handleDelete(blog.id)}
            >
              Delete
            </button>
          </div>
        </div>
      ))}
    </div>
  );
}
 ```
　
 削除機能も実装することができました！
 一番初めに投稿したブログを削除してみましょう！
 これで、一通りの CRUD 操作の実装が完了しました。お疲れさまでした。
 
  ![スクリーンショット 2025-01-28 152437.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3949612/55321079-e621-f023-ec38-aaeace85db57.png)
　

# 最後に
　かなり、記事が長くなってしまいましたが、これにて CRUD 編終了です。
　次は、認証機能の実装をしていきたいと考えているので、是非そちらもご覧ください！