June 28, 2026ยท6 min readยทBy Innovibe

How to Stream LLM Responses in Next.js (App Router)

Waiting 10 seconds for a full LLM response kills UX. Streaming shows output as it generates โ€” here's the complete implementation for Next.js App Router.

AINext.jsStreamingTutorial

Nobody wants to stare at a spinner for 8 seconds while GPT-4 writes its answer. Streaming fixes this by sending tokens as they're generated, so users see output appear word by word. Here's how to wire it up end-to-end in Next.js 14 App Router.

How LLM streaming works

The API returns a ReadableStream of Server-Sent Events (SSE). Each event contains a chunk of the response. You pipe this stream from your backend to your frontend.

[User Request] โ†’ [Next.js Route Handler] โ†’ [OpenAI/Anthropic API stream] โ†’ [Frontend]
                          โ†‘ streams chunks back as they arrive

Step 1: Create a streaming API route

// app/api/chat/route.ts
import OpenAI from 'openai'
import { OpenAIStream, StreamingTextResponse } from 'ai' // Vercel AI SDK

const openai = new OpenAI()

export const runtime = 'edge' // edge runtime is fastest for streaming

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

  const response = await openai.chat.completions.create({
    model: 'gpt-4o',
    messages,
    stream: true,  // โ† this is the key
  })

  const stream = OpenAIStream(response)
  return new StreamingTextResponse(stream)
}

If you'd rather not use the Vercel AI SDK, here's the raw implementation:

// Without the AI SDK โ€” raw streaming
export async function POST(req: Request) {
  const { messages } = await req.json()

  const response = await openai.chat.completions.create({
    model: 'gpt-4o',
    messages,
    stream: true,
  })

  const encoder = new TextEncoder()

  const stream = new ReadableStream({
    async start(controller) {
      for await (const chunk of response) {
        const text = chunk.choices[0]?.delta?.content || ''
        if (text) {
          controller.enqueue(encoder.encode(text))
        }
      }
      controller.close()
    },
  })

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

Step 2: Consume the stream on the frontend

With the Vercel AI SDK (useChat hook):

// app/chat/page.tsx
'use client'
import { useChat } from 'ai/react'

export default function ChatPage() {
  const { messages, input, handleInputChange, handleSubmit, isLoading } = useChat({
    api: '/api/chat',
  })

  return (
    <div>
      <div className="messages">
        {messages.map(m => (
          <div key={m.id} className={`message ${m.role}`}>
            {m.content}
          </div>
        ))}
        {isLoading && <div className="message assistant">โ–‹</div>}
      </div>

      <form onSubmit={handleSubmit}>
        <input value={input} onChange={handleInputChange} placeholder="Ask anything..." />
        <button type="submit" disabled={isLoading}>Send</button>
      </form>
    </div>
  )
}

Without the SDK โ€” manual fetch + stream reading:

'use client'
import { useState } from 'react'

export default function ChatPage() {
  const [response, setResponse] = useState('')
  const [loading, setLoading] = useState(false)

  async function sendMessage(message: string) {
    setLoading(true)
    setResponse('')

    const res = await fetch('/api/chat', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ messages: [{ role: 'user', content: message }] }),
    })

    const reader = res.body!.getReader()
    const decoder = new TextDecoder()

    while (true) {
      const { done, value } = await reader.read()
      if (done) break
      const chunk = decoder.decode(value, { stream: true })
      setResponse(prev => prev + chunk)
    }

    setLoading(false)
  }

  return (
    <div>
      <div>{response}{loading && 'โ–‹'}</div>
      <button onClick={() => sendMessage('Hello!')}>Send</button>
    </div>
  )
}

Step 3: Stream from Anthropic (Claude)

// app/api/chat/route.ts โ€” Anthropic version
import Anthropic from '@anthropic-ai/sdk'

const client = new Anthropic()

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

  const encoder = new TextEncoder()

  const stream = new ReadableStream({
    async start(controller) {
      const response = await client.messages.stream({
        model: 'claude-sonnet-5',
        max_tokens: 1024,
        messages,
      })

      for await (const event of response) {
        if (
          event.type === 'content_block_delta' &&
          event.delta.type === 'text_delta'
        ) {
          controller.enqueue(encoder.encode(event.delta.text))
        }
      }
      controller.close()
    },
  })

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

Common gotchas

Edge runtime required for best performance. Node.js runtime buffers responses differently. Add export const runtime = 'edge' to your route handler.

Don't forget error handling. If the stream errors mid-way, the frontend will show a partial response. Wrap the stream start in try/catch and close the controller with an error.

Abort on unmount. If the user navigates away, abort the fetch to avoid dangling requests:

useEffect(() => {
  const controller = new AbortController()
  // pass controller.signal to fetch
  return () => controller.abort()
}, [])

Shipping an AI chat interface? We build production LLM products. Get in touch.

K
Innovibe
Founder & Technical Lead, Innovibe

Building software for 15+ years. Passionate about AI, system design, and shipping things that work.

Frequently asked questions

Do I need a GPU to run this in production?+

For hosted APIs (OpenAI, Anthropic, Google) โ€” no. You call an HTTPS endpoint. GPUs only matter if you're self-hosting models, which is overkill for most production use cases.

How do I keep LLM costs under control?+

Cache identical prompts aggressively, use the smallest model that meets your quality bar, and set hard token limits per request. A response cache alone can cut costs 40โ€“60% on typical workloads.

What's the difference between fine-tuning and RAG?+

Fine-tuning bakes knowledge into model weights โ€” expensive, slow to update. RAG retrieves context at query time โ€” cheap to update, easier to debug. Use RAG for most production use cases and fine-tune only when you need a very specific tone or format.

When should I use Server Components vs Client Components?+

Server Components are the default โ€” use them for data fetching, static content, and anything that doesn't need interactivity. Only switch to Client Components when you need browser APIs, state, effects, or event handlers. The less JS you ship to the browser, the faster your site.

Why is my Next.js build so slow?+

Usually it's TypeScript type-checking or a large dependency. Try `NEXT_TELEMETRY_DISABLED=1 next build` with `--profile` and look at what's taking time. Splitting large data files and enabling turborepo are the two biggest wins.

Building something with AI?

We scope and ship AI features quickly. Let's talk.

Start a Conversation