はじめに
「結婚レース」「ダイエットレース」「出世レース」——レース名と走者(2〜5人)を入れるだけで、AIが実況・解説つきのレース展開をでっち上げてくれるだけのアプリ、なんでもレースを作りました。
きっかけは単純で、「Vercel AI SDK の Structured Outputs(generateObject / streamObject)でおもちゃを作りたかっただけ」です。Zodスキーマを渡すだけでレース展開・実況・順位をまるごと生成させられました。この記事では、何を作ったか、どういう仕組みで動いているかをさらっと紹介します。
作ったもの
-
/createでテーマ(レース名)と走者を入力する - 「レース開始!」を押すと、コース上をカート姿のキャラが走るアニメーションと一緒に、AIが実況を生成し始める
- 実況は生成が終わるのを待たずに、確定した分から順に画面へ流れる(実況キャラのワイプ付き)
- 結果ページで表彰台・ランキング・ドラマイベント・総評を表示
走者名・プロフィールは自由入力なので、「結婚レース」なら「マッチングアプリに登録した」「プロポーズした」のような、レース名から連想されるテーマの出来事としてAIが展開を書きます。完走タイムのような数値は出さず、「入籍しました」「アプリ登録どまり」のような、そのテーマでどこまで進んだかを表す短いラベルを表彰台に載せています。
技術構成
| 分類 | 採用技術 |
|---|---|
| フレームワーク | Next.js 16 (App Router / Turbopack), React 19 |
| スキーマ / バリデーション | Zod v4 |
| AI | Vercel AI SDK (ai v7) + @ai-sdk/openai, モデルは gpt-5-nano
|
| フォーム | react-hook-form + zodResolver
|
| スタイル | Tailwind CSS v4 |
| Lint / Format | Biome |
| ホスティング | Vercel |
DBは使っていません。レース結果は「AIが生成したJSONをそのままURLに圧縮して埋め込む」形で持ち回っています(後述の図で説明します)。個人の小さいエンタメアプリでここにDBを持ち込むと、永続化層のぶんだけ複雑さが増えるだけだと判断しました。
どういう経路で生成して、どう受け取っているか
このアプリのサーバー側がやっていることは実はシンプルで、「OpenAIにストリーミングで生成させながら、その部分的な結果を自分もストリーミングでブラウザへ中継する」の一言に尽きます。ストリーミングが二重になっているのがポイントです。
サーバーは1回のHTTPリクエストの中で、OpenAIからの partialObjectStream を受けながら、確定した実況要素だけを取り出してその都度ブラウザへ書き込みます(1行1JSONのNDJSON形式)。ブラウザ側は fetch の ReadableStream を1行ずつ読みながら、commentary 行が来たら実況として表示し、最後の result 行が来たら生成完了として扱います。
サーバー側は何も保存しません。最終結果が届いたらブラウザがそれを lz-string で圧縮してURLに埋め込み、そのURLへ遷移するだけです。結果ページ(/r)はクエリ文字列をデコードして描画するだけなので、DBもセッションも必要ありません。
生成の入り口はこれだけです。
const result = streamObject({
model: openai("gpt-5-nano"),
schema: generatedRaceSchema, // Zodスキーマそのもの
instructions: buildRaceSystemPrompt(),
prompt: buildRaceUserPrompt(setup),
});
Zodスキーマを渡すだけで、実況・ドラマイベント・順位・総評までまるごと型付きで返ってきます。バリデーションもパース処理も自分では書いていません。
おわりに
「なんでもレース」は、突き詰めると「入力を実況台本に変換するだけ」のアプリです。
なんでも、レースにしよう。
🐱 でぶねこ|猫好きエンジニア
週2ペースでゆるくネタ系Webアプリを作ってます。
良ければXもフォローしてください🐾
▶ X: https://x.com/dev_cat222


