n4nAI

Building a streaming chatbot with Next.js and Vercel Edge Functions

Step-by-step tutorial for a Next.js and Vercel Edge Functions streaming chatbot: scaffold, edge API route, client streaming, and deployment.

n4n Team3 min read581 words

Audio narration

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

Building a nextjs vercel edge functions streaming chatbot requires handling bidirectional state on the client and a serverless edge route that proxies LLM tokens with low latency. This guide walks through a minimal but production-shaped implementation using the Next.js App Router, Edge Runtime, and the OpenAI streaming protocol.

Prerequisites

  • Node.js 18.18+ and npm
  • A Vercel account (free tier works)
  • An LLM provider API key (OpenAI, or any OpenAI-compatible endpoint)
  • Familiarity with React state and fetch streams

Create a project directory and scaffold:

npx create-next-app@latest chatbot-edge --ts --app --no-tailwind --no-eslint
cd chatbot-edge

Set your API key in .env.local:

echo "OPENAI_API_KEY=sk-your-key" > .env.local

If you want automatic fallback when a provider is degraded, you can point the same OpenAI-compatible call at n4n.ai’s single endpoint, which addresses 240+ models and forwards provider cache-control hints. The rest of this tutorial uses the standard OpenAI URL; swap the base URL if you choose a gateway.

Scaffold the edge API route

Create app/api/chat/route.ts. The Edge Runtime keeps cold starts low and lets you stream the provider response straight to the browser.

export const runtime = 'edge';

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

  const upstream = await fetch('https://api.openai.com/v1/chat/completions', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
    },
    body: JSON.stringify({
      model: 'gpt-3.5-turbo',
      messages,
      stream: true,
    }),
  });

  return new Response(upstream.body, {
    headers: {
      'Content-Type': 'text/event-stream',
      'Cache-Control': 'no-cache, no-transform',
    },
  });
}

This route does not buffer. It pipes the LLM’s SSE stream directly to the client. Expected checkpoint: curl from local dev returns data: {...} lines immediately as tokens generate.

curl -N -X POST http://localhost:3000/api/chat \
  -H 'Content-Type: application/json' \
  -d '{"messages":[{"role":"user","content":"Say hi"}]}'

You should see incremental data: {"choices":[{"delta":{"content":"Hello"}}]} lines, ending with data: [DONE].

Build the client chat component

Create app/components/Chat.tsx. Mark it 'use client' and manage three pieces of state: sent messages, current input, and the in-flight streamed text.

'use client';

import { useState } from 'react';

type Msg = { role: 'user' | 'assistant'; content: string };

export default function Chat() {
  const [messages, setMessages] = useState<Msg[]>([]);
  const [input, setInput] = useState('');
  const [streaming, setStreaming] = useState('');

  async function send() {
    if (!input.trim()) return;
    const next = [...messages, { role: 'user' as const, content: input }];
    setMessages(next);
    setInput('');
    setStreaming('');

    const res = await fetch('/api/chat', {
      method: 'POST',
      body: JSON.stringify({ messages: next }),
    });

    const reader = res.body!.getReader();
    const decoder = new TextDecoder();
    let buffer = '';

    while (true) {
      const { done, value } = await reader.read();
      if (done) break;
      buffer += decoder.decode(value, { stream: true });
      const lines = buffer.split('\n');
      buffer = lines.pop() ?? '';
      for (const line of lines) {
        if (!line.startsWith('data:')) continue;
        const payload = line.slice(5).trim();
        if (payload === '[DONE]') continue;
        try {
          const json = JSON.parse(payload);
          const token = json.choices?.[0]?.delta?.content ?? '';
          setStreaming((prev) => prev + token);
        } catch {
          // ignore malformed keep-alive
        }
      }
    }
    setMessages([...next, { role: 'assistant', content: streaming }]);
    setStreaming('');
  }

  return (
    <div style={{ maxWidth: 600, margin: '2rem auto' }}>
      {messages.map((m, i) => (
        <p key={i}><strong>{m.role}:</strong> {m.content}</p>
      ))}
      {streaming && <p><strong>assistant:</strong> {streaming}</p>}
      <input
        value={input}
        onChange={(e) => setInput(e.target.value)}
        onKeyDown={(e) => e.key === 'Enter' && send()}
        placeholder="Type a message"
        style={{ width: '80%' }}
      />
      <button onClick={send}>Send</button>
    </div>
  );
}

Wire the component into the page

Replace app/page.tsx with a server component that renders the client chat:

import Chat from './components/Chat';

export default function Page() {
  return (
    <main>
      <h1>Edge Streaming Chatbot</h1>
      <Chat />
    </main>
  );
}

Run npm run dev. Open http://localhost:3000. Type “Explain edge runtime” and press Enter. The assistant text appears token-by-token in the streaming paragraph. That is the core of a nextjs vercel edge functions streaming chatbot working locally.

Hardening the stream parser

The naive line split above can break if a JSON payload spans two chunks. In production, accumulate until you see \n\n (SSE event boundary). A small helper keeps it robust:

function parseSSE(buffer: string): { events: string[]; rest: string } {
  const parts = buffer.split('\n\n');
  const rest = parts.pop() ?? '';
  const events = parts
    .map((p) => p.split('\n').filter((l) => l.startsWith('data:')))
    .flat()
    .map((l) => l.slice(5).trim());
  return { events, rest };
}

Integrate it in the while loop by feeding buffer through parseSSE after each decode. This avoids dropping partial data: lines when the network flakes.

Deploy to Vercel

Commit and push to GitHub, then import the repo in Vercel. Set OPENAI_API_KEY in the project’s Environment Variables. Vercel detects runtime = 'edge' and deploys the route to the edge network automatically.

git add -A
git commit -m "initial nextjs vercel edge functions streaming chatbot"
git push

After deploy, the same streaming behavior works globally. Check the Functions tab: the /api/chat route should show Edge as the runtime, not Node.js.

Checkpoint: expected production output

In the deployed chat, send “Write a haiku about latency”. You should observe:

  1. Immediate first token within ~300–800ms (provider dependent).
  2. Smooth incremental append with no full-page refetch.
  3. Network panel shows a single POST /api/chat with text/event-stream response that stays open until [DONE].

If you see a buffered response instead of streaming, confirm the route file exports runtime = 'edge' and that no middleware is stripping Cache-Control. Also verify the upstream fetch uses stream: true and you return upstream.body unwrapped.

Why edge matters for this pattern

A nextjs vercel edge functions streaming chatbot moves the proxy close to the user and avoids a regional Node function buffering the LLM response. The edge function pays only for the milliseconds it spends forwarding bytes. Because the LLM call stays server-side, your provider key never reaches the browser, and you can enforce rate limits or prompt guards in the same route before calling the model.

Keep the client dumb: it sends messages, reads a stream, and renders. All retry or fallback logic (e.g., switching models on 429) belongs in the edge route, where you can inspect the upstream status before piping. That separation is what makes the architecture maintainable as you add features like tool calls or multi-turn summarization.

Tagsvercelnextjsedge-functionschatbot

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 edge functions llm streaming posts →