1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Next.js + FastAPI — フロントとバックエンドをつないだ

1
Posted at

はじめに

Next.jsの基本が使えるようになったので、FastAPIと組み合わせた構成を作った。

PHPのLaravelだとフロントとバックエンドが一体になっていたが、Next.jsとFastAPIを分離した構成は「フロントはNext.js、APIはFastAPI」という役割分担になる。CORS設定やAPI呼び出しの設計など、一体型とは違うポイントがいくつかあったので整理した。


全体構成

[ブラウザ]
    ↓ HTTPリクエスト
[Next.js(ポート3000)]
    ├── Server Component → FastAPIを直接呼ぶ(サーバー間通信)
    ├── Route Handler    → FastAPIへのプロキシ
    └── Client Component → Route Handler経由でFastAPIを呼ぶ
            ↓
[FastAPI(ポート8000)]
            ↓
        [DB / Snowflake / 外部API]

Server ComponentはサーバーサイドでFastAPIを直接呼べるのでCORSを気にしなくていい。Client Componentはブラウザから直接FastAPIを叩くかNext.jsのRoute Handlerをプロキシとして挟む。


FastAPI側のセットアップ

# main.py
from fastapi              import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from pydantic             import BaseModel

app = FastAPI()

# CORS設定
app.add_middleware(
    CORSMiddleware,
    allow_origins     = [
        "http://localhost:3000",      # 開発環境のNext.js
        "https://myapp.example.com",  # 本番のNext.js
    ],
    allow_credentials = True,
    allow_methods     = ["*"],
    allow_headers     = ["*"],
)

# モデル
class User(BaseModel):
    id:    int
    name:  str
    email: str

class UserCreate(BaseModel):
    name:  str
    email: str

# ダミーデータ
fake_users = [
    User(id=1, name="田中", email="tanaka@example.com"),
    User(id=2, name="鈴木", email="suzuki@example.com"),
    User(id=3, name="佐藤", email="sato@example.com"),
]

@app.get("/users", response_model=list[User])
def list_users():
    return fake_users

@app.get("/users/{user_id}", response_model=User)
def get_user(user_id: int):
    user = next((u for u in fake_users if u.id == user_id), None)
    if user is None:
        from fastapi import HTTPException
        raise HTTPException(status_code=404, detail="ユーザーが見つかりません")
    return user

@app.post("/users", response_model=User, status_code=201)
def create_user(user: UserCreate):
    new_user = User(id=len(fake_users) + 1, **user.model_dump())
    fake_users.append(new_user)
    return new_user

Next.jsからリクエストが来るのでCORSを設定する必要がある。allow_originsに開発環境と本番環境のNext.jsのURLを指定する。


環境変数の設定

# .env.local(Next.js側)
FASTAPI_URL=http://localhost:8000       # サーバー間通信用(外部に出ない)
NEXT_PUBLIC_API_URL=http://localhost:8000  # ブラウザから直接叩く場合

FASTAPI_URLはServer Component用。ブラウザには露出しない。NEXT_PUBLIC_はブラウザのJavaScriptからも参照できるが、APIキーなど秘密情報を含む場合はRoute Handler経由にして直接露出を避ける。


パターン① Server ComponentからFastAPIを直接呼ぶ

サーバーサイドの処理なのでCORSを気にしなくていい。

// app/users/page.tsx
type User = {
    id:    number;
    name:  string;
    email: string;
};

async function getUsers(): Promise<User[]> {
    const res = await fetch(`${process.env.FASTAPI_URL}/users`, {
        cache: 'no-store',  // SSR: リクエストのたびに取得
    });

    if (!res.ok) {
        throw new Error(`APIエラー: ${res.status}`);
    }

    return res.json();
}

export default async function UsersPage() {
    const users = await getUsers();

    return (
        <main className="p-8">
            <h1 className="text-2xl font-bold mb-6">ユーザー一覧</h1>
            <div className="grid gap-4">
                {users.map(user => (
                    <div
                        key={user.id}
                        className="p-4 border rounded-lg"
                    >
                        <p className="font-medium">{user.name}</p>
                        <p className="text-gray-600 text-sm">{user.email}</p>
                    </div>
                ))}
            </div>
        </main>
    );
}

Server ComponentはNode.jsで動くのでFastAPIに直接リクエストを送れる。シンプルで一番使いやすいパターン。


