はじめに
Next.js 14 の App Router と Supabase を組み合わせて、外国語話者向けの ES(エントリーシート)生成 Web アプリを作ったときの話を共有します。
やったこと
多言語(10 言語)対応: 日本語 / 英語 / 中国語 / 韓国語 / スペイン語 / フランス語 / ドイツ語 / ポルトガル語 / ロシア語 / アラビア語
業界別テンプレート: 商社 / IT / メーカー / 金融 / サービス
サーバーサイドで LLM を呼び出して ES 生成
生成結果は DB に保存して後から編集可能
技術スタック
typescript // app/api/es/generate/route.ts import { NextRequest, NextResponse } from 'next/server'; import { createSupabaseAdmin } from '@/lib/supabase'; import { deductCredit } from '@/lib/credits';
export async function POST(req: NextRequest) { const { input, language, industry } = await req.json(); const userId = req.headers.get('x-user-id');
const deduct = await deductCredit(userId, 'es'); if (!deduct.success) { return NextResponse.json({ error: 'insufficient_credits' }, { status: 402 }); }
const prompt = `次の情報をもとに、業界:${industry}、言語:${language} で ES を生成してください: ${input}`; const { data, error } = await supabase.functions.invoke('llm-call', { body: { prompt, model: 'qwen-plus', max_tokens: 800 } });
if (error) return NextResponse.json({ error: 'llm_failed' }, { status: 500 }); return NextResponse.json({ output: data.output }); } ```
実装でハマったところ
1. force-dynamic と generateStaticParams の衝突
```typescript // NG: 本番で DYNAMIC_SERVER_USAGE 500 になる export const dynamic = 'force-dynamic'; export async function generateStaticParams() { ... }
// OK: force-static + revalidate 3600 に統一 export const dynamic = 'force-static'; export const revalidate = 3600; export async function generateStaticParams() { ... } ```
ローカル dev では再現しないので、本番デプロイ後に vercel logs の digest を見て初めて気づく系のやつ。
2. 多言語 JSON の null 安全
翻訳 JSON に欠損フィールドがあると Cannot read properties of undefined で落ちる。
```typescript // qText() ヘルパーで正規化 export function qText(value: unknown): string { if (value == null) return ''; if (typeof value === 'string') return value; if (Array.isArray(value)) return value.map(qText).join(' '); if (typeof value === 'object') { const v = (value as Record<string, unknown>).text ?? (value as Record<string, unknown>).value; return typeof v === 'string' ? v : ''; } return String(value); } ```
3. クレジット消費の二重実行防止
```typescript // use_credits RPC はアトミックなので OK だが、フロントの楽観更新で二重クリックされるとまずい const inFlightRef = useRef(false); const handleGenerate = async () => { if (inFlightRef.current) return; inFlightRef.current = true; try { await generate(); } finally { inFlightRef.current = false; } }; ```
4. LLM 出力の後処理
```typescript import { z } from 'zod';
const EsOutputSchema = z.object({ title: z.string(), body: z.string().max(2000), industry_match: z.enum(['trading', 'it', 'manufacturer', 'finance', 'service']), });
const parsed = EsOutputSchema.safeParse(JSON.parse(llmRawOutput)); if (!parsed.success) throw new Error('schema_invalid'); ```
zod でパースできないものは再生成 or フォールバックに逃がす。
構成図(テキスト)
``` [Browser] ↓ (form submit) [Next.js Route Handler /api/es/generate] ↓ (deduct credit → Supabase RPC use_credits) ↓ (invoke Supabase Edge Function llm-call) ↓ (write to es_outputs table) [Supabase] ↓ (return JSON) [Browser] ← display result + edit form ```
学んだこと
多言語展開は i18n ルーティング より static generate の方が安定(dynamic + 10 言語は事故る)
LLM 出力は 必ず zod などで後処理
無料枠と有料枠の境界は 総当たりテスト が一番確実
Supabase の RPC はサーバーサイドで呼ぶ(クライアントから直叩きしない)
監視は Vercel の function logs + digest を本番デプロイ直後に必ず見る
次の課題
生成結果の A/B テスト基盤
多言語テンプレートのレビュー UI
クレジット消費の可視化(ユーザー向け)
参考
Next.js 14 App Router: https://nextjs.org/docs/app
Supabase Edge Functions: https://supabase.com/docs/guides/functions
zod: https://zod.dev