RESTful APIs
Thunder supports an export default object pattern that maps HTTP methods directly to named handler functions. Combined with the thunder:http response helpers, this pattern lets you build clean RESTful APIs with minimal boilerplate.
The method handler pattern
Section titled “The method handler pattern”Instead of routing manually inside a single handler function, export a default object where each key is an HTTP method:
import { JSONResponse, EmptyResponse, ErrorResponse, HTTP } from "thunder:http";
export default { GET(req: Request) { return JSONResponse({ message: "list items" }).toResponse(); },
POST(req: Request) { return JSONResponse({ created: true }) .status(HTTP.Created) .toResponse(); },
DELETE(req: Request) { return EmptyResponse() .status(HTTP.NoContent) .toResponse(); },};Thunder automatically dispatches incoming requests to the matching method handler. If a request arrives with a method that has no corresponding handler (for example, PATCH on the object above), the runtime returns 405 Method Not Allowed with an Allow header listing the supported methods. You do not need to handle this yourself.
Importing thunder:http
Section titled “Importing thunder:http”The thunder:http module provides response builders and an HTTP status map. Import what you need:
import { HTTP, JSONResponse, TextResponse, HTMLResponse, EmptyResponse, ErrorResponse, StreamResponse, RedirectResponse, BinaryResponse, FileResponse, GenericResponse,} from "thunder:http";The thunder:http alias is resolved by Thunder CLI flows (thunder watch, thunder bundle, thunder test, thunder check).
HTTP status map
Section titled “HTTP status map”The HTTP export provides named constants for standard status codes:
HTTP.Ok; // 200HTTP.Created; // 201HTTP.NoContent; // 204HTTP.BadRequest; // 400HTTP.NotFound; // 404HTTP.MethodNotAllowed; // 405HTTP.InternalServerError; // 500Fluent builder API
Section titled “Fluent builder API”All response builders support method chaining and finalize with .toResponse():
return JSONResponse({ id: 42 }) .status(HTTP.Created) .header("x-request-id", "abc-123") .toResponse();Shared builder methods:
| Method | Description |
|---|---|
.status(code) | Set the HTTP status code |
.header(key, value) | Set a response header |
.appendHeader(key, value) | Append a value to an existing header |
.withHeaders(headers) | Set multiple headers at once |
.cookie(name, value, attributes?) | Set a cookie |
.toResponse() | Build the final Response object |
CRUD example
Section titled “CRUD example”Here is a complete in-memory CRUD API using the method handler pattern:
import { JSONResponse, EmptyResponse, ErrorResponse, HTTP } from "thunder:http";
let nextId = 3;const items = new Map<number, { id: number; name: string }>([ [1, { id: 1, name: "alpha" }], [2, { id: 2, name: "beta" }],]);
function idFromUrl(req: Request): number | null { const match = new URL(req.url).pathname.match(/\/(\d+)$/); if (!match) return null; const parsed = Number(match[1]); return Number.isFinite(parsed) ? parsed : null;}
export default { async GET(req: Request) { const id = idFromUrl(req); if (id !== null) { const item = items.get(id); if (!item) { return ErrorResponse("not_found") .status(HTTP.NotFound) .toResponse(); } return JSONResponse(item).toResponse(); }
return JSONResponse({ data: Array.from(items.values()), }).toResponse(); },
async POST(req: Request) { const body = await req.json().catch(() => null); const name = typeof body?.name === "string" ? body.name.trim() : ""; if (!name) { return JSONResponse({ error: "invalid_payload", expected: "{ name: string }" }) .status(HTTP.BadRequest) .toResponse(); }
const item = { id: nextId++, name }; items.set(item.id, item);
return JSONResponse(item) .status(HTTP.Created) .header("location", `/items/${item.id}`) .toResponse(); },
async DELETE(req: Request) { const id = idFromUrl(req); if (id === null) { return ErrorResponse("id_required") .status(HTTP.BadRequest) .toResponse(); }
if (!items.delete(id)) { return ErrorResponse("not_found") .status(HTTP.NotFound) .toResponse(); }
return EmptyResponse() .status(HTTP.NoContent) .toResponse(); },};Testing the CRUD API
Section titled “Testing the CRUD API”After bundling and deploying (or using thunder watch):
# List all itemscurl http://localhost:8080/items
# Get a single itemcurl http://localhost:8080/items/1
# Create an itemcurl -X POST http://localhost:8080/items \ -H "content-type: application/json" \ -d '{"name": "gamma"}'
# Delete an itemcurl -X DELETE http://localhost:8080/items/2
# Unsupported method returns 405curl -X PATCH http://localhost:8080/items/1Response builders reference
Section titled “Response builders reference”JSONResponse
Section titled “JSONResponse”Serializes a value as JSON with content-type: application/json:
return JSONResponse({ id: 1, name: "alpha" }) .status(HTTP.Ok) .toResponse();TextResponse
Section titled “TextResponse”Returns plain text with content-type: text/plain:
return TextResponse("pong").toResponse();HTMLResponse
Section titled “HTMLResponse”Returns HTML with content-type: text/html:
return HTMLResponse("<h1>Hello</h1>").toResponse();ErrorResponse
Section titled “ErrorResponse”Returns a JSON error body. Accepts an error code string and optional details:
return ErrorResponse("invalid_payload", { expected: "{ name: string }" }) .status(HTTP.BadRequest) .toResponse();EmptyResponse
Section titled “EmptyResponse”Returns a response with no body, typically used with 204 No Content:
return EmptyResponse() .status(HTTP.NoContent) .toResponse();RedirectResponse
Section titled “RedirectResponse”Returns a redirect response:
return RedirectResponse("/new-path") .status(HTTP.PermanentRedirect) .toResponse();BinaryResponse
Section titled “BinaryResponse”Returns raw bytes with content-type: application/octet-stream:
const bytes = new Uint8Array([1, 2, 3]);return BinaryResponse(bytes).toResponse();FileResponse
Section titled “FileResponse”Returns a file download response with a Content-Disposition header:
const bytes = new TextEncoder().encode("report data");return FileResponse(bytes) .attachment("report.txt") .toResponse();GenericResponse auto-detection
Section titled “GenericResponse auto-detection”For quick handlers, GenericResponse(value, init?) infers the correct response type automatically:
import { GenericResponse, HTTP } from "thunder:http";
export default async function handler(req: Request): Promise<Response> { if (req.method === "GET") { return GenericResponse({ ok: true }); // inferred as JSON }
if (req.method === "POST") { return GenericResponse("accepted", { status: HTTP.Accepted }); // inferred as text/plain }
return GenericResponse(null); // 204 No Content}Inference rules
Section titled “Inference rules”GenericResponse applies these rules in order:
Response— passthrough, returned as-is.ResponseDraft(builder instance) — calls.toResponse().- Envelope object
{ body, status?, headers? }— uses envelope metadata. null/undefined— empty response (default 204).ReadableStream— stream response.Blob— blob response.ArrayBuffer/ typed arrays — binary response.string— text response.number/boolean/bigint— text response viaString(value).- Objects / arrays — JSON response.
Default function handler pattern
Section titled “Default function handler pattern”You can also use export default function for a single handler that receives all methods:
import { GenericResponse, HTTP } from "thunder:http";
export default async function handler(req: Request): Promise<Response> { const url = new URL(req.url); const method = req.method.toUpperCase();
if (method === "GET") { return GenericResponse({ status: "ok" }); }
return GenericResponse(null, { status: HTTP.MethodNotAllowed });}When using the export default { GET, POST, ... } object pattern, the runtime handles 405 responses automatically. When using a single export default function, you are responsible for method routing yourself.