Reactその他

API とは?【Next.js + Vercelで実践編】

この記事は約24分で読めます。

前回の記事では、APIの基礎や使用される環境など基礎的な部分を紹介しました!(よろしければご覧ください!)
今回は実践編!やってみましょうー!

Next.jsとVercelとは

Next.jsとは

Next.jsとはReactをもとに作成されたフレームワークです
Reactを知っている人には親しみやすく、初心者も学びやすいフレームワークです。
Next.jsではサーバーサイドでの処理も簡単に書けるようになっており、
/pages/apiフォルダにファイルを置くだけでAPIの構築が可能です。
これにより、データベースとの連携なども簡単に実装できます。

Vercelとは

Vercel(バーセル)は、Next.jsの開発元が提供するクラウドプラットフォームで、
作成したウェブアプリを簡単にデプロイ(インターネット上に公開)できるサービスです。
VercelはNext.jsの公式提供元でもあり、Next.jsで作られたアプリケーションを非常にスムーズにデプロイできるように作られています。
GitHubなどのソースコード管理サービスと連携して、ボタン1つでプロジェクトをVercelにデプロイできます。
また、変更をコードに加えGitHubにプッシュすると自動的に更新が反映されるため、運用が便利になっています。

API実装

それでは実際に簡単なAPIを作成してみましょう!

①Next.jsプロジェクト作成

まずはクライアント側のアプリケーションを作成していきます

npm install -g pnpm

高速なpnpmをインストール

  npx create-next-app@latest test-api --use pnpm 

test-apiというプロジェクトを作成
※途中で出てくる項目はエンターキーを押してスキップでOKです

pnpm i 

プロジェクトのパッケージをインストール  

pnpm dev

開発サーバを起動

http://localhost:3000にアクセスし画面が表示されたらプロジェクトの作成は完了です
デプロイのため、Githubにリポジトリを作成し、上記のプロジェクトをプッシュしてください

②Vercelのセットアップ

次に作成したアプリケーションをサーバにアップロードするために、Vercelの設定を行なっていきます

・Vercelアカウントを作成する
画面の「Deploy now」をクリックしVercelへ移動
「Continue with GitHub」を選択しGithubアカウントと連携
リポジトリが表示されるので、pushした「test-api」インポート

・Vercelにプロジェクトを接続しデプロイする
インポートされたリポジトリを選択し、「Deploy」をクリックしてプロジェクトをデプロイ

③Vercel内でデータベースを作成

  デプロイ後画面の「Continue to Dashboard」をクリック
「Strage」→「Connect Database」 →「Create New」の順でクリックし「Postgres」を選択

以下の画面で「Create」をクリック 作成後に「Connect」でデプロイしたリポジトリに接続

・Next.jsプロジェクトでDBを設定
test-apiディレクトリ内に.envファイルを作成
.env.localタブを開き、「Show Seecret」をクリックし表示された内容を.envファイルにコピペ

ターミナルで

pnpm i @vercel/postgres

を実行し、Vercel Postgres SDKをインストールします。

・データベースに初期データをシードする
Vercelに戻り、画面左端の「Data」をクリックしQueryタグを開く

以下のSQLを貼り付け実行

CREATE TABLE fruits ( id SERIAL PRIMARY KEY, name VARCHAR(100) NOT NULL );  
INSERT INTO fruits (name) VALUES ('Apple'),('Grape'), ('Banana'); 

Browseタブからfruitsテーブルとその中身を確認できます 

④APIを実装

APIを作成する際に便利なルートハンドラーを使用して実装していきます!

 npm install pg 

データベースに接続するためのパッケージをインストール

test-apiディレクトリに apiフォルダ、その中にroute.tsを作成
以下のように記載します

route.ts

import pg from "pg";
import { NextResponse } from "next/server";

const { Pool } = pg;

// データベース接続プールを設定
const pool = new Pool({
  connectionString: process.env.POSTGRES_URL, // 環境変数から接続情報を取得
});

// GETリクエストを作成
export async function GET() {
  try {
    const res = await pool.query("SELECT id, name from fruits");
    return NextResponse.json({ fruits: res.rows });
  } catch (error) {
    return NextResponse.json(
      {
        error: error instanceof Error ? error.message : "Failed to fetch data",
      },
      { status: 500 }
    );
  }
}

次に、page.tsを以下のように書き換えます

page.ts

"use client";
import { useState, useEffect } from "react";

