APIs for AI Apps: REST, Server-Sent Events, WebSockets, and Authentication
Choose between REST, Server-Sent Events, and WebSockets for AI features, stream events from a Next.js route handler, and secure every endpoint with sessions.
On this page· Three transports, three shapes of conversation
Why this matters
Your API is the only door between the internet and your model budget. Choose the wrong transport and a chat feels sluggish or breaks behind a proxy; forget authentication on one route and anyone can run up your bill. This article covers how data moves (REST, Server-Sent Events, WebSockets) and how to make sure only the right users can move it.
Three transports, three shapes of conversation#
Show text descriptionHide text description
Three ways a browser and a server exchange data, shown as one sequence with three frames. Participants, left to right: Browser, Server.
- Browser → Server: POST /api/summaries
- Server → Browser: 200 OK + JSON body
- Browser → Server: POST /api/chat (Accept: text/event-stream)
- Server → Browser: data: {"text":"Hel"} (streamed)
- Server → Browser: data: {"text":"lo"} (streamed)
- Server → Browser: event: done (streamed)
- Browser → Server: GET /ws (Upgrade: websocket)
- Server → Browser: 101 Switching Protocols
- Browser → Server: message (asynchronous)
- Server → Browser: message (asynchronous)
| Transport | Direction | Best for in AI apps | Watch out for |
|---|---|---|---|
| REST (JSON request/response) | One request, one response | CRUD, summaries, structured extraction, anything under a few seconds | Users staring at a spinner for long generations |
| Server-Sent Events (SSE) | Server → browser, one long response | Streaming model output token by token, progress updates | Proxies that buffer responses; one direction only |
| WebSockets | Both ways, any time | Voice, collaborative editing, agents that push and receive events continuously | Stateful connections complicate scaling and serverless hosting |
Default to REST, use SSE for streaming, and reach for WebSockets only when the client must also push events mid-stream. Most chat apps never need WebSockets: the user's next message is simply a new request.
REST done well#
A few conventions make an API predictable for both humans and models calling it as a tool:
- Nouns in URLs, verbs in methods:
GET /api/conversations,POST /api/conversations/:id/messages. - Status codes that mean something:
400invalid input,401not signed in,403signed in but not allowed,404not found (also for resources the user may not see),429rate limited,502/503when an upstream such as the model provider fails. - One error shape everywhere:
{ "error": "message", "issues"?: [...] }. - Validate every input at the boundary (with Zod, as in the TypeScript article) before any database or model call.
Server-Sent Events from scratch#
SSE is plain HTTP: the server sets Content-Type: text/event-stream and writes events separated by a blank line. Each event has optional event: and id: fields and one or more data: lines.
event: token
data: {"text":"Hel"}
event: token
data: {"text":"lo"}
event: done
data: {}
A small helper formats events, and a Route Handler streams them with a web ReadableStream:
/** Format one Server-Sent Event. Data is JSON-encoded, so it never contains a raw newline. */
export function sseEvent(event: string, data: unknown): string {
return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
}
export const SSE_HEADERS = {
"Content-Type": "text/event-stream; charset=utf-8",
"Cache-Control": "no-cache, no-transform",
"X-Accel-Buffering": "no", // ask nginx-style proxies not to buffer the stream
} as const;import { SSE_HEADERS, sseEvent } from "../../../lib/sse";
export async function GET(request: Request) {
const encoder = new TextEncoder();
const stream = new ReadableStream<Uint8Array>({
async start(controller) {
for (let n = 5; n > 0; n--) {
if (request.signal.aborted) return controller.close(); // the client disconnected
controller.enqueue(encoder.encode(sseEvent("tick", { n })));
await new Promise((resolve) => setTimeout(resolve, 500));
}
controller.enqueue(encoder.encode(sseEvent("done", {})));
controller.close();
},
});
return new Response(stream, { headers: SSE_HEADERS });
}The browser has a built-in EventSource, but it only supports GET and cannot send a JSON body or custom headers. For chat you POST the conversation, so you read the stream with fetch instead. This parser turns a streaming response into an async iterable of events:
export interface SSEMessage {
event: string;
data: string;
}
/** Parse a text/event-stream response body into events (handles chunks split mid-event). */
export async function* readSSE(response: Response): AsyncGenerator<SSEMessage> {
if (!response.ok || !response.body) throw new Error(`Stream failed with HTTP ${response.status}`);
const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
let buffer = "";
while (true) {
const { value, done } = await reader.read();
if (done) return;
buffer += value.replace(/\r\n/g, "\n");
let boundary = buffer.indexOf("\n\n");
while (boundary !== -1) {
const raw = buffer.slice(0, boundary);
buffer = buffer.slice(boundary + 2);
boundary = buffer.indexOf("\n\n");
let event = "message";
const data: string[] = [];
for (const line of raw.split("\n")) {
if (line.startsWith(":")) continue; // comment, often used as a keep-alive heartbeat
if (line.startsWith("event:")) event = line.slice(6).trim();
else if (line.startsWith("data:")) data.push(line.slice(5).replace(/^ /, ""));
}
if (data.length > 0) yield { event, data: data.join("\n") };
}
}
}import { readSSE } from "./lib/read-sse.ts";
// Run your Next.js dev server first, then: node try-countdown.ts
const response = await fetch("http://localhost:3000/api/countdown");
for await (const message of readSSE(response)) {
console.log(message.event, JSON.parse(message.data));
if (message.event === "done") break;
}In the LLM App Engineering section, the countdown becomes model tokens and the same parser drives a React chat UI.
WebSockets, briefly#
A WebSocket starts as an HTTP request with an Upgrade header and becomes a persistent, two-way channel. Use one when the client must stream data to the server continuously, such as audio for a voice assistant, or when many users share live state. Plan for what SSE gives you for free: reconnection, authentication on connect, and a separate long-running server process, because many serverless platforms do not keep WebSocket connections open.
Authentication: who is calling?#
Every endpoint that triggers a model call or touches user data must know who is calling. For browser apps the standard is a session cookie: the server issues an HttpOnly, Secure, SameSite cookie at login, the browser sends it automatically, and the server verifies it on every request.
Show text descriptionHide text description
A cookie-based session, the default choice for web apps. Participants, left to right: Browser, API, Session store.
- Browser → API: POST /login (email, password)
- API → Session store: create session
- API → Browser: Set-Cookie: sid=…; HttpOnly; Secure; SameSite=Lax
- Browser → API: POST /api/chat (cookie sent automatically)
- API → Session store: look up sid → user
- API → Browser: 200 (or 401 if missing / expired)
This helper signs a session token with HMAC so it cannot be forged, and reads it from the cookie in any Route Handler, Server Component, or Server Action. In production, use a maintained authentication library or provider for login flows, password storage, and OAuth; the verification pattern on your routes stays the same.
import { createHmac, timingSafeEqual } from "node:crypto";
import { cookies } from "next/headers";
const COOKIE = "session";
const MAX_AGE = 60 * 60 * 24 * 7; // 7 days
function secret(): string {
const value = process.env.SESSION_SECRET;
if (!value || value.length < 32) throw new Error("SESSION_SECRET must be set to at least 32 random characters");
return value;
}
const sign = (payload: string) => createHmac("sha256", secret()).update(payload).digest("base64url");
export function createSessionToken(userId: string): string {
const payload = Buffer.from(JSON.stringify({ sub: userId, exp: Math.floor(Date.now() / 1000) + MAX_AGE })).toString("base64url");
return `${payload}.${sign(payload)}`;
}
export function verifySessionToken(token: string | undefined): string | null {
if (!token) return null;
const [payload, signature] = token.split(".");
if (!payload || !signature) return null;
const expected = Buffer.from(sign(payload));
const actual = Buffer.from(signature);
// Constant-time comparison, so timing does not reveal how much of the signature matched.
if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) return null;
const { sub, exp } = JSON.parse(Buffer.from(payload, "base64url").toString()) as { sub: string; exp: number };
return exp > Date.now() / 1000 ? sub : null;
}
export async function setSession(userId: string) {
(await cookies()).set(COOKIE, createSessionToken(userId), {
httpOnly: true, // JavaScript in the page cannot read it
secure: process.env.NODE_ENV === "production",
sameSite: "lax", // not sent on cross-site POSTs, which blocks most CSRF
path: "/",
maxAge: MAX_AGE,
});
}
export async function getUserId(): Promise<string | null> {
return verifySessionToken((await cookies()).get(COOKIE)?.value);
}import { getUserId } from "../../../lib/session";
export async function GET() {
const userId = await getUserId();
if (!userId) return Response.json({ error: "Not signed in" }, { status: 401 });
return Response.json({ userId });
}API keys for machine clients#
When other programs call your API, issue API keys. Generate them with crypto.randomBytes(32), show the key to the user once, and store only a SHA-256 hash, exactly like a password. Look up the hash on each request, and log the key's ID, never the key itself.
Common mistakes#
- An unauthenticated route that calls the model. Bots find it within days and spend your budget. Every route that costs money checks the session and a rate limit first.
- Trusting the user ID in the request body. Take identity from the verified session, never from fields the client controls.
- Using
EventSourcefor chat. It cannotPOSTa conversation. Usefetchand parse the stream. - Streaming through a buffering proxy. If tokens arrive all at once at the end, a proxy or compression layer is buffering. Send
Cache-Control: no-transformand check your hosting platform's streaming support. - Returning 403 for resources a user may not know exist. Return 404 so the response does not confirm the resource exists.
Try it
Add a heartbeat and an id
- Extend
/api/countdownto send an SSE comment line (: ping) every 2 seconds while waiting, and confirmreadSSEignores it. - Add
id:lines to each event and log the last ID the client received. - Protect the route with
getUserId()and return 401 when there is no session. Test it with and without a cookie.
Check your understanding
Key takeaways
- REST by default, Server-Sent Events for streaming model output, WebSockets only for true two-way streaming.
- SSE is plain HTTP:
text/event-stream, events separated by blank lines; read it withfetchso you can POST. - Stop streaming when
request.signalaborts; it saves tokens and money. - Authenticate every route that costs money, take identity only from a verified session, and hash API keys at rest.
Next steps#
Finished this article? Track it to see your progress on the roadmap.
Related articles
- Databases for AI Apps: PostgreSQL, Drizzle, Redis, and pgvectorDesign an AI app's data layer with PostgreSQL, Drizzle, pgvector for embeddings, and Redis for caching and rate limits, with a full schema and runnable queries.
- Streaming LLM Responses to a React UI with Next.js and SSEStream Claude responses token by token from a Next.js Route Handler to a React client over Server-Sent Events, with stop, retry, error handling, and accessible updates.