🔖

【初学者向け】FastAPIにNextjsを乗せるには

2025/03/07に公開

1. はじめに

https://github.com/nto300002/fast-api-todo
https://github.com/nto300002/fast-api-todo_front-next

個人開発で、GoogleCloudFunctions を使って機械学習系の機能を盛り込みたいというニーズがあり、その分野では比較的日本語のドキュメントが豊富そうな python のフレームワークを使いたいと思い目についたのが FastAPI でした。
よく比較される Django や Flask といったフレームワークもありますが、私個人としては Django より Flask のほうが使いやすく(Django 独自の仕様を学ぶ必要があるため)、FastAPI も Flask に似た性能かつネット上での評判も良いため、今回は FastAPI を選びました。

2. 開発環境のセットアップ

環境構築
fast api

pip install fastapi

uvicorn(ASGI サーバー)

pip install "uvicorn[standard]"

3. バックエンド開発(FastAPI)

環境構築

main.py

from fastapi import FastAPI
import uvicorn

app = FastAPI()


@app.get("/")
async def root():
    return {"message": "Hello World"}

if __name__ == "__main__":
    uvicorn.run(app, host="0.0.0.0", port=8000, log_level="debug")

バックエンドサーバー起動

uvicorn main:app --reload

port8000 に繋いであるので、ブラウザ上で
localhost:8000 に繋げば"message": "Hello World"が出力されているはずです。
これでセットアップ完了!

DB 構築

基本はこちら[https://qiita.com/abek21/items/7739163085899b257cb8]のデータベースの利用から続きを実装していきます。

必要パッケージ

pip install alembic SQLAlchemy

SQLAlchemy は ORM という SQL をプログラム言語の仕様で扱えるものの一種です。コードの仕様を理解していれば生の SQL を操作するより簡単かもしれません。

マイグレーションを初期状態に

alembic init migrations

DB は Sqlite を使用します sqlite ファイルで DB を扱うことができます
SQLAlchemy を install した際に作成されたalembic.inisqlalchemy.urlを以下のように変更します

sqlalchemy.url = sqlite:///sample.sqlite

main.py と同じ階層にsettings.pyを作成します。DB の接続情報を管理します。

from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base #ORMの基底クラス
from sqlalchemy.orm import sessionmaker

SQLALCHEMY_DATABASE_URL = 'sqlite:///sample.sqlite' #sqliteファイルにデータ保存

engine = create_engine(
    SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False} #複数スレッドからのアクセスを許可するか False=許可
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) #自動コミット フラッシュ セッションの紐づけ

Base = declarative_base()

モデル作成
models.py

from sqlalchemy import Column, Integer, String, DateTime
from datetime import datetime
from settings import Base


class TodoModel(Base):
    __tablename__ = 'todo'

    id = Column(Integer, primary_key=True)
    title = Column(String)
    created_date = Column(DateTime, default=datetime.utcnow)

先ほど install した ORM の書き方をここで使います

マイグレーションを編集
migrations/env.py

import 文を追加

from settings import Base
from models import *

target_metadataを編集

target_metadata = Base.metadata

定義したモデルをもとに実際に DB にテーブルを作成するコードを作る

alembic revision --autogenerate -m "create todo table"

migrations/versions/XXXXXXXXXXXX_create_todo_table.pyというマイグレーションが作成される

def upgrade() -> None:
    # ### commands auto generated by Alembic - please adjust! ###
    op.create_table('todo',
    sa.Column('id', sa.Integer(), nullable=False),
    sa.Column('title', sa.String(), nullable=True),
    sa.Column('created_date', sa.DateTime(), nullable=True),
    sa.PrimaryKeyConstraint('id')
    )

DB に上記コードを適用
alembic upgrade head

エンドポイント作成(main.py)

from fastapi import FastAPI, Depends
import uvicorn

from schemas import PostTodo
from models import TodoModel
from settings import SessionLocal

from sqlalchemy.orm import Session


app = FastAPI()

def get_db():
    db = SessionLocal()
    try:
        yield db
    finally:
        db.close()


@app.get("/")
async def root():
    return {"message": "Hello World"}


# データベースからToDo一覧を取得するAPI
@app.get("/todo")
def get_todo(
        db: Session = Depends(get_db)
    ):
    # query関数でmodels.pyで定義したモデルを指定し、.all()関数ですべてのレコードを取得
    return db.query(TodoModel).all()

# ToDoを作成するAPI
@app.post("/todo")
def post_todo(
        todo: PostTodo,
        db: Session = Depends(get_db)
    ):
    # 受け取ったtitleからモデルを作成
    db_model = TodoModel(title = todo.title)
    # データベースに登録(インサート)
    db.add(db_model)
    # 変更内容を確定
    db.commit()

    return {"message": "success"}

