0
0

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 14 + Supabase + LLM で多言語 ES 生成アプリを実装した話

0
Posted at

はじめに
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
0
0
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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?