GioJSdocs
On this page

Route Handlers

API endpoints, Server-Sent Events, and WebSockets with route.ts files.

A route.ts (or route.js) file turns its folder into a server endpoint. Export a function per HTTP method - GET, POST, PUT, PATCH, DELETE:

app/api/notes/[id]/route.ts
import type { GioRequest } from '@gio.js/core';

export async function POST(req: GioRequest<'/api/notes/:id'>) {
  const { text } = req.json<{ text: string }>();
  const user = req.cookies['session'];       // parsed Cookie header
  const id = req.params.id;                  // from the [id] segment: string
  return { saved: text, id };                // → application/json, 200
}

The route pattern in GioRequest<'/api/notes/:id'> types req.params; it is checked against the routes the server discovered (the generated .gio/routes.d.ts), so a typo fails tsc. A params shape (GioRequest<{ id: string }>) works too, and plain GioRequest types them as Record<string, string> (each one string | undefined under noUncheckedIndexedAccess, which the TypeScript starter enables). To type the whole handler, use RouteHandler - its return type admits everything the server accepts:

ts
import type { RouteHandler } from '@gio.js/core';

export const GET: RouteHandler<'/api/notes/:id'> = async (req) => {
  const note = await db.notes.find(req.params.id);
  return note ?? new Response('gone', { status: 410 });
};

The request object

Handlers receive a GioRequest: method, path, params, query, lowercased headers, parsed cookies, the raw body (bodyBase64 is true for binary bodies), and the json() and formData() helpers.

It also says who is asking: ip is the client's address, scheme ('https' or 'http') and host are what the client used, and requestId is the request's X-Request-Id, also on every log line for the request. Behind a reverse proxy these describe the visitor only when the proxy is listed in [server] trusted_proxies - otherwise ip is the proxy's address (see Configuration). Use req.ip, never the x-forwarded-for header: any client can send that header, while req.ip only honors it from trusted proxies. req.host, on the other hand, is whatever the client sent unless your proxy pins it - fine for display, never for a security decision.

app/api/audit/route.ts
import type { GioRequest } from '@gio.js/core';

export async function POST(req: GioRequest) {
  await audit.record({ ip: req.ip, requestId: req.requestId, action: req.json() });
  return { ok: true };
}

