OWolf

BlogToolsProjectsAboutContact
© 2025 owolf.com
HomeAboutNotesContactPrivacy

2025-07-11 Web Development

Streaming OpenAI Responses in a Next.js 15 App Router Project

By O. Wolfson

Enabling real-time, typewriter-style AI chat requires streaming responses from OpenAI all the way to your React frontend. This article walks through the exact architecture and implementation, using file-by-file examples for a Next.js 15 App Router project.

Project Structure

A minimal setup for streaming might look like this:

/app
  /api
    /chat
      route.ts             // API route for streaming
/components
  /chat
    chat-component.tsx     // React UI for chat
/lib
  openai.ts                // OpenAI streaming helper

1. API Route: /app/api/chat/route.ts

This route receives chat requests, assembles the full prompt, and streams OpenAI’s output to the client using a ReadableStream.

ts
import { NextRequest } from "next/server";
import { chatWithContext } from "@/lib/openai";

export async function POST(req: NextRequest) {
  const { question, history } = await req.json();
  const fullPrompt = "..."; // your custom logic for prompt assembly

  const encoder = new TextEncoder();
  const stream = new ReadableStream({
    async start(controller) {
      try {
        await chatWithContext(question, [fullPrompt], (delta) =>
          controller.enqueue(encoder.encode(delta))
        );
      } catch (e) {
        controller.enqueue(encoder.encode("Error: " + e.message));
      } finally {
        controller.close();
      }
    },
  });

  return new Response(stream, {
    headers: { "Content-Type": "text/plain; charset=utf-8" },
  });
}

2. OpenAI Streaming Helper: /lib/openai.ts

This utility function wraps the OpenAI Node.js SDK, handling streaming and calling a callback for each chunk received.

ts
import OpenAI from "openai";

export const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });

export async function chatWithContext(
  question: string,
  context: string[],
  onDelta?: (delta: string) => void
) {
  const stream = await openai.chat.completions.create({
    model: "gpt-3.5-turbo",
    messages: [
      { role: "system", content: context.join("\n\n") },
      { role: "user", content: question },
    ],
    stream: true,
  });

  for await (const chunk of stream) {
    const content = chunk.choices[0]?.delta?.content || "";
    if (onDelta) onDelta(content);
  }
}

3. React Chat Component: /components/chat/chat-component.tsx

The chat UI POSTs to your API, reads the response as a stream, and updates state for a typewriter effect.

tsx
"use client";
import { useState } from "react";

export default function Chat() {
  const [messages, setMessages] = useState([]);
  const [input, setInput] = useState("");
  const [isPending, setIsPending] = useState(false);

  async function handleSubmit(e) {
    e.preventDefault();
    const userMessage = {
      id: crypto.randomUUID(),
      content: input,
      role: "user",
    };
    setMessages((prev) => [...prev, userMessage]);
    setInput("");
    setIsPending(true);

    const res = await fetch("/api/chat", {
      method: "POST",
      body: JSON.stringify({
        question: userMessage.content,
        history: [...messages, userMessage],
      }),
      headers: { "Content-Type": "application/json" },
    });

    if (!res.body) throw new Error("No stream");
    const reader = res.body.getReader();
    let assistantContent = "";
    const assistantId = crypto.randomUUID();

    while (true) {
      const { value, done } = await reader.read();
      if (done) break;
      const text = new TextDecoder().decode(value);
      assistantContent += text;
      setMessages((prev) =>
        prev.some((msg) => msg.id === assistantId)
          ? prev.map((msg) =>
              msg.id === assistantId
                ? { ...msg, content: assistantContent }
                : msg
            )
          : [
              ...prev,
              { id: assistantId, content: assistantContent, role: "assistant" },
            ]
      );
    }

    setIsPending(false);
  }

  return (
    <form onSubmit={handleSubmit}>
      <div>
        {messages.map((m) => (
          <div key={m.id}>
            {m.role === "user" ? "You: " : "AI: "}
            {m.content}
          </div>
        ))}
      </div>
      <input
        value={input}
        onChange={(e) => setInput(e.target.value)}
        disabled={isPending}
        placeholder="Ask something..."
      />
      <button type="submit" disabled={isPending || !input.trim()}>
        Send
      </button>
    </form>
  );
}

How It Works

  1. The user submits a question via the chat UI.
  2. The frontend sends a POST request to /api/chat with the conversation history.
  3. The API route streams the OpenAI response to the client, chunk by chunk.
  4. The React component reads each chunk as it arrives and updates the UI in real time.

Deployment Tips

  • Use the Node.js runtime (not Edge) for streaming support in API routes.
  • Do not use .json() or .text() to read the response on the client; always use the stream reader pattern.
  • For production, handle errors and consider request limits or user feedback for slow responses.

With this approach, you deliver instant, interactive AI chat—directly in your Next.js 15 app, using native streaming all the way from OpenAI to the browser.


Chat with me

Ask me anything about this blog post. I'll do my best to help you.

Comments