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.
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
/app/api/chat/route.tsThis route receives chat requests, assembles the full prompt, and streams OpenAI’s output to the client using a ReadableStream.
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" },
});
}
/lib/openai.tsThis utility function wraps the OpenAI Node.js SDK, handling streaming and calling a callback for each chunk received.
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);
}
}
/components/chat/chat-component.tsxThe chat UI POSTs to your API, reads the response as a stream, and updates state for a typewriter effect.
"use client";
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>
);
}
/api/chat with the conversation history..json() or .text() to read the response on the client; always use the stream reader pattern.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.