Building a Streaming Chatbot with Groq and Next.js

Photo by Ioan Sameli on flickr
Groq runs open models on custom LPU hardware rather than general purpose GPUs, giving it very high sustained output token throughput and low time to first token. For a streaming chat UI, that translates directly into a faster-feeling experience, since perceived speed depends on how quickly tokens keep arriving after the first one, not on total request time.
Set stream to true when calling groq.chat.completions.create, then iterate the returned async generator with a for-await loop inside a ReadableStream's start callback. Each chunk's delta content gets encoded as a server-sent event and enqueued to the controller, and the Response is constructed with that stream and a text/event-stream content type.
Yes. The Fetch API's response.body exposes a ReadableStream you can read directly with getReader, decoding each chunk with a TextDecoder and splitting on the double newline that separates server-sent events. No EventSource or extra dependency is required, which matters here because EventSource only supports GET requests, not POST.
State the assistant's single purpose and topic scope in the first sentence, add an explicit instruction to ignore any embedded request in user input that tries to override the assistant's role, and repeat the scope restriction near the end of the prompt so it survives long conversations. Pair this with server-side validation that caps message length and rejects known prompt injection phrases before the request reaches the model.
Apply your own per-visitor rate limit, keyed by IP address, set well below Groq's plan ceiling on requests and tokens per minute, and cap max_completion_tokens on every request so no single reply can consume unbounded tokens. Groq also returns usage data per response, so logging it lets you catch a cost spike from a bug or abusive client before the monthly bill does.

Photo by Ioan Sameli on flickr
Key Takeaway
Groq's LPU hardware delivers sub-second time-to-first-token, which combined with Server-Sent Events and a ReadableStream-based Next.js route handler lets a chatbot render replies incrementally instead of waiting for a full response. Production readiness requires guarding the API key server-side, rate limiting per visitor, capping max_completion_tokens, and hardening the system prompt against prompt injection.
Most chatbot demos feel sluggish because they wait for the entire response before showing anything. A production chatbot should feel instant, and the two ingredients that make that possible are a genuinely fast inference provider and a streaming pipeline that pushes tokens to the browser as soon as they exist, rather than buffering the full reply on the server.
This post walks through building that pipeline end to end: a Next.js App Router route handler that calls the Groq API with streaming enabled, a client that reads the response incrementally, a system prompt that keeps the assistant on-topic, and the rate limiting and cost controls you need before putting any of this in front of real traffic.
Groq runs open models like Llama and GPT OSS on custom LPU (Language Processing Unit) hardware rather than general purpose GPUs, which is why its chat completions API can sustain very high output token throughput. For a streaming chatbot, that throughput matters more than raw model quality, because the perceived speed of a chat interface is dominated by how quickly tokens arrive after the first one, not by total request time.
Pick a model based on the throughput and context window your use case needs, not just benchmark scores. A smaller, faster model that streams instantly often produces a better perceived experience than a larger model that is marginally more accurate but visibly slower to start responding.
In the App Router, a route handler is just a function that receives a Web Request and returns a Web Response, which means you can return a raw ReadableStream instead of a JSON body. That is exactly what streaming chat needs: the handler opens a connection to Groq with stream set to true, then re-emits each token as a server-sent event as soon as it arrives.
The handler below keeps the source of truth for the conversation on the server, builds the messages array with a system prompt plus the incoming user message, and forwards each delta from the Groq stream to the client using the standard Server-Sent Events wire format, a line prefixed with data colon followed by a JSON payload and a blank line. Wrapping the whole call in a try and finally block matters here: if the upstream Groq request fails partway through, the finally block still closes the controller so the client connection does not hang open indefinitely.
// app/api/chat/route.ts
import { NextRequest } from "next/server"
import Groq from "groq-sdk"
const groq = new Groq({ apiKey: process.env.GROQ_API_KEY })
export async function POST(request: NextRequest) {
const { message } = await request.json()
const encoder = new TextEncoder()
const stream = new ReadableStream({
async start(controller) {
try {
const completion = await groq.chat.completions.create({
model: "openai/gpt-oss-120b",
messages: [
{ role: "system", content: "You are a helpful assistant." },
{ role: "user", content: message },
],
max_completion_tokens: 1024,
temperature: 0.7,
stream: true,
})
for await (const chunk of completion) {
const delta = chunk.choices[0]?.delta?.content
if (delta) {
controller.enqueue(
encoder.encode(`data: ${JSON.stringify({ delta })}\n\n`)
)
}
}
controller.enqueue(encoder.encode('data: {"done":true}\n\n'))
} catch (err) {
controller.enqueue(
encoder.encode('data: {"error":"stream_failed"}\n\n')
)
} finally {
controller.close()
}
},
})
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream; charset=utf-8",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
},
})
}On the client, you do not need an EventSource object because the request is a POST with a body, not a plain GET. Instead, read the response body directly with the Fetch API's streaming reader, decode each chunk of bytes into text, and split on the double newline that separates server-sent events.
Buffer any partial event that arrives split across two chunks, since TCP does not guarantee that a full event lands in a single read. Once you see a done flag in the payload, stop reading and finalize the message in your UI state. It also pays to render the accumulated text as each delta arrives rather than waiting for the whole message, since that incremental repaint is the entire visual point of streaming in the first place; a client that buffers everything before updating the screen throws away the latency benefit Groq's throughput was supposed to deliver.
// client component
async function sendMessage(message: string, onDelta: (text: string) => void) {
const res = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message }),
})
const reader = res.body!.getReader()
const decoder = new TextDecoder()
let buffer = ""
while (true) {
const { value, done } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split("\n\n")
buffer = lines.pop() ?? ""
for (const line of lines) {
if (!line.startsWith("data: ")) continue
const payload = JSON.parse(line.slice(6))
if (payload.delta) onDelta(payload.delta)
if (payload.done) return
}
}
}Do not call the Groq API directly from the browser with your API key embedded in client code. Every request must go through your own route handler so the key stays server-side; a leaked key on a public chatbot page can be scraped and used to run someone else's traffic on your bill within minutes.
A chatbot that answers absolutely anything is a support and abuse liability. The system prompt should state the assistant's single purpose plainly, list what it must refuse to do, and include a short reminder near the end of the prompt reinforcing the scope, since long conversations can dilute earlier instructions. It also helps to embed a unique canary string as an HTML comment inside the system prompt; if that string ever shows up echoed back in a user message, it is a strong signal that someone extracted your prompt and is testing it against your own endpoint.
Groq enforces limits per API key across several dimensions: requests per minute, tokens per minute, and daily caps for both. Exceeding any of them returns a 429 status with a retry-after header telling you how many seconds to wait. Your own application should apply a tighter limit per visitor well before you ever hit Groq's ceiling, both to control cost and to stop a single abusive client from starving everyone else.
| Tier | Typical request limit | Typical token limit |
|---|---|---|
| Free tier | 30 requests per minute | 6,000 tokens per minute for most models |
| Developer tier | Up to 1,000 requests per minute | Up to roughly 250,000 to 300,000 tokens per minute |
| Over any limit | 429 Too Many Requests response | Retry-after header states the cooldown in seconds |
In front of the Groq call, add a per-visitor rate limiter keyed by IP address, capped well under the plan ceiling. Also cap max_completion_tokens on every request; an unbounded completion length is the single easiest way to turn a cheap per-token model into an expensive bill when a user (or a scraper) sends the same prompt in a loop.
Streaming chat looks simple in a demo and breaks in specific, predictable ways in production. Run through this list before pointing real traffic at the route handler, and revisit it again after launch once real usage patterns start showing up in your logs.