n4nAI

Build a streaming chat UI with Vercel AI SDK's useChat

Learn to build a streaming chat UI with the Vercel AI SDK useChat hook in Next.js, from API route to live token streaming in a runnable tutorial.

n4n Team3 min read646 words

Audio narration

Coming soon — every post will get a voice note here.

This vercel ai sdk usechat streaming chat ui tutorial walks through building a token-streaming chat interface in Next.js using the useChat hook and a server route that streams from an LLM. You’ll get runnable code for both ends and see exactly what the UI does as tokens arrive.

Prerequisites

  • Node.js 18+ (Edge runtime works fine on Node 18+ for the route)
  • A Next.js 14+ project using the App Router
  • An API key from an OpenAI-compatible provider, or a gateway that exposes one endpoint
  • Basic familiarity with React client components

If you don’t have a Next.js app yet, scaffold one:

npx create-next-app@latest chat-app --app --ts --no-tailwind
cd chat-app

Install the SDK packages:

npm i ai @ai-sdk/react @ai-sdk/openai

Server: the streaming route

The server side is a single POST handler. It receives the message array from the client, calls streamText, and returns a data stream response that useChat understands natively.

Create app/api/chat/route.ts:

import { openai } from '@ai-sdk/openai';
import { streamText } from 'ai';

// Use edge if you want lower cold starts; node works too.
export const runtime = 'edge';

export async function POST(req: Request) {
  const { messages } = await req.json();

  const result = streamText({
    model: openai('gpt-4o-mini'),
    messages,
  });

  return result.toDataStreamResponse();
}

If you’d rather not juggle multiple provider keys, point the provider at an OpenAI-compatible gateway. For example, n4n.ai exposes one endpoint covering 240+ models with automatic fallback on provider degradation; just swap the client:

import { createOpenAI } from '@ai-sdk/openai';

const openai = createOpenAI({
  baseURL: 'https://api.n4n.ai/v1',
  apiKey: process.env.N4N_API_KEY,
});

The rest of the code stays identical. useChat neither knows nor cares which backend produced the stream.

Client: the useChat hook

Create a client component at app/components/chat.tsx. The hook manages message state, input binding, submission, and streaming reconciliation.

'use client';

import { useChat } from '@ai-sdk/react';

export function Chat() {
  const {
    messages,
    input,
    handleInputChange,
    handleSubmit,
    isLoading,
    stop,
    error,
  } = useChat();

  return (
    <div style={{ maxWidth: 700, margin: '0 auto' }}>
      {error && (
        <div style={{ color: 'red', marginBottom: 8 }}>
          Error: {error.message}
        </div>
      )}

      <div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
        {messages.map((m) => (
          <div key={m.id} style={{ whiteSpace: 'pre-wrap' }}>
            <strong>{m.role}:</strong> {m.content}
          </div>
        ))}
      </div>

      <form
        onSubmit={handleSubmit}
        style={{ marginTop: 16, display: 'flex', gap: 8 }}
      >
        <input
          value={input}
          onChange={handleInputChange}
          placeholder="Type a message…"
          style={{ flex: 1, padding: 8 }}
        />
        <button type="submit" disabled={isLoading}>
          Send
        </button>
        <button type="button" onClick={stop} disabled={!isLoading}>
          Stop
        </button>
      </form>
    </div>
  );
}

Wire it into a page

Edit app/page.tsx:

import { Chat } from './components/chat';

export default function Page() {
  return (
    <main style={{ padding: 24 }}>
      <h1>Streaming Chat</h1>
      <Chat />
    </main>
  );
}

Checkpoint: first stream

Run npm run dev and open http://localhost:3000. Type “Explain recursion in one sentence” and hit Send.

Expected behavior:

  • The input clears immediately.
  • A new user message appears.
  • An assistant message appears with empty content, then fills in word-by-word as the model streams.
  • The Send button disables while isLoading is true; Stop aborts the stream.

You should see something like:

user: Explain recursion in one sentence
assistant: A function that calls itself to solve smaller instances of the same problem…

The tokens arrive incrementally because toDataStreamResponse() emits Server-Sent Events that useChat parses and appends to m.content.

Why useChat beats a hand-rolled fetch

You could stream with fetch + ReadableStream manually, but you’d reimplement:

  • Message ID generation and deduplication
  • Partial token buffering and React state batching
  • Abort controller wiring for Stop
  • Error normalization

useChat does this with documented semantics. In this vercel ai sdk usechat streaming chat ui tutorial we lean on that so the client stays 30 lines.

One opinion: keep the route thin. Don’t transform messages server-side unless you’re doing RAG or guardrails. The hook sends a standard {role, content}[] shape that streamText accepts directly.

Handling provider errors

If the model 500s or the key is invalid, useChat surfaces an error object. Add a retry by calling regenerate (available from the hook) or just resubmitting. For production, wrap streamText in try/catch and return a clean status:

export async function POST(req: Request) {
  try {
    const { messages } = await req.json();
    const result = streamText({ model: openai('gpt-4o-mini'), messages });
    return result.toDataStreamResponse();
  } catch (e) {
    return new Response('Model unavailable', { status: 502 });
  }
}

The client error will then show “Model unavailable” via error.message only if you map statuses; otherwise it shows a generic stream error. Keep your error contract explicit.

Adding cache-control hints

If your gateway or provider supports prompt caching, forward the hint. With the AI SDK you pass providerOptions:

const result = streamText({
  model: openai('gpt-4o-mini'),
  messages,
  providerOptions: { openai: { cacheControl: true } },
});

n4n.ai honors client routing directives and forwards provider cache-control hints, so the same option works without client changes if you use that endpoint.

UX touches that matter

Streaming UIs feel broken without feedback. Add:

  • A blinking cursor or “thinking” state when isLoading and no assistant content yet.
  • scrollIntoView on the message list bottom via a ref and useEffect.
  • Disable input while loading to prevent queueing duplicates.
const bottomRef = useRef<HTMLDivElement>(null);
useEffect(() => {
  bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);

Place <div ref={bottomRef} /> after the map.

Production considerations

  • Never expose your provider key to the client. The route holds it via process.env.
  • Use export const runtime = 'edge' only if your provider latency benefits; otherwise node runtime is simpler for debugging.
  • For multi-model UIs, pass body: { model } to useChat and read it in the route to select openai(reqBody.model).
  • Meter usage if you bill customers. The stream response doesn’t expose tokens directly; use onFinish in streamText to log usage:
const result = streamText({
  model: openai('gpt-4o-mini'),
  messages,
  onFinish: ({ usage }) => {
    console.log('tokens', usage);
  },
});

That’s the full loop. This vercel ai sdk usechat streaming chat ui tutorial gave you a runnable Next.js chat with real token streaming, stop support, and a clean separation between UI state and model streaming. Swap the model string or the base URL and the frontend doesn’t change.

Tagsvercel-ai-sdkusechatstreamingchat-ui

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All vercel ai sdk streaming chat ui (usechat) posts →