json() parses only bodies sent with Content-Type: application/json (or application/*+json). For anything else it throws UnsupportedMediaTypeError, which becomes a 415 Unsupported Media Type response unless you catch it - a form on another site can send text/plain without a CORS preflight, so a handler must not treat it as JSON. A JSON body that is empty or does not parse throws MalformedBodyError, a 400 Bad Request unless you catch it: the client sent it, so it is not logged as a handler failure. req.body always holds the raw body:

ts
import { isUnsupportedMediaTypeError, type GioRequest } from '@gio.js/core';

export function POST(req: GioRequest) {
  try {
    return { saved: req.json<{ text: string }>().text };
  } catch (err) {
    if (!isUnsupportedMediaTypeError(err)) throw err;
    return { saved: new URLSearchParams(req.body ?? '').get('text') };  // form post
  }
}

await req.formData() is the form counterpart: it parses application/x-www-form-urlencoded and multipart/form-data bodies into a web-standard FormData (file fields are File objects), answers 415 for any other content type and 400 for a body that does not parse (MalformedBodyError). Bodies are limited by [server] max_body_bytes (413 above it). For forms that post to a page, a page action is usually simpler than a route handler - see Forms and Mutations.

What you can return

  • Any JSON-serializable value - sent as application/json with status 200.
  • A web-standard Response - its status, headers, and body pass through. Binary bodies (images, files) are supported, and a ReadableStream body streams (see below).
  • null / undefined - 204 No Content.
  • redirect(url), returned or thrown - its status (303 by default), headers and Location, with the URL sent as written, so a relative path works, and Cache-Control: private, no-cache unless its headers set one. Response.redirect() accepts only absolute URLs and throws (a 500) on a path. Only the value redirect() returns is a redirect: a plain object such as parsed request JSON is always sent as JSON, whatever its keys.
  • A GioEventStream - switches the connection to SSE. Any method may return one; a browser's EventSource always sends GET.
ts
export function DELETE() {
  return new Response('gone', { status: 202, headers: { 'X-Reason': 'cleanup' } });
}

Setting cookies

Append one Set-Cookie per cookie - each is sent as its own header, byte-for-byte (cookies are never comma-joined, so Expires dates stay intact). Other repeated headers such as Link or WWW-Authenticate are combined into one comma-separated value.

app/api/login/route.ts
import { serializeCookie, type GioRequest } from '@gio.js/core';
import { sessions } from '../../../lib/session.server.ts';

export async function POST(req: GioRequest) {
  const user = await login(req.json());
  const session = sessions.getSession(req);
  session.set('userId', user.id);
  const headers = new Headers({ Location: '/dashboard' });
  headers.append('Set-Cookie', sessions.commitSession(session));
  headers.append('Set-Cookie', serializeCookie('theme', user.theme, { httpOnly: false }));
  return new Response(null, { status: 303, headers });
}

redirect('/dashboard', { headers: { 'set-cookie': [a, b] } }) answers the same way.

serializeCookie applies secure defaults (HttpOnly, SameSite=Lax, Secure in production) and refuses values that could inject attributes; sessions are covered in Authentication.

Server-Sent Events

app/ticker/route.ts
import { GioEventStream } from '@gio.js/core';

export function GET() {
  return new GioEventStream((stream) => {
    const t = setInterval(() => stream.send({ time: Date.now() }), 1000);
    return () => clearInterval(t);           // runs when the client disconnects
  });
}

Streaming responses

Return a Response whose body is a ReadableStream and the client receives each chunk as you produce it - an LLM token stream, a large export, an event stream written by hand:

app/api/chat/route.ts
import type { GioRequest } from '@gio.js/core';

export async function POST(req: GioRequest) {
  const { prompt } = req.json<{ prompt: string }>();
  const tokens = await llm.stream(prompt);           // AsyncIterable<string>
  const encoder = new TextEncoder();
  const body = new ReadableStream({
    async pull(controller) {
      const { done, value } = await tokens.next();
      if (done) controller.close();
      else controller.enqueue(encoder.encode(value));
    },
    cancel() {
      tokens.return?.();                            // the client went away
    },
  });
  return new Response(body, { headers: { 'content-type': 'text/plain; charset=utf-8' } });
}
ts
// app/api/progress/route.ts - Server-Sent Events from a plain Response
export function GET() {
  let timer: ReturnType<typeof setInterval>;
  const body = new ReadableStream({
    start(controller) {
      let n = 0;
      timer = setInterval(() => controller.enqueue(`data: ${JSON.stringify({ n: ++n })}\n\n`), 1000);
    },
    cancel() {
      clearInterval(timer);
    },
  });
  return new Response(body, { headers: { 'content-type': 'text/event-stream' } });
}
  • Status, headers and every Set-Cookie go out before the first chunk, so they must be final when you return the Response.
  • A body that is already complete when you return it - a string, a buffer, JSON, a stream that closes without waiting - and is at most 1 MiB is sent buffered, with a Content-Length (unless it is compressed: a compressed body's size is only known once it is sent, so it goes out chunked). Anything else streams with chunked encoding. A text/event-stream body always streams, is never compressed (compression would hold events back), and gets Cache-Control: no-cache unless you set one.
  • Chunks may be Uint8Array or strings (sent as UTF-8); binary bodies arrive byte-for-byte.
  • Backpressure reaches your stream: when the client reads slowly, the server stops pulling once about 1 MiB is waiting for it, so a large download never piles up in memory. Produce chunks in pull() (as above) to benefit.
  • When the client disconnects, the stream is cancelled - your cancel() runs, so stop timers and upstream requests there.
  • A streamed body has no time limit: it ends when you close the stream or the client leaves. When the server shuts down, a text/event-stream body is ended and cancelled at once (an EventSource reconnects), so it never holds up a deploy. Any other streamed body - a download, an export - is left to finish within the 8-second shutdown drain; one still running after it has its connection reset, so the client sees a failed download, never a short file that looks complete. If your stream errors midway, the response ends early (the status is already sent).
  • A streamed text/html body is sent exactly as you write it - an htmx fragment or a page without a <head> arrives chunk by chunk. GioJS injects its head scripts into page streams only.
  • HEAD requests cancel a streaming body instead of sending it. While a plugin with an onResponse hook is installed, bodies are buffered so the hook can see them - except event streams, which stream and skip the hook.

GioEventStream (above) remains the shortest way to write SSE: it frames events for you and runs your cleanup on disconnect. The Streaming guide covers streamed pages and what happens to open streams at shutdown.

Rules

  • Handler responses are never cached or coalesced - every request runs your code.
  • Requests for methods you didn't export get 405 with an Allow header.
  • A GET without a GET handler falls through to a sibling page.tsx if one exists, and so does a POST without a POST handler - to the page's action.
  • When a page and a route.ts in different folders both match a URL, the more specific pattern owns it (see Layouts & Pages): app/blog/about/page.tsx wins /blog/about over app/blog/[slug]/route.ts, and a route.ts that is more specific than a matching page answers every method itself (405 for those it does not export).
  • Pages answer GET/HEAD, and POST when they export an action (see Forms and Mutations); PUT/PATCH/DELETE belong in route handlers.
  • A thrown error is logged server-side and answered with a JSON 500 (no internals leaked).
  • A route.ts that throws while it is imported (a module-scope check, a missing GIO_SESSION_SECRET) answers that JSON 500 for every method (OPTIONS included), and its URL stays its own - no sibling page or 404 takes it over. A WebSocket connection to it is closed with 1011, not the 4404 of a path with no wsHandler. The log names the file and the error, once at startup and per request under the response's digest; in development the response carries the error too.
  • Cross-site POST/PUT/PATCH/DELETE requests are refused with 403 before your handler runs (CSRF protection). Endpoints other sites post to on purpose - OAuth/OIDC form_post and SAML callbacks, payment (3-D Secure) returns, webhooks that send an Origin - go in [security.csrf] exempt - see Security.
  • Export wsHandler from the same file for WebSockets (with the same dynamic segments), and publish to WebSocket rooms from any handler with broadcast(room, data) - see WebSockets.