export default function Home() {
  const [fruits, setFruits] = useState<{ id: number; name: string }[]>([]);
  const [newFruit, setNewFruit] = useState<string>(""); // 新規追加用のステート
  const [error, setError] = useState<string | null>(null);

  // フルーツ名の変更を反映するハンドラ
  const handleFruitChange = (index: number, newValue: string) => {
    const updatedFruits = [...fruits];
    updatedFruits[index] = {
      ...updatedFruits[index],
      name: newValue,
    };
    setFruits(updatedFruits);
  };
    const data = await response.json();
    if (response.ok) {
      console.log("Added fruit:", data.fruit);
    } else {
      console.error("Error:", data.error);
    }
  }

  useEffect(() => {
    const fetchFruits = async () => {
      try {
        const res = await fetch("/api/fruits"); //先程作成したAPI
        const data = await res.json();
        if (!res.ok) {
          throw new Error(data.error || "Something went wrong");
        }
        setFruits(data.fruits);
      } catch (error) {
        if (error instanceof Error) {
          setError(error.message);
        } else {
          setError("An unknown error occurred");
        }
      }
    };
    fetchFruits();
  }, []);

  return (
    <div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20 font-[family-name:var(--font-geist-sans)]">
      <main className="flex flex-col gap-8 row-start-2 items-center sm:items-start">
        <h1>APIテスト</h1>
        {error && <p style={{ color: "red" }}>{error}</p>}
        <ul>
          {fruits.length > 0 ? (
            fruits.map((fruit, index) => (
              <li key={fruit.id}>
                <input
                  type="text"
                  value={fruit.name}
                  onChange={(e) => handleFruitChange(index, e.target.value)}
                  className="border-2 border-white bg-black text-white p-2 mb-5 mr-5"
                  style={{ borderRadius: "4px" }}
                />
                {/* 編集ボタン */}
                <button
                  className="border-2 border-green-500 text-green-500 px-4 py-1 mr-2 rounded hover:bg-green-500 hover:text-white transition"
                  // onClick={() => updateFruit(fruit.id, fruit.name)}
                >
                  編集
                </button>

                {/* 削除ボタン */}
                <button
                  className="border-2 border-red-500 text-red-500 px-4 py-1 rounded hover:bg-red-500 hover:text-white transition"
                 // onClick={() => handleDeleteFruit(fruit.id)}
                >
                  削除
                </button>
              </li>
            ))
          ) : (
            <p>データを取得しています...</p>
          )}
        </ul>
        {/* 新規フルーツ追加フォーム */}
        <form className="flex items-center gap-4">
          <input
            type="text"
            value={newFruit}
            onChange={(e) => setNewFruit(e.target.value)}
            placeholder="新しいフルーツ"
            className="border-2 border-white bg-black text-white p-2"
            style={{ borderRadius: "4px" }}
          />
          <button
            type="submit"
          // onClick={() => {
              addFruit(newFruit);
            }}
            className="border-2 border-blue-500 text-blue-500 px-4 py-2 rounded hover:bg-blue-500 hover:text-white transition"
          >
            新規追加
          </button>
        </form>
      </main>
    </div>
  );
}

この状態でhttp://localhost:3000にアクセスすると、DBのデータの取得、表示が確認できます

DBとデータのやり取りを行うPOST、PUT、DELETEの処理を作成
route.tsとpage.tsを以下のように書き換えます
route.ts

import pg from "pg";
import { NextResponse } from "next/server";

const { Pool } = pg;

// データベース接続プールを設定
const pool = new Pool({
  connectionString: process.env.POSTGRES_URL, // 環境変数から接続情報を取得
});

// GETリクエストを作成
export async function GET() {
  try {
    const res = await pool.query("SELECT id, name from fruits");
    return NextResponse.json({ fruits: res.rows });
  } catch (error) {
    return NextResponse.json(
      {
        error: error instanceof Error ? error.message : "Failed to fetch data",
      },
      { status: 500 }
    );
  }
}

// POSTリクエストを作成
export async function POST(request: Request) {
  try {
    const { newFruit } = await request.json();
    const res = await pool.query(
      "INSERT INTO fruits (name) VALUES ($1) RETURNING *",
      [newFruit]
    );
    return NextResponse.json({ fruit: res.rows[0] }, { status: 201 });
  } catch (error) {
    return NextResponse.json(
      {
        error: error instanceof Error ? error.message : "Failed to insert data",
      },
      { status: 500 }
    );
  }
}

// PUTリクエストを作成
export async function PUT(request: Request) {
  try {
    const { id, updatedFruits } = await request.json();
    const res = await pool.query(
      "UPDATE fruits SET name = $1 WHERE id = $2 RETURNING *",
      [updatedFruits, id]
    );
    return NextResponse.json({ fruit: res.rows[0] }, { status: 200 });
  } catch (error) {
    return NextResponse.json(
      {
        error: error instanceof Error ? error.message : "Failed to update data",
      },
      { status: 500 }
    );
  }
}

/// DELETEリクエストを作成
export async function DELETE(request: Request) {
  try {
    const { id } = await request.json();
    const res = await pool.query(
      "DELETE FROM fruits  WHERE id = $1 RETURNING *",
      [id]
    );
    return NextResponse.json({ fruit: res.rows[0] }, { status: 200 });
  } catch (error) {
    return NextResponse.json(
      {
        error: error instanceof Error ? error.message : "Failed to delete data",
      },
      { status: 500 }
    );
  }
}
      

page.ts

"use client";
import { useState, useEffect } from "react";