パターン② Route HandlerをFastAPIのプロキシとして使う

Client ComponentからAPIを叩く場合、セキュリティや認証トークンを付加したいときにNext.jsのRoute Handlerをプロキシとして挟む。

// app/api/users/route.ts
import { NextRequest, NextResponse } from 'next/server';

const FASTAPI_URL = process.env.FASTAPI_URL || 'http://localhost:8000';

export async function GET(request: NextRequest) {
    const searchParams = request.nextUrl.searchParams.toString();
    const url          = searchParams
        ? `${FASTAPI_URL}/users?${searchParams}`
        : `${FASTAPI_URL}/users`;

    const res = await fetch(url, {
        headers: {
            // 認証トークンをサーバー側で付加できる
            'Authorization': `Bearer ${process.env.INTERNAL_API_KEY}`,
            'Content-Type':  'application/json',
        },
    });

    const data = await res.json();
    return NextResponse.json(data, { status: res.status });
}

export async function POST(request: NextRequest) {
    const body = await request.json();

    const res = await fetch(`${FASTAPI_URL}/users`, {
        method:  'POST',
        headers: {
            'Authorization': `Bearer ${process.env.INTERNAL_API_KEY}`,
            'Content-Type':  'application/json',
        },
        body: JSON.stringify(body),
    });

    const data = await res.json();
    return NextResponse.json(data, { status: res.status });
}
// app/api/users/[id]/route.ts
import { NextRequest, NextResponse } from 'next/server';

const FASTAPI_URL = process.env.FASTAPI_URL || 'http://localhost:8000';

export async function GET(
    request: NextRequest,
    { params }: { params: { id: string } }
) {
    const res  = await fetch(`${FASTAPI_URL}/users/${params.id}`);
    const data = await res.json();

    if (!res.ok) {
        return NextResponse.json(data, { status: res.status });
    }

    return NextResponse.json(data);
}

export async function DELETE(
    request: NextRequest,
    { params }: { params: { id: string } }
) {
    const res = await fetch(`${FASTAPI_URL}/users/${params.id}`, {
        method: 'DELETE',
    });

    if (res.status === 204) {
        return new NextResponse(null, { status: 204 });
    }

    const data = await res.json();
    return NextResponse.json(data, { status: res.status });
}

パターン③ Client ComponentからAPIを呼ぶ

// components/UserForm.tsx
'use client';

import { useState }      from 'react';
import { useRouter }     from 'next/navigation';

type UserCreate = {
    name:  string;
    email: string;
};

export default function UserForm() {
    const router             = useRouter();
    const [name,  setName]   = useState('');
    const [email, setEmail]  = useState('');
    const [error, setError]  = useState('');
    const [loading, setLoading] = useState(false);

    async function handleSubmit(e: React.FormEvent) {
        e.preventDefault();
        setLoading(true);
        setError('');

        try {
            const res = await fetch('/api/users', {  // Route Handlerを経由
                method:  'POST',
                headers: { 'Content-Type': 'application/json' },
                body:    JSON.stringify({ name, email }),
            });

            if (!res.ok) {
                const data = await res.json();
                setError(data.detail || 'エラーが発生しました');
                return;
            }

            router.push('/users');
            router.refresh();  // Server Componentのキャッシュを更新
        } catch (err) {
            setError('通信エラーが発生しました');
        } finally {
            setLoading(false);
        }
    }

    return (
        <form onSubmit={handleSubmit} className="space-y-4">
            {error && (
                <div className="p-3 bg-red-100 text-red-700 rounded">
                    {error}
                </div>
            )}

            <div>
                <label className="block text-sm font-medium mb-1">
                    名前
                </label>
                <input
                    type="text"
                    value={name}
                    onChange={e => setName(e.target.value)}
                    className="w-full border rounded px-3 py-2"
                    required
                />
            </div>

            <div>
                <label className="block text-sm font-medium mb-1">
                    メールアドレス
                </label>
                <input
                    type="email"
                    value={email}
                    onChange={e => setEmail(e.target.value)}
                    className="w-full border rounded px-3 py-2"
                    required
                />
            </div>

            <button
                type="submit"
                disabled={loading}
                className="w-full bg-blue-600 text-white py-2 rounded disabled:opacity-50"
            >
                {loading ? '送信中...' : '作成する'}
            </button>
        </form>
    );
}

router.refresh()でServer Componentのキャッシュを更新している。フォーム送信後にリストが古いまま表示されるのを防ぐ。


