はじめに
以前 Research→Experience API を作りました という記事で、FastAPI + PostgreSQL(pgvector) で「ビジネス課題 → 関連論文探索 → 示唆」を返す API を紹介しました。
今回は、この API を体験できるフロントエンド UI を Next.js(Vercel)ブログの /portfolio/r2e に追加した実装記です。バックエンドは Render の無料枠で動かしています。
完成したもの
-
URL:
/portfolio/r2e - 機能: 3タブUI(Search / Sources / Status)
-
バックエンド: Render(
https://research-to-experience-api.onrender.com) - フロントエンド: Next.js(Vercel)
Search タブでビジネス課題を入力すると、Sources タブに論文リストが表示されます。
アーキテクチャ設計
要件
- フロントエンドからバックエンド API を叩く
- 本番環境では Render、開発環境では localhost:8000 を使い分け
- CORS を避けたい(同一オリジン扱いにしたい)
選択肢
最初は Next.js の rewrites を使うことを考えましたが、Vercel 上で POST リクエストが正しくプロキシされない問題が発生しました。最終的に Next.js API Route でバックエンドにプロキシする方式にしました。
// src/app/api/r2e/[...path]/route.ts
export async function GET(request: NextRequest, { params }: { params: { path: string[] } }) {
return proxyRequest(request, params.path, 'GET')
}
export async function POST(request: NextRequest, { params }: { params: { path: string[] } }) {
return proxyRequest(request, params.path, 'POST')
}
ディレクトリ構成
external/research-to-experience-api/
├── client.ts # APIクライアント(search, health)
└── README.md # 使い方
src/app/
├── api/r2e/[...path]/
│ └── route.ts # バックエンドプロキシ
└── portfolio/r2e/
└── page.tsx # メインUI
実装詳細
1. API クライアント(薄いラッパー)
external/ フォルダに API クライアントを配置。ドメイン非依存の相対パスで実装しています。
export type SearchResponse = {
summary?: string[]
business_implications?: string[]
sources?: Source[]
}
export async function search(query: string): Promise<SearchResponse> {
const r = await fetch('/api/r2e/search', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ query }),
})
if (!r.ok) {
const errorData = await r.json().catch(() => ({}))
if (r.status === 504) {
throw new Error('バックエンドAPIのコールドスタート中です。30秒ほど待ってから再度お試しください。')
}
throw new Error(errorData.details || `HTTP ${r.status}`)
}
return r.json()
}
2. API Route(プロキシ実装)
Next.js API Route でバックエンド API にプロキシします。環境変数 BACKEND_URL で開発/本番を切り替えます。
const BACKEND_URL = process.env.BACKEND_URL || 'https://research-to-experience-api.onrender.com'
async function proxyRequest(
request: NextRequest,
pathSegments: string[],
method: 'GET' | 'POST'
) {
const path = pathSegments.join('/')
const url = `${BACKEND_URL}/${path}`
const options: RequestInit = { method, headers: { 'Content-Type': 'application/json' } }
if (method === 'POST') {
const body = await request.text()
options.body = body
}
// タイムアウト設定(Render のコールドスタート対応)
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 90000)
try {
const response = await fetch(url, { ...options, signal: controller.signal })
clearTimeout(timeoutId)
const data = await response.text()
const jsonData = JSON.parse(data)
return NextResponse.json(jsonData, { status: response.status })
} catch (fetchError) {
clearTimeout(timeoutId)
if (fetchError instanceof Error && fetchError.name === 'AbortError') {
return NextResponse.json(
{ error: 'Request timeout', details: 'Backend API did not respond within 90 seconds...' },
{ status: 504 }
)
}
throw fetchError
}
}
3. メインUI(3タブ構成)
React の state でタブ管理、API クライアントでデータ取得します。
export default function R2EPage() {
const [tab, setTab] = useState<Tab>('Search')
const [query, setQuery] = useState('')
const [loading, setLoading] = useState(false)
const [resp, setResp] = useState<SearchResponse | null>(null)
const doSearch = async () => {
setLoading(true)
setError(null)
try {
const data = await doApiSearch(query)
setResp(data)
setTab('Sources') // 成功時はSourcesへ切替
} catch (e: unknown) {
const message = e instanceof Error ? e.message : 'request failed'
setError(message)
} finally {
setLoading(false)
}
}
// ... UI実装
}
タブ構成:
- Search: ビジネス課題を入力、検索実行
- Sources: 検索結果の論文リスト(Title, URL, Confidence)
-
Status:
/healthエンドポイントの疎通確認
環境設定
Vercel 環境変数
Vercel CLI で環境変数を設定しました。
# Production / Preview
echo -n "https://research-to-experience-api.onrender.com" | vercel env add BACKEND_URL production
echo -n "https://research-to-experience-api.onrender.com" | vercel env add BACKEND_URL preview
# Development(ローカル開発用)
echo -n "http://localhost:8000" | vercel env add BACKEND_URL development
Render デプロイ
バックエンドは Render の無料枠でデプロイ。Dockerfile を使っています。
-
URL:
https://research-to-experience-api.onrender.com -
Health Check Path:
/health - Auto-Deploy: Yes
ハマりポイントと対応
1. POST リクエストが 405 エラー
問題: Next.js の rewrites を使ったが、Vercel 上で POST が正しくプロキシされなかった。
対応: rewrites をやめ、Next.js API Route でプロキシする方式に変更。
// ✗ 最初の実装(rewrites)
// next.config.mjs
async rewrites() {
return [{ source: '/r2e/:path*', destination: `${BACKEND_URL}/:path*` }]
}
// ✓ 最終実装(API Route)
// src/app/api/r2e/[...path]/route.ts
export async function POST(...) { return proxyRequest(...) }
2. Render のコールドスタート
問題: Render 無料枠は15分間アクセスがないとスリープし、初回アクセスで30〜60秒かかることがある。
対応:
- タイムアウトを90秒に延長
- 504エラー時に「コールドスタート中です。30秒ほど待ってから再度お試しください。」と案内
if (r.status === 504) {
throw new Error('バックエンドAPIのコールドスタート中です。30秒ほど待ってから再度お試しください。')
}
3. ビルド時の環境変数エラー
問題: Vercel ビルド時に BACKEND_URL が undefined になり、rewrites の destination が undefined/:path* になってエラー。
対応: API Route ではランタイムで process.env.BACKEND_URL を参照するため、ビルド時の問題は発生しない(rewrites から API Route に変更したので解決)。
学んだこと
- Next.js の rewrites は GET には向くが、POST は API Route が確実
- 無料ホスティング(Render)のコールドスタートを考慮した UX 設計が重要
- 環境変数はビルド時とランタイムで扱いが異なる
まとめ
- Next.js API Route でバックエンドプロキシを実装
- 3タブUIで R2E API を体験できるUIを追加
- Render のコールドスタートに対応
- Vercel 環境変数で開発/本番を切り替え
実際に /portfolio/r2e にアクセスして、ビジネス課題から論文探索までの体験を試してみてください!
Top comments (0)