Streaming chat using the OpenAI API.
Live: https://nextjs-ai-app-sand.vercel.app
Next.js 16 (App Router, Turbopack) · React 19 · Tailwind CSS v4 · OpenAI SDK v6 (gpt-4o-mini) · Web Streams API · TypeScript 5 · Vercel.
app/
├── api/chat/route.ts POST /api/chat (streaming)
├── chat/
│ ├── _components/ Co-located UI
│ │ ├── chat-header.tsx
│ │ ├── chat-input.tsx
│ │ ├── message-bubble.tsx
│ │ └── message-list.tsx
│ ├── _types.ts
│ └── page.tsx Orchestrator
├── layout.tsx
├── page.tsx
└── globals.css
Flow: ChatInput fires onSubmit(content) → page.tsx appends the user message and POSTs the history to /api/chat → server calls OpenAI with stream: true and pipes a ReadableStream back → client reads chunks via getReader(), decodes with TextDecoder, and appends to the last bubble.
cp .env.example .env.local # add OPENAI_API_KEY
npm install
npm run devOpen http://localhost:3000/chat.
| Var | Description |
|---|---|
OPENAI_API_KEY |
OpenAI API key |
ADRs in Michael Nygard format.
Context: Pages Router is in maintenance mode in Next.js 16.
Decision: App Router with folder-based routing. Endpoints in app/api/chat/route.ts. Components co-located under _components/.
Consequences: Aligned with current conventions. Server Components shrink the client bundle. More file conventions to remember.
Context: Vercel AI SDK abstracts streaming and state via useChat.
Decision: Use the openai SDK directly. Manage streams manually with ReadableStream and getReader().
Consequences: More code, but explicit handling of Web Streams, async iterators, and back-pressure. Swapping providers means changing one SDK.
Context: SSE (text/event-stream) makes sense for multi-event channels or auto-reconnect.
Decision: text/plain with raw token chunks.
Consequences: Less framing on both ends. Errors signaled via HTTP status before the stream or .read() rejection mid-stream. Move to SSE when tool calls or multiple channels are needed.
Context: Multiple setState calls inside async code; closures capture stale state.
Decision: Use setMessages((prev) => ...) whenever the new state depends on the previous one.
Consequences: No stale-closure bug. Slightly more verbose.
Context: Either push an empty bubble before the fetch or wait until the first chunk lands.
Decision: Wait. The typing indicator covers the gap; the bubble appears already populated on the first token.
Consequences: No empty-bubble flash. Read loop needs a firstChunk flag. Error handling distinguishes pre-stream (append) from mid-stream (replace) failures.
Context: Component placement: global folder, route-local, or inline.
Decision: _components/ next to the route. Underscore opts out of routing.
Consequences: Components live with the route that consumes them. Promote when actually reused; no premature abstraction.
Auto-deploy on push to main. OPENAI_API_KEY set in Vercel project settings (Production + Preview).
- Runtime: Node (OpenAI SDK isn't Edge-compatible)
- Cold start: ~1-3s on first request
- Vercel Hobby tier (free)