export default function Home() {
  const [fruits, setFruits] = useState<{ id: number; name: string }[]>([]);
  const [newFruit, setNewFruit] = useState<string>(""); // 新規追加用のステート
  const [error, setError] = useState<string | null>(null);

  // フルーツ名の変更を反映するハンドラ
  const handleFruitChange = (index: number, newValue: string) => {
    const updatedFruits = [...fruits];
    updatedFruits[index] = {
      ...updatedFruits[index],
      name: newValue,
    };
    setFruits(updatedFruits);
  };

  // フルーツ追加用のハンドラ
  async function addFruit(newFruit: string) {
    console.log(newFruit);
    const response = await fetch("/api/fruits", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ newFruit }),
    });

    const data = await response.json();
    if (response.ok) {
      console.log("Added fruit:", data.fruit);
    } else {
      console.error("Error:", data.error);
    }
  }

  //フルーツ更新用のハンドラ
  async function updateFruit(id: number, updatedFruits: string) {
    const response = await fetch("/api/fruits", {
      method: "PUT",
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ id, updatedFruits }),
    });

    const data = await response.json();
    if (response.ok) {
      console.log("Updated fruit:", data.fruit);
      window.location.reload();
    } else {
      console.error("Error:", data.error);
    }
  }

  // フルーツ削除用のハンドラ
  async function handleDeleteFruit(id: number) {
    const response = await fetch("/api/fruits", {
      method: "DELETE",
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ id }),
    });
    const data = await response.json();
    if (response.ok) {
      console.log("Updated fruit:", data.fruit);
      window.location.reload();
    } else {
      console.error("Error:", data.error);
    }
  }

  useEffect(() => {
    const fetchFruits = async () => {
      try {
        const res = await fetch("/api/fruits"); //先程作成したAPI
        const data = await res.json();
        if (!res.ok) {
          throw new Error(data.error || "Something went wrong");
        }
        setFruits(data.fruits);
      } catch (error) {
        if (error instanceof Error) {
          setError(error.message);
        } else {
          setError("An unknown error occurred");
        }
      }
    };
    fetchFruits();
  }, []);

  return (
    <div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20 font-[family-name:var(--font-geist-sans)]">
      <main className="flex flex-col gap-8 row-start-2 items-center sm:items-start">
        <h1>APIテスト</h1>
        {error && <p style={{ color: "red" }}>{error}</p>}
        <ul>
          {fruits.length > 0 ? (
            fruits.map((fruit, index) => (
              <li key={fruit.id}>
                <input
                  type="text"
                  value={fruit.name}
                  onChange={(e) => handleFruitChange(index, e.target.value)}
                  className="border-2 border-white bg-black text-white p-2 mb-5 mr-5"
                  style={{ borderRadius: "4px" }}
                />
                {/* 編集ボタン */}
                <button
                  className="border-2 border-green-500 text-green-500 px-4 py-1 mr-2 rounded hover:bg-green-500 hover:text-white transition"
                  onClick={() => updateFruit(fruit.id, fruit.name)}
                >
                  編集
                </button>

                {/* 削除ボタン */}
                <button
                  className="border-2 border-red-500 text-red-500 px-4 py-1 rounded hover:bg-red-500 hover:text-white transition"
                  onClick={() => handleDeleteFruit(fruit.id)}
                >
                  削除
                </button>
              </li>
            ))
          ) : (
            <p>データを取得しています...</p>
          )}
        </ul>
        {/* 新規フルーツ追加フォーム */}
        <form className="flex items-center gap-4">
          <input
            type="text"
            value={newFruit}
            onChange={(e) => setNewFruit(e.target.value)}
            placeholder="新しいフルーツ"
            className="border-2 border-white bg-black text-white p-2"
            style={{ borderRadius: "4px" }}
          />
          <button
            type="submit"
            onClick={() => {
              addFruit(newFruit);
            }}
            className="border-2 border-blue-500 text-blue-500 px-4 py-2 rounded hover:bg-blue-500 hover:text-white transition"
          >
            新規追加
          </button>
        </form>
      </main>
    </div>
  );
}

上記の内容でファイルを書き換えることで、
データの追加、更新、削除ができることを確認できるかと思います

Vercelにデプロイ時の注意点
※Ts-lintに引っかかっていたらビルドエラーになるため、未使用変数やパッケージインストール時に「npm i –save-dev @types/pg」のようにインストールしているかなど
 ビルド前の確認が大切です!
※エラー回避のためにpnpm install –no-frozen-lockfile上記を実行しておくこともお忘れなく

最後に


Next.jsとVercelを使用することでさくっととAPIを実装、試せるのはとても便利でした!
また、Next.jsのみでクライアント処理もサーバー側の処理も一括して作成できるのは相当な強みであると同時にAPIについての理解の助けになるのではないかと思います。
実戦に勝る学習はありませんから、より理解を含めてみようという方はぜひ!
Next.jsとVercelでさくっと学習してみてくださいー!