# ToDoを削除するAPI
@app.delete("/todo/{id}")
def delete_todo(
        id: int,
        db: Session = Depends(get_db)
    ):
    delete_todo = db.query(TodoModel).filter(TodoModel.id==id).one()
    db.delete(delete_todo)
    db.commit()

    return {"message": "success"}


if __name__ == "__main__":
    uvicorn.run(app, host="0.0.0.0", port=8000, log_level="debug")

rails で言うところの MVC に近い雰囲気

完了したら
バックエンドサーバーを起動させておいてください

uvicorn main:app --reload

4. フロントエンド開発(Nextjs)

npx next-create-app@latest

->その後は基本全て Enter で OK
*tailwind css が ver4 になったため環境構築でエラーが発生するようになりました
その際は package.json からバージョンをダウングレードして npm install を試みてください

最初に生成された page.tsx を編集する

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

type Todo = {
  id: number;
  title: string;
};

export default function Home() {
  const [title, setTitle] = useState('');
  const [todos, setTodos] = useState<Todo[]>([]);
  useEffect(() => {
    // ページが読み込まれた時にToDoを取得
    handleGetTodos();
  }, []);
  const handleGetTodos = async () => {
    // ToDoを取得する関数
    const res = await fetch('http://localhost:8000/todo');
    if (!res.ok) {
      throw new Error('Network response was not ok');
    }
    const data = await res.json();
    setTodos(data);
  };
  const handlePostTodo = async () => {
    try {
      const res = await fetch('http://localhost:8000/todo', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({ title }),
      });
      if (!res.ok) {
        throw new Error('Network response was not ok');
      }
      const data = await res.json();
      console.log(data);
      setTitle('');
      handleGetTodos();
      return data;
    } catch (error) {
      console.error('Error:', error);
    }
  };
  const handleDeleteTodo = async (id: number) => {
    try {
      const res = await fetch(`http://localhost:8000/todo/${id}`, {
        method: 'DELETE',
      });
      if (!res.ok) {
        throw new Error('Network response was not ok');
      }
      // 削除成功後にTodoリストを再取得
      handleGetTodos();
    } catch (error) {
      console.error('Error:', error);
    }
  };

  return (
    <main className="flex min-h-screen flex-col items-center p-24">
      <h1>ToDoアプリ</h1>
      <input
        className="text-black"
        type="text"
        value={title}
        onChange={(e) => setTitle(e.target.value)}
      />
      <button onClick={handlePostTodo}>ToDoを作成</button>
      <ul>
        {todos.map((todo) => (
          <li key={todo.id}>
            {todo.title}
            <button
              className="ml-4 text-red-500"
              onClick={() => handleDeleteTodo(todo.id)}
            >
              削除
            </button>
          </li>
        ))}
      </ul>
    </main>
  );
}

これを生成された page.tsx に貼り付けたら

npm run dev

してサーバーを起動してください
以下解説

バックエンドから todo 一覧を取得する(GET)

ページが読み込まれた際に、下記の関数を発動させます。

useEffect(() => {
  // ページが読み込まれた時にToDoを取得
  handleGetTodos();
}, []); //[]に値をいれるとそれが変更されたタイミングでレンダリングが行われる
const handleGetTodos = async () => {
  // ToDoを取得する関数
  const res = await fetch('http://localhost:8000/todo');
  if (!res.ok) {
    throw new Error('Network response was not ok');
  }
  const data = await res.json();
  setTodos(data);
};
const res = await fetch('http://localhost:8000/todo');

main.py

@app.get("/todo")
def get_todo(
        db: Session = Depends(get_db)
    ):
    return db.query(TodoModel).all()

先ほど作成したエンドポイント(main.py)において、"/todo"の GET メソッドにアクセスします。

const data = await res.json();

エラーが起きなかった場合 json 形式でデータが返ってきます

setTodos(data);

setTodos は useStatus という React で使われている hooks です。画面の一部を非同期で更新するために使われます。このデータが画面に表示されます。

todo の追加処理(POST)

const handlePostTodo = async () => {
  try {
    const res = await fetch('http://localhost:8000/todo', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ title }),
    });
    if (!res.ok) {
      throw new Error('Network response was not ok');
    }
    const data = await res.json();
    console.log(data);
    setTitle('');
    handleGetTodos();
    return data;
  } catch (error) {
    console.error('Error:', error);
  }
};

上記のコードは先程と違い POSTMethod なので、明示的に POSTMethod を送っているという記述をしなければなりません。詳しくは HTTP リクエストについて調べると良いと思います。ついでに headers にも json 形式のコンテンツをであると記載しておきます。
エラーがなければ json 形式のデータを POST できるというのは先程の一連の流れとほとんど同じです。

setTitle('');
handleGetTodos();

このコードは、送信後の挙動に関係してきます。これがないと input フォームの値が空にならず、todo の値も更新されません。画面上で自分で更新をかければ値も更新されますが、todo を追加する度にいちいちそれをするのは面倒だし、利便性も下がります。
なので、処理が完了した場合、input の値や todo の値を自動的に更新する関数を handlePostTodo の中に持ってきています。

