From 1bb8729570980391ffda8f2829e29069dbb928de Mon Sep 17 00:00:00 2001 From: guuszz Date: Wed, 27 May 2026 20:12:06 -0300 Subject: [PATCH 1/2] feat(solutions): add ai-streaming-ndjson example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This example demonstrates progressive token-by-token streaming from a Next.js Route Handler to the browser using NDJSON (newline-delimited JSON) over a ReadableStream — a simpler alternative to Server-Sent Events when you need POST with a body. What's included: - app/api/stream/route.ts: ReadableStream writing one JSON event per line with custom types (meta, chunk, done, error). Edge-compatible. - app/page.tsx: client component that reads the stream via getReader() + TextDecoder, parses NDJSON line-by-line, and renders chunks progressively with a live progress bar and typing cursor effect. - README.md: explains the why (EventSource doesn't accept POST + body), the protocol, server/client snippets, when to use vs WebSockets/SSE, and X-Accel-Buffering header notes. The endpoint accepts { prompt } and echoes it back word-by-word with a 40ms delay to simulate token generation — in a real app, replace the loop with your LLM SDK's stream iterator (OpenAI/Anthropic/Gemini all have this). Why this matters: many AI integrations need a POST with a long prompt or context document, but EventSource is GET-only. Most tutorials show SSE demos that conveniently fit in a query string. This example fills the gap with a production-tested pattern. --- solutions/ai-streaming-ndjson/.eslintrc.json | 4 + solutions/ai-streaming-ndjson/.gitignore | 42 + solutions/ai-streaming-ndjson/README.md | 153 + .../app/api/stream/route.ts | 81 + solutions/ai-streaming-ndjson/app/layout.tsx | 19 + solutions/ai-streaming-ndjson/app/page.tsx | 181 + solutions/ai-streaming-ndjson/package.json | 29 + solutions/ai-streaming-ndjson/pnpm-lock.yaml | 3397 +++++++++++++++++ .../ai-streaming-ndjson/postcss.config.js | 8 + .../ai-streaming-ndjson/public/favicon.ico | Bin 0 -> 15086 bytes .../ai-streaming-ndjson/tailwind.config.js | 9 + solutions/ai-streaming-ndjson/tsconfig.json | 31 + solutions/ai-streaming-ndjson/turbo.json | 9 + solutions/ai-streaming-ndjson/vercel.json | 4 + 14 files changed, 3967 insertions(+) create mode 100644 solutions/ai-streaming-ndjson/.eslintrc.json create mode 100644 solutions/ai-streaming-ndjson/.gitignore create mode 100644 solutions/ai-streaming-ndjson/README.md create mode 100644 solutions/ai-streaming-ndjson/app/api/stream/route.ts create mode 100644 solutions/ai-streaming-ndjson/app/layout.tsx create mode 100644 solutions/ai-streaming-ndjson/app/page.tsx create mode 100644 solutions/ai-streaming-ndjson/package.json create mode 100644 solutions/ai-streaming-ndjson/pnpm-lock.yaml create mode 100644 solutions/ai-streaming-ndjson/postcss.config.js create mode 100644 solutions/ai-streaming-ndjson/public/favicon.ico create mode 100644 solutions/ai-streaming-ndjson/tailwind.config.js create mode 100644 solutions/ai-streaming-ndjson/tsconfig.json create mode 100644 solutions/ai-streaming-ndjson/turbo.json create mode 100644 solutions/ai-streaming-ndjson/vercel.json diff --git a/solutions/ai-streaming-ndjson/.eslintrc.json b/solutions/ai-streaming-ndjson/.eslintrc.json new file mode 100644 index 0000000000..a2569c2c7c --- /dev/null +++ b/solutions/ai-streaming-ndjson/.eslintrc.json @@ -0,0 +1,4 @@ +{ + "root": true, + "extends": "next/core-web-vitals" +} diff --git a/solutions/ai-streaming-ndjson/.gitignore b/solutions/ai-streaming-ndjson/.gitignore new file mode 100644 index 0000000000..26fd87c7cb --- /dev/null +++ b/solutions/ai-streaming-ndjson/.gitignore @@ -0,0 +1,42 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# Dependencies +/node_modules +/.pnp +.pnp.js + +# Testing +/coverage + +# Next.js +/.next/ +/out/ +next-env.d.ts + +# Production +build +dist + +# Misc +.DS_Store +*.pem + +# Debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Local ENV files +.env.local +.env.development.local +.env.test.local +.env.production.local + +# Vercel +.vercel + +# Turborepo +.turbo + +# typescript +*.tsbuildinfo \ No newline at end of file diff --git a/solutions/ai-streaming-ndjson/README.md b/solutions/ai-streaming-ndjson/README.md new file mode 100644 index 0000000000..6f95a17bfb --- /dev/null +++ b/solutions/ai-streaming-ndjson/README.md @@ -0,0 +1,153 @@ +# AI Streaming with NDJSON example + +This example shows how to implement progressive token-by-token streaming from a Next.js API Route to the browser using **NDJSON** (newline-delimited JSON) — a simpler alternative to Server-Sent Events when you need to send a `POST` request with a body. + +## Why NDJSON instead of SSE? + +`EventSource` is the browser's native client for Server-Sent Events, but it has two notable limitations: + +- ❌ Only accepts `GET` requests — you can't send a request body +- ❌ Locked to the `text/event-stream` format + +For many real-world cases (chat interfaces, long-running operations, AI completions), you want to **POST a payload** and stream the response back progressively. NDJSON over `ReadableStream` gives you the same streaming UX with: + +- ✅ Any HTTP method (POST, PUT, etc.) +- ✅ Custom event types (`meta`, `chunk`, `error`, `done`) +- ✅ Trivial to test with `curl --no-buffer` +- ✅ Easy to parse line-by-line on the client + +## Demo + +https://ai-streaming-ndjson.vercel.app + +## How to Use + +You can choose from one of the following two methods to use this repository: + +### One-Click Deploy + +Deploy the example using [Vercel](https://vercel.com?utm_source=github&utm_medium=readme&utm_campaign=vercel-examples): + +[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/vercel/examples/tree/main/solutions/ai-streaming-ndjson&project-name=ai-streaming-ndjson&repository-name=ai-streaming-ndjson) + +### Clone and Deploy + +Execute [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app) with [npm](https://docs.npmjs.com/cli/init) or [Yarn](https://yarnpkg.com/lang/en/docs/cli/create/) to bootstrap the example: + +```bash +pnpm create next-app --example https://github.com/vercel/examples/tree/main/solutions/ai-streaming-ndjson +``` + +Next, run Next.js in development mode: + +```bash +pnpm dev +``` + +## How it works + +### The NDJSON protocol + +Each message is one JSON object terminated with `\n`. This example uses 4 event types: + +``` +{"type":"meta","totalChunks":50} +{"type":"chunk","text":"Hello "} +{"type":"chunk","text":"world"} +{"type":"done"} +``` + +If something fails mid-stream: + +``` +{"type":"error","message":"Provider rate-limited"} +``` + +### Server: `ReadableStream` writing NDJSON + +The handler returns a `ReadableStream` that encodes each event as JSON + newline. This works on both Node.js and Edge runtimes: + +```typescript +// app/api/stream/route.ts +export async function POST(req: Request) { + const encoder = new TextEncoder() + const event = (data: Record) => + encoder.encode(JSON.stringify(data) + '\n') + + const stream = new ReadableStream({ + async start(controller) { + controller.enqueue(event({ type: 'meta', totalChunks: tokens.length })) + + for (const token of tokens) { + await sleep(40) // simulate token-by-token generation + controller.enqueue(event({ type: 'chunk', text: token })) + } + + controller.enqueue(event({ type: 'done' })) + controller.close() + }, + }) + + return new Response(stream, { + headers: { + 'Content-Type': 'application/x-ndjson', + 'Cache-Control': 'no-cache, no-transform', + 'X-Accel-Buffering': 'no', // hint to proxies not to buffer + }, + }) +} +``` + +### Client: reading NDJSON line-by-line + +The browser reads the stream with `getReader()` and decodes incrementally with `TextDecoder`. Since chunks may arrive split across line boundaries, we keep a `buffer` and only parse complete lines (terminated by `\n`): + +```typescript +const response = await fetch('/api/stream', { method: 'POST' }) +if (!response.body) throw new Error('No body') + +const reader = response.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 }) + + let newlineIdx = buffer.indexOf('\n') + while (newlineIdx !== -1) { + const line = buffer.slice(0, newlineIdx).trim() + buffer = buffer.slice(newlineIdx + 1) + if (line) { + const event = JSON.parse(line) + // handle meta / chunk / done / error + } + newlineIdx = buffer.indexOf('\n') + } +} +``` + +The key insight: `decoder.decode(value, { stream: true })` keeps the decoder stateful, so multi-byte characters split across chunks are reconstructed correctly. + +## When to use this pattern + +✅ Good fit: + +- AI completions with a long prompt/document in the body +- Long-running operations with progress reporting +- Multi-event responses (metadata + content + completion) +- When you want to test the endpoint with `curl` + +❌ Not ideal: + +- Server-initiated push to idle clients (use WebSockets) +- Browser auto-reconnect needed (SSE has this built in) +- Streaming binary data (use raw `ReadableStream` without JSON) + +## Notes + +- The `X-Accel-Buffering: no` header is a hint to proxies (like Nginx) not to buffer the response. Vercel respects this automatically, but if you self-host behind a proxy, this is critical. +- Most browsers handle the streaming `fetch` response uniformly, but Safari historically buffered chunks. Modern Safari (17+) works as expected. +- For production use, consider adding heartbeats (`{"type":"ping"}` every 15s) so intermediate proxies don't drop the connection on long generations. diff --git a/solutions/ai-streaming-ndjson/app/api/stream/route.ts b/solutions/ai-streaming-ndjson/app/api/stream/route.ts new file mode 100644 index 0000000000..f4dda2e6a7 --- /dev/null +++ b/solutions/ai-streaming-ndjson/app/api/stream/route.ts @@ -0,0 +1,81 @@ +import type { NextRequest } from 'next/server' + +export const runtime = 'edge' // works in Node.js runtime too — change here if needed +export const dynamic = 'force-dynamic' + +/** + * Streams a response in NDJSON (newline-delimited JSON) format. + * + * Protocol — each line is a JSON event: + * {"type":"meta","totalChunks":N} sent once at the start + * {"type":"chunk","text":"..."} one per token, many of these + * {"type":"done"} sent once at the end + * {"type":"error","message":"..."} only if something fails mid-stream + * + * The handler accepts a `{ prompt }` body and echoes it back word-by-word + * with a small artificial delay to simulate token generation. In a real app, + * you would replace the loop with calls to your LLM SDK's stream iterator + * (OpenAI, Anthropic, Gemini, etc.). + */ +export async function POST(req: NextRequest) { + let prompt = '' + try { + const body = await req.json() + if (typeof body?.prompt === 'string') prompt = body.prompt.trim() + } catch { + // No body or invalid JSON — fall back to a default + } + + if (!prompt) { + prompt = 'Hello from a streaming response. Each word arrives one at a time.' + } + + // Tokenize: split on whitespace, keeping the delimiters so output reconstructs cleanly + const tokens = prompt + .split(/(\s+)/) + .filter((t) => t.length > 0) + .map((t, i, arr) => (i < arr.length - 1 && !arr[i + 1].match(/^\s/) ? t + ' ' : t)) + + const encoder = new TextEncoder() + + // Helper: serialize an event as NDJSON (one JSON object + newline) + const event = (data: Record) => + encoder.encode(JSON.stringify(data) + '\n') + + const stream = new ReadableStream({ + async start(controller) { + try { + // 1. Send metadata first so the client can render progress UI + controller.enqueue(event({ type: 'meta', totalChunks: tokens.length })) + + // 2. Stream each token with a small delay + for (const token of tokens) { + await new Promise((resolve) => setTimeout(resolve, 40)) + controller.enqueue(event({ type: 'chunk', text: token })) + } + + // 3. Signal completion + controller.enqueue(event({ type: 'done' })) + controller.close() + } catch (err) { + controller.enqueue( + event({ + type: 'error', + message: err instanceof Error ? err.message : 'Unknown error', + }), + ) + controller.close() + } + }, + }) + + return new Response(stream, { + headers: { + 'Content-Type': 'application/x-ndjson', + 'Cache-Control': 'no-cache, no-transform', + // Hint to proxies (Nginx etc.) not to buffer the response. + // Vercel respects this automatically. + 'X-Accel-Buffering': 'no', + }, + }) +} diff --git a/solutions/ai-streaming-ndjson/app/layout.tsx b/solutions/ai-streaming-ndjson/app/layout.tsx new file mode 100644 index 0000000000..cc99249c13 --- /dev/null +++ b/solutions/ai-streaming-ndjson/app/layout.tsx @@ -0,0 +1,19 @@ +import type { ReactNode } from 'react' +import { Layout, getMetadata } from '@vercel/examples-ui' +import '@vercel/examples-ui/globals.css' + +export const metadata = getMetadata({ + title: 'AI Streaming with NDJSON', + description: + 'Progressive token-by-token streaming from a Next.js Route Handler using NDJSON over ReadableStream.', +}) + +export default function RootLayout({ children }: { children: ReactNode }) { + return ( + + + {children} + + + ) +} diff --git a/solutions/ai-streaming-ndjson/app/page.tsx b/solutions/ai-streaming-ndjson/app/page.tsx new file mode 100644 index 0000000000..3641ad29a6 --- /dev/null +++ b/solutions/ai-streaming-ndjson/app/page.tsx @@ -0,0 +1,181 @@ +'use client' + +import { useState } from 'react' +import { Page, Text, Code, Link, Button } from '@vercel/examples-ui' + +interface StreamEvent { + type: 'meta' | 'chunk' | 'done' | 'error' + totalChunks?: number + text?: string + message?: string +} + +export default function Home() { + const [output, setOutput] = useState('') + const [streaming, setStreaming] = useState(false) + const [progress, setProgress] = useState<{ current: number; total: number } | null>(null) + const [error, setError] = useState(null) + const [prompt, setPrompt] = useState( + 'Streaming responses give users instant feedback. They make slow APIs feel fast.', + ) + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault() + if (streaming) return + + setStreaming(true) + setOutput('') + setError(null) + setProgress(null) + + try { + const response = await fetch('/api/stream', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ prompt }), + }) + + if (!response.ok) throw new Error(`HTTP ${response.status}`) + if (!response.body) throw new Error('No response body to stream') + + const reader = response.body.getReader() + const decoder = new TextDecoder() + let buffer = '' + let received = 0 + + while (true) { + const { done, value } = await reader.read() + if (done) break + + buffer += decoder.decode(value, { stream: true }) + + let newlineIdx = buffer.indexOf('\n') + while (newlineIdx !== -1) { + const line = buffer.slice(0, newlineIdx).trim() + buffer = buffer.slice(newlineIdx + 1) + + if (line) { + try { + const event = JSON.parse(line) as StreamEvent + if (event.type === 'meta' && event.totalChunks) { + setProgress({ current: 0, total: event.totalChunks }) + } else if (event.type === 'chunk' && event.text) { + received += 1 + setOutput((prev) => prev + event.text) + setProgress((prev) => + prev ? { ...prev, current: received } : null, + ) + } else if (event.type === 'error') { + throw new Error(event.message || 'Stream error') + } + } catch (parseErr) { + if (parseErr instanceof SyntaxError) { + console.warn('Invalid NDJSON line:', line) + } else { + throw parseErr + } + } + } + + newlineIdx = buffer.indexOf('\n') + } + } + } catch (err) { + setError(err instanceof Error ? err.message : 'Unknown error') + } finally { + setStreaming(false) + } + } + + return ( + +
+ AI Streaming with NDJSON + + Progressive token-by-token streaming from a Next.js Route Handler using{' '} + application/x-ndjson over a ReadableStream. + + + NDJSON is a simpler alternative to Server-Sent Events when you need to{' '} + POST a request body — which EventSource can't do. + The demo below sends your prompt to the server, which streams back one chunk + per word so you can see the response build progressively. + +
+ +
+ Try it +
+