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:
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:
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.
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:
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/jsonwith status 200. - A web-standard
Response- its status, headers, and body pass through. Binary bodies (images, files) are supported, and aReadableStreambody streams (see below). null/undefined- 204 No Content.redirect(url), returned or thrown - its status (303by default), headers andLocation, with the URL sent as written, so a relative path works, andCache-Control: private, no-cacheunless its headers set one.Response.redirect()accepts only absolute URLs and throws (a500) on a path. Only the valueredirect()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'sEventSourcealways sendsGET.
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.
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
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:
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' } });
}// 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-Cookiego out before the first chunk, so they must be final when you return theResponse. - 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. Atext/event-streambody always streams, is never compressed (compression would hold events back), and getsCache-Control: no-cacheunless you set one. - Chunks may be
Uint8Arrayor 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-streambody is ended and cancelled at once (anEventSourcereconnects), 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/htmlbody 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. HEADrequests cancel a streaming body instead of sending it. While a plugin with anonResponsehook 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
405with anAllowheader. - A GET without a GET handler falls through to a sibling
page.tsxif one exists, and so does a POST without a POST handler - to the page'saction. - 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.tsxwins /blog/about overapp/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.tsthat throws while it is imported (a module-scope check, a missingGIO_SESSION_SECRET) answers that JSON 500 for every method (OPTIONSincluded), and its URL stays its own - no sibling page or 404 takes it over. A WebSocket connection to it is closed with1011, not the4404of a path with nowsHandler. The log names the file and the error, once at startup and per request under the response'sdigest; in development the response carries the error too. - Cross-site
POST/PUT/PATCH/DELETErequests are refused with 403 before your handler runs (CSRF protection). Endpoints other sites post to on purpose - OAuth/OIDCform_postand SAML callbacks, payment (3-D Secure) returns, webhooks that send anOrigin- go in[security.csrf] exempt- see Security. - Export
wsHandlerfrom the same file for WebSockets (with the same dynamic segments), and publish to WebSocket rooms from any handler withbroadcast(room, data)- see WebSockets.