エンドポイント(main.py)

@app.post("/todo")
def post_todo(
        todo: PostTodo,
        db: Session = Depends(get_db)
    ):
    # 受け取ったtitleからモデルを作成
    db_model = TodoModel(title = todo.title)
    # データベースに登録(インサート)
    db.add(db_model)
    # 変更内容を確定
    db.commit()

    return {"message": "success"}

todo の削除処理(DELETE)

const handleDeleteTodo = async (id: number) => {
  try {
    const res = await fetch(`http://localhost:8000/todo/${id}`, {
      method: 'DELETE',
    });
    if (!res.ok) {
      throw new Error('Network response was not ok');
    }
    // 削除成功後にTodoリストを再取得
    handleGetTodos();
  } catch (error) {
    console.error('Error:', error);
  }
};

フロントエンド側では以下のように li 要素に todo.id という key が配置されています。

{
  todos.map((todo) => (
    <li key={todo.id}>
      {todo.title}
      <button
        className="ml-4 text-red-500"
        onClick={() => handleDeleteTodo(todo.id)}
      >
        削除
      </button>
    </li>
  ));
}

削除ボタンを押す際に関数に todo.id を渡すことで id と同じ todo をバックエンドで削除します。

@app.delete("/todo/{id}")
def delete_todo(
        id: int,
        db: Session = Depends(get_db)
    ):
    delete_todo = db.query(TodoModel).filter(TodoModel.id==id).one()
    db.delete(delete_todo)
    db.commit()

    return {"message": "success"}

todo/${id}がバックエンドに渡ってきたらdelete_todoが実行されます。

delete_todo = db.query(TodoModel).filter(TodoModel.id==id).one()

DB のクエリから TodoModel を選択し、filter で渡ってきた id と同じレコードを 1 件のみ取得します。
そして、delete_todo に代入したのち、db.delete

5. 細かな注意点

フロントエンドとバックエンドで使っているサーバーが異なるため、検証する際はそれぞれのサーバーを起動する必要があります。

CROS(Cross-Origin Resource Sharing)
これはセキュリティに関する設定です。クロスサイトリクエストフォージェリ(CSRF/XSRF)といったサイバー攻撃から Web サイトを守ります。
具体的には異なる URL からのアクセスを制限するものです。オリジンと呼ばれます。
フロントエンドとバックエンドでは別々のサーバーを起動(localhost:8000 と 3000)を起動していますが、バックエンド側でフロントエンドの URL からのアクセスを許可をしなくてはアクセスできないということです。
main.py

# CORSミドルウェアを追加
app.add_middleware(
    CORSMiddleware,
    allow_origins=["http://localhost:3000"],  # フロントエンドのオリジン
    allow_credentials=True,
    allow_methods=["*"],  # すべてのHTTPメソッドを許可
    allow_headers=["*"],  # すべてのヘッダーを許可
)

6. まとめと感想

Flask と設定次第では Rails のように簡単にマイグレーションができて非常に使いやすかったです。Django では元々ある設定のために新たに学習が必要なのと、拡張がしづらいのが自分にとってネックだったので、覚えることが少なく初学者向けなのではないかと思います。
利用経験があり、初学者向けとされる Rails では API モードにするとエンドポイントがわかりにくい印象があり、個人的には難しかったです。
その点、FastAPI はエンドポイントをわかりやすい形で掲示してくれるのでフロントエンドとバックエンドの連携がしやすいと感じました。

Rails todo アプリのコントローラー

module Api
  module V1
    class TodosController < ApplicationController
      before_action :set_todo, only: [:show, :update, :destroy]

      # GET /api/v1/todos
      def index
        @todos = Todo.all.order(created_at: :desc)
        render json: @todos
      end

      # GET /api/v1/todos/:id
      def show
        render json: @todo
      end

      # POST /api/v1/todos
      def create
        @todo = Todo.new(todo_params)

        if @todo.save
          render json: @todo, status: :created
        else
          render json: { errors: @todo.errors.full_messages }, status: :unprocessable_entity
        end
      end

      # PATCH/PUT /api/v1/todos/:id
      def update
        if @todo.update(todo_params)
          render json: @todo
        else
          render json: { errors: @todo.errors.full_messages }, status: :unprocessable_entity
        end
      end

      # DELETE /api/v1/todos/:id
      def destroy
        @todo.destroy
        head :no_content
      end

      private

      def set_todo
        @todo = Todo.find(params[:id])
      end

      def todo_params
        params.require(:todo).permit(:title, :description, :completed)
      end
    end
  end
end

7. 参考リソースとソースコード

公式ドキュメント
https://fastapi.tiangolo.com/ja/#_6

FastAPI を用いた API 開発テンプレート
https://qiita.com/abek21/items/7739163085899b257cb8

GitHubで編集を提案

Discussion