型定義の共有

FastAPIのPydanticモデルとNext.jsのTypeScript型を一致させる。

# FastAPI側(schemas.py)
from pydantic import BaseModel
from datetime import datetime

class User(BaseModel):
    id:         int
    name:       str
    email:      str
    created_at: datetime

class UserCreate(BaseModel):
    name:  str
    email: str
// Next.js側(types/user.ts)
export type User = {
    id:         number;
    name:       string;
    email:      string;
    created_at: string;  // JSONではstringになる
};

export type UserCreate = {
    name:  string;
    email: string;
};

手動で一致させる必要がある。FastAPIのOpenAPIスキーマから自動生成する方法もある。

# OpenAPIスキーマからTypeScriptの型を自動生成
npm install -D openapi-typescript

# FastAPIのスキーマを取得して型を生成
npx openapi-typescript http://localhost:8000/openapi.json -o types/api.ts

自動生成しておくとFastAPIのモデルを変えたときにNext.js側の型エラーで気づける。


Docker Composeで両方を起動する

# compose.yaml
services:
  frontend:
    build:
      context: ./frontend
      dockerfile: Dockerfile
    ports:
      - "3000:3000"
    environment:
      - FASTAPI_URL=http://backend:8000  # Docker内部の通信
    depends_on:
      - backend

  backend:
    build:
      context: ./backend
      dockerfile: Dockerfile
    ports:
      - "8000:8000"
    env_file:
      - ./backend/.env
    depends_on:
      db:
        condition: service_healthy

  db:
    image: postgres:16
    environment:
      POSTGRES_DB:       mydb
      POSTGRES_USER:     user
      POSTGRES_PASSWORD: password
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U user -d mydb"]
      interval: 5s
      timeout: 5s
      retries: 5

volumes:
  postgres_data:

Docker Compose内ではサービス名でホスト名が解決される。FASTAPI_URL=http://backend:8000でNext.jsからFastAPIに通信できる。

# 各サービスのDockerfile

# frontend/Dockerfile
FROM node:20-slim
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
CMD ["npm", "start"]

# backend/Dockerfile
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

エラーハンドリングの統一

# FastAPIのエラーレスポンス形式を統一
from fastapi           import FastAPI, HTTPException
from fastapi.responses import JSONResponse
from pydantic          import BaseModel

class ErrorResponse(BaseModel):
    error:   str
    detail:  str | None = None
    code:    str | None = None

@app.exception_handler(HTTPException)
async def http_exception_handler(request, exc):
    return JSONResponse(
        status_code = exc.status_code,
        content     = ErrorResponse(
            error  = exc.detail,
            code   = f"HTTP_{exc.status_code}",
        ).model_dump(),
    )
// Next.js側のエラーハンドリング
type ApiError = {
    error:  string;
    detail: string | null;
    code:   string | null;
};

async function apiRequest<T>(
    url:     string,
    options: RequestInit = {}
): Promise<T> {
    const res = await fetch(url, {
        ...options,
        headers: {
            'Content-Type': 'application/json',
            ...options.headers,
        },
    });

    if (!res.ok) {
        const error: ApiError = await res.json();
        throw new Error(error.error || `HTTP ${res.status}`);
    }

    return res.json();
}

// 使う側
const users = await apiRequest<User[]>('/api/users');

エラーレスポンスの形式をFastAPIとNext.jsで統一しておくと、フロントでのエラー処理が書きやすくなる。


まとめ

パターン 用途 CORS
Server Component → FastAPI 初期データ取得・SEOが必要なページ 不要(サーバー間)
Route Handler → FastAPI 認証トークン付与・プロキシ 不要(サーバー間)
Client Component → Route Handler インタラクティブな操作 不要(同一オリジン)
Client Component → FastAPI直接 シンプルな公開API 必要
  • Server ComponentはFastAPIを直接呼べる。CORS不要でシンプル
  • Client ComponentからはRoute Handler経由が安全(APIキーを隠せる)
  • FastAPIのOpenAPIスキーマからTypeScriptの型を自動生成すると型の一致を保ちやすい
  • Docker Composeではサービス名でホスト名が解決される

LaravelのフルスタックからNext.js + FastAPIの分離構成に移ると設計の考え方が変わる。「どこでデータを取得するか」「APIキーをどこに置くか」を意識する習慣がついた。

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?