はじめに
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キーをどこに置くか」を意識する習慣がついた。