When you ship vercel edge functions cors llm streaming, the browser enforces CORS on the streaming response exactly as it does for any other cross-origin fetch. If your frontend lives on app.example.com and your edge function on api.example.com, missing Access-Control-Allow-Origin will abort the connection before the first token streams. This guide gives you an end-to-end proxy pattern that returns correct preflight and response headers while streaming tokens from an LLM provider through Vercel’s edge runtime.
Step 1: Scaffold the Edge Function
Create a file at api/llm-stream.ts in your Vercel project. The edge runtime is selected via the config export. Unlike Node functions, edge functions run on V8 isolates, so you cannot use Buffer or Node-specific modules.
export const config = { runtime: 'edge' };
export default async function handler(req: Request): Promise<Response> {
return new Response('not implemented');
}
Deploy with vercel deploy or run locally with vercel dev. The function will be available at /api/llm-stream. At this point it returns no CORS headers, so any cross-origin browser call fails. We fix that next.
Step 2: Handle Preflight OPTIONS Requests
Browsers send a CORS preflight when the request is cross-origin, uses POST, and sets a Content-Type of application/json (or any non-simple header). The preflight is an OPTIONS request asking whether the server will accept the real request. If you return anything other than a 2xx with the right headers, the browser blocks the subsequent POST.
const ALLOWED_ORIGINS = new Set(['https://app.example.com']);
function corsHeaders(origin: string | null): Record<string, string> {
const h: Record<string, string> = {
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
'Access-Control-Max-Age': '86400',
};
if (origin && ALLOWED_ORIGINS.has(origin)) {
h['Access-Control-Allow-Origin'] = origin;
h['Access-Control-Allow-Credentials'] = 'true';
}
return h;
}
export default async function handler(req: Request): Promise<Response> {
const origin = req.headers.get('origin');
if (req.method === 'OPTIONS') {
return new Response(null, { status: 204, headers: corsHeaders(origin) });
}
// ... POST handling below
}
Note the 204 status with an empty body. Access-Control-Max-Age caches the preflight for a day, reducing repeat flights.
Step 3: Reflect the Origin on the Streaming Response
The same CORS headers must appear on the actual streaming response. Do not use Access-Control-Allow-Origin: * if you send cookies or Authorization headers from the browser; the spec forbids wildcard with credentials. Instead, reflect the verified origin:
if (req.method === 'POST') {
const upstream = await fetch('https://api.openai.com/v1/chat/completions', { /* ... */ });
return new Response(upstream.body, {
status: upstream.status,
headers: {
...corsHeaders(origin),
'Content-Type': 'text/event-stream; charset=utf-8',
'Cache-Control': 'no-cache, no-transform',
},
});
}
If you skip this on the streaming response, Chrome will fire the request, receive data, then throw a CORS error in the console and close the stream—tokens already received may be discarded. This is the core of vercel edge functions cors llm streaming: the header must be on every response, including the streaming one.
Step 4: Proxy the LLM Call Server-Side
The edge function acts as a server-side proxy. This avoids exposing provider keys to the browser and lets you enforce CORS at one boundary. Use the standard fetch API. If you want to avoid wiring up multiple providers, a gateway like n4n.ai exposes one OpenAI-compatible endpoint across 240+ models with automatic fallback when a provider is rate-limited, which keeps your edge code unchanged as you swap models.
const upstreamRes = await fetch('https://api.n4n.ai/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${process.env.LLM_API_KEY}`,
},
body: JSON.stringify({
model: 'anthropic/claude-3.5-sonnet',
messages: [{ role: 'user', content: 'Explain CORS' }],
stream: true,
}),
});
Never log the full stream in production; edge memory is limited and streaming bodies are not replayable.
Step 5: Pipe the Stream Without Buffering
Vercel Edge Functions stream the ReadableStream from upstreamRes.body directly to the client. Do not call await upstreamRes.text()—that buffers the entire response and defeats streaming. If you need to transform SSE lines, use pipeThrough:
const transformed = upstreamRes.body!.pipeThrough(new TextDecoderStream())
.pipeThrough(new TransformStream({
transform(chunk, controller) {
// modify chunk if needed
controller.enqueue(new TextEncoder().encode(chunk));
},
}));
For most proxies, passing upstreamRes.body straight into new Response(stream, ...) is enough. Ensure Content-Type is text/event-stream for SSE or application/jsonlines for JSON delta lines. The CORS headers ride along on that Response.
Step 6: Handle Client Aborts and Upstream Errors
If the user closes the tab, the browser aborts the fetch. You should propagate that abort to the upstream call so you don’t burn tokens on a disconnected client. Use req.signal:
const upstreamRes = await fetch(url, {
method: 'POST',
headers,
body,
signal: req.signal,
});
Wrap the fetch in try/catch. On upstream failure, send a final SSE comment or error event before closing:
if (!upstreamRes.ok) {
const msg = `data: ${JSON.stringify({ error: 'upstream failure' })}\n\n`;
return new Response(msg, {
status: 200,
headers: { ...corsHeaders(origin), 'Content-Type': 'text/event-stream' },
});
}
If the client aborts, fetch throws an AbortError; catch it and return a minimal response. The browser already terminated the connection, so no CORS violation occurs, but you avoid unhandled rejections in the edge runtime.
Step 7: Deploy and Verify Success
Deploy with vercel deploy. Verification has two parts: preflight and cross-origin stream.
First, simulate the browser preflight:
curl -i -X OPTIONS https://your-app.vercel.app/api/llm-stream \
-H "Origin: https://app.example.com" \
-H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: content-type"
You should see HTTP/2 204 and access-control-allow-origin: https://app.example.com. If the header is missing, your ALLOWED_ORIGINS set does not match the Origin exactly.
Second, test the streaming POST from a different origin using a tiny HTML page hosted on app.example.com:
<script>
fetch('https://your-app.vercel.app/api/llm-stream', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt: 'hi' }),
}).then(r => {
const reader = r.body.getReader();
const dec = new TextDecoder();
(function read() {
reader.read().then(({ done, value }) => {
if (done) return;
console.log(dec.decode(value));
read();
});
})();
});
</script>
Open the browser console; tokens should appear without CORS errors. If you see No 'Access-Control-Allow-Origin' header is present, recheck that the origin set matches exactly (no trailing slash, correct scheme).
Common Pitfalls with vercel edge functions cors llm streaming
Wildcard and Credentials
Setting Access-Control-Allow-Origin: * while the browser sends Authorization triggers a CORS rejection. Reflect a specific origin from an allowlist.
Header Case
Vercel edge headers are case-insensitive but must be strings. Use lowercase keys in your Record to avoid duplication when the platform adds its own headers.
Missing Content-Length Is Fine
For streaming, do not set Content-Length. Some upstream proxies add it automatically; strip it. Browsers accept chunked transfer encoding and will stream correctly.
Edge Runtime Limits
You cannot use fs, Buffer, or child_process. Use TextEncoder/TextDecoder. If you need to parse SSE, do it incrementally with a TransformStream, not by accumulating the whole body.
Preflight Cache Poisoning
If you return Access-Control-Allow-Origin: null for disallowed origins, some browsers cache that. Always return no CORS header (not null) for unapproved origins.
Minimal Complete Example
export const config = { runtime: 'edge' };
const ALLOWED = new Set(['https://app.example.com']);
function cors(origin: string | null): Record<string, string> {
const h: Record<string, string> = {
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
'Access-Control-Max-Age': '86400',
};
if (origin && ALLOWED.has(origin)) {
h['Access-Control-Allow-Origin'] = origin;
h['Access-Control-Allow-Credentials'] = 'true';
}
return h;
}
export default async function handler(req: Request): Promise<Response> {
const origin = req.headers.get('origin');
if (req.method === 'OPTIONS') {
return new Response(null, { status: 204, headers: cors(origin) });
}
if (req.method !== 'POST') {
return new Response('Method Not Allowed', { status: 405, headers: cors(origin) });
}
try {
const upstream = await fetch('https://api.n4n.ai/v1/chat/completions', {
method: 'POST',
signal: req.signal,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${process.env.LLM_API_KEY}`,
},
body: JSON.stringify({
model: 'openai/gpt-4o-mini',
messages: [{ role: 'user', content: 'Stream a haiku' }],
stream: true,
}),
});
return new Response(upstream.body, {
status: upstream.status,
headers: {
...cors(origin),
'Content-Type': 'text/event-stream; charset=utf-8',
'Cache-Control': 'no-cache, no-transform',
},
});
} catch (e) {
return new Response('data: {"error":"stream failed"}\n\n', {
status: 200,
headers: { ...cors(origin), 'Content-Type': 'text/event-stream' },
});
}
}
This pattern has handled vercel edge functions cors llm streaming in production for multiple models behind a single gateway. Adjust ALLOWED to your frontend domains, set the API key in Vercel project environment, and ship.