Skip to content
AI Full-Stack Wiki

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.

Difficulty: Intermediate17 min readUpdated
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#

REST vs SSE vs WebSocketsREST returns one response per request; Server-Sent Events keep one HTTP response open and stream events to the browser; WebSockets upgrade the connection so both sides can send messages at any time.RESTServer-Sent EventsWebSocketBrowserServerPOST /api/summaries200 OK + JSON bodyPOST /api/chat (Accept:text/event-stream)data: {"text":"Hel"}data: {"text":"lo"}event: doneGET /ws (Upgrade:websocket)101 Switching Protocolsmessagemessage
REST, Server-Sent Events, and WebSockets compared: one response per request, a one-way stream of events, or a two-way persistent channel.
Show text description

Three ways a browser and a server exchange data, shown as one sequence with three frames. Participants, left to right: Browser, Server.

  1. Browser → Server: POST /api/summaries
  2. Server → Browser: 200 OK + JSON body
  3. Browser → Server: POST /api/chat (Accept: text/event-stream)
  4. Server → Browser: data: {"text":"Hel"} (streamed)
  5. Server → Browser: data: {"text":"lo"} (streamed)
  6. Server → Browser: event: done (streamed)
  7. Browser → Server: GET /ws (Upgrade: websocket)
  8. Server → Browser: 101 Switching Protocols
  9. Browser → Server: message (asynchronous)
  10. Server → Browser: message (asynchronous)
TransportDirectionBest for in AI appsWatch out for
REST (JSON request/response)One request, one responseCRUD, summaries, structured extraction, anything under a few secondsUsers staring at a spinner for long generations
Server-Sent Events (SSE)Server → browser, one long responseStreaming model output token by token, progress updatesProxies that buffer responses; one direction only
WebSocketsBoth ways, any timeVoice, collaborative editing, agents that push and receive events continuouslyStateful 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: 400 invalid input, 401 not signed in, 403 signed in but not allowed, 404 not found (also for resources the user may not see), 429 rate limited, 502/503 when 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:

lib/sse.ts
/** 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;
app/api/countdown/route.ts
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:

lib/read-sse.ts
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") };
    }
  }
}
try-countdown.ts
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.

Cookie-based session authenticationThe browser logs in, receives an httpOnly session cookie, and sends it automatically on each request; the server looks up the session before handling the request.BrowserAPISessionstorePOST /login(email,password)create sessionSet-Cookie:sid=…;HttpOnly;Secure;SameSite=LaxPOST /api/chat(cookie sentautomatically)look up sid →user200 (or 401 ifmissing /expired)
Session authentication: the server sets an httpOnly cookie after login, and every later request is checked against the session store before any data or model call.
Show text description

A cookie-based session, the default choice for web apps. Participants, left to right: Browser, API, Session store.

  1. Browser → API: POST /login (email, password)
  2. API → Session store: create session
  3. API → Browser: Set-Cookie: sid=…; HttpOnly; Secure; SameSite=Lax
  4. Browser → API: POST /api/chat (cookie sent automatically)
  5. API → Session store: look up sid → user
  6. 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.

lib/session.ts
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);
}
app/api/me/route.ts
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 EventSource for chat. It cannot POST a conversation. Use fetch and 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-transform and 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

  1. Extend /api/countdown to send an SSE comment line (: ping) every 2 seconds while waiting, and confirm readSSE ignores it.
  2. Add id: lines to each event and log the last ID the client received.
  3. Protect the route with getUserId() and return 401 when there is no session. Test it with and without a cookie.

Check your understanding

Your chat UI sends the whole conversation and needs to show tokens as they arrive. Which approach fits?
Why compare signatures with timingSafeEqual instead of ===?
What should an API store for a customer's API key?

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 with fetch so you can POST.
  • Stop streaming when request.signal aborts; 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.