Routes
Define type-safe HTTP routes with full request validation using Thunder's Router.
Thunder's routing system is built around a clean, functional API that combines Zod validation, TypeScript type safety, and a declarative handler pattern. Every route in Thunder validates its inputs and enforces its output shape at compile time.
Internally, Thunder uses path-to-regexp for URL pattern matching, which means you have the full power of named parameters, optional segments, and wildcard paths available out of the box.
Creating a Router
Use the built-in router snippet (or write it manually) to scaffold a new router file:
import { Router } from "@/core/http/router.ts";
export default new Router("/", function home(router) {
// Use the `req` snippet inside here to add route handlers
});Each router receives a base path as its first argument. All routes defined within that router are mounted relative to that base.
File-base Routing
Thunder uses a file-based routing system. Which means all the files inside routes/ directory are root paths of your API's. And each file can have its own router relative to the file name. For Example
import { Router } from "@/core/http/router.ts";
// ❌ Avoid this: Repeating the name creates a duplicated endpoint ('/posts/posts/')
export default new Router("/posts", function posts(router) {
});
// ✅ Do this: Using "/" correctly maps the endpoint to the file's path ('/posts/')
export default new Router("/", function posts(router) {
});Because the file name acts as the starting prefix, you should generally use "/" as your router's base path. If you repeat the file name in your router definition, it will accidentally create a duplicated path like this ('/posts/posts').
Adding a Sub-Prefix
The router base path is appended after the file name, so you can use it to group a file's endpoints under a shared sub-prefix such as /api:
import { Router } from "@/core/http/router.ts";
export default new Router("/api", function users(router) {
// GET /users/api
router.get("/", function listUsers() {
return () => Response.json({ users: [] });
});
// GET /users/api/:id
router.get("/:id", function getUser() {
return (req) => Response.json({ id: "123" });
});
}).group("Users");The optional .group("Name") call tags every endpoint in the router with a group name, which improves the generated OpenAPI specification and TypeScript SDK. See CLI Tasks.
Named functions are required. Both the router function and every handler function must be named functions - not arrow functions - and handler names must be unique within a router. Arrow functions will throw an error at registration.
Defining Route Handlers
Inside a router, use the req snippet or write a handler manually. The structure of every handler follows this pattern:
router.get("/", function getName() {
const $params = z.object({});
const $query = z.object({});
const $body = z.object({});
const $return = z.object({});
return {
shape: () => ({
params: $params,
query: $query,
body: $body,
return: $return,
}),
handler: async (req: Request) => {
const params = $params.parse(paramsAsJson(req));
const query = $query.parse(queryAsJson(req));
const body = $body.parse(await bodyAsJson(req));
return Response.json({} satisfies z.output<typeof $return>);
},
};
});What Each Part Does
| Part | Purpose |
|---|---|
$params | Validates URL path parameters (e.g. /users/:id) |
$query | Validates URL query string values (e.g. ?page=1&limit=10) |
$body | Validates the request body (primarily for POST, PUT, PATCH) |
$return | Defines the expected shape of the response - TypeScript enforces this |
shape() | Registers all validators with the framework |
handler | Contains your business logic |
The satisfies z.output<typeof $return> expression at the end of your handler is what makes Thunder's response validation work. TypeScript will raise a compile-time error if the object you return does not match your declared $return schema.
Request Utilities
Import these utilities from @/core/http/utils.ts to extract validated data from the request object:
import { bodyAsJson, paramsAsJson, queryAsJson } from "@/core/http/utils.ts";Always use the @/ alias. Relative imports into core/ are forbidden and will produce a lint error:
Relative import into "core/" is forbidden. Use "@/core/..." instead.Why Use These Utilities?
Directly interacting with raw Request objects can be repetitive. Thunder provides specialized utilities to extract and format incoming data, making it ready for instant validation through your Zod schemas.
Here is a breakdown of how and when to use each one:
bodyAsJson
This utility parses the JSON payload sent by the client. It is primarily used for POST, PUT, and PATCH requests where data is transmitted in the request body.
router.get("/", function getName() {
const $body = z.object({
title: z.string(),
content: z.string(),
authorId: z.string(),
});
return {
shape: () => ({
body: $body,
}),
handler: async (req: Request) => {
const body = $body.parse(await bodyAsJson(req));
},
};
});Async Operation: Reading the request body is asynchronous. You must use the await keyword, or the data will fail to parse correctly.
paramsAsJson
This utility used to extract dynamic path parameters defined in your route's URL (e.g., retrieving the id from a path like /posts/:id).
router.get("{/:id}", function getPost() {
const $params = z.object({
id: z.string(),
});
return {
shape: () => ({
params: $params,
}),
handler: async (req: Request) => {
const { id } = $params.parse(paramsAsJson(req));
},
};
});queryAsJson
This utility extracts query string parameters appended to the URL (e.g., capturing ?sort=desc&page=2). This is the standard way to handle filters, pagination, and search criteria.
router.get("{/:id}/?filters=&sort=desc&limit=2", function getName() {
const $query = z.object(
{
filters: filtersSchema.optional().describe("Client side filters"),
limit: z.number().min(1).max(2000).default(2000),
sort: z.record(z.string(), z.number().min(-1).max(1)).default({ _id: -1 })
},
).meta({ tsLabel: "TPagination" });
return {
shape: () => ({
query: $query
}),
handler: async (req: Request) => {
const query = $query.parse(queryAsJson(req));
},
};
});HTTP Methods
Thunder supports all standard HTTP methods. Use the following methods on your router instance:
| Method | Router Call | Use Case |
|---|---|---|
| GET | router.get(...) | Retrieve a resource |
| POST | router.post(...) | Create a resource |
| PUT | router.put(...) | Replace a resource |
| PATCH | router.patch(...) | Partially update a resource |
| DELETE | router.del(...) | Delete a resource |
Use .del() - not .delete() - for DELETE endpoints. .delete is a reserved keyword in JavaScript.
URL Parameters
Path parameters are defined using the path-to-regexp syntax and validated via $params:
router.get("{/:id}", function getUser() {
const $params = z.object({
id: z.string(),
});
return {
shape: () => ({ params: $params }),
handler: async (req: Request) => {
const { id } = $params.parse(paramsAsJson(req));
// use id...
return Response.json({ id });
},
};
});Path Syntax Reference
| Pattern | Meaning |
|---|---|
:id | Named parameter |
:id? | Optional parameter |
{/:id} | Optional segment |
{/*path} | Wildcard - captures the rest of the path as a string[] |
router.get("/users/:userId/posts/:postId", function getPost() { /* ... */ });
router.get("/files{/*filepath}", function serveFile() { /* ... */ });
router.get("/items{/:id}", function getItems() { /* ... */ }); // id optionalBasic Auth
Use parseBasicAuth to read credentials from a Basic Authorization header. It returns { username, password } or null:
import { parseBasicAuth } from "@/core/http/utils.ts";
const auth = parseBasicAuth(req); // { username, password } | nullResponse Helpers
Thunder extends the native Response object with static helper methods for common HTTP responses. Import it from @/core/http/response.ts:
import { Response } from "@/core/http/response.ts";
Response.ok(); // 200 OK
Response.ok("Success"); // 200 with body
Response.json({ data: "value" }); // JSON response (standard)
Response.created({ id: "123" }); // 201 Created
Response.badRequest(); // 400 Bad Request
Response.unauthorized(); // 401 Unauthorized
Response.forbidden(); // 403 Forbidden
Response.notFound(); // 404 Not Found
Response.redirect("/path"); // 302 RedirectComplete Method Examples
router.get("{/:id}", function getPost() {
const $params = z.object({
id: z.string(),
});
const $return = z.object({
title: z.string(),
content: z.string(),
authorId: z.string(),
});
return {
shape: () => ({ params: $params, return: $return }),
handler: async (req: Request) => {
const { id } = $params.parse(paramsAsJson(req));
const doc = await postModel.findOne({ _id: new ObjectId(id) });
return Response.json(doc satisfies z.output<typeof $return>);
},
};
});router.post("/", function createPost() {
const $body = z.object({
title: z.string(),
content: z.string(),
authorId: z.string(),
});
const $return = z.object({
_id: z.instanceof(ObjectId),
});
return {
shape: () => ({ body: $body, return: $return }),
handler: async (req: Request) => {
const body = $body.parse(await bodyAsJson(req));
const { insertedId } = await postModel.insertOne(body);
return Response.created({ _id: insertedId.toString() });
},
};
});router.patch("{/:id}", function updatePost() {
const $params = z.object({ id: z.string() });
const $body = z.object({
title: z.string().optional(),
content: z.string().optional()
});
const $return = z.object({ updated: z.boolean() });
return {
shape: () => ({ params: $params, body: $body, return: $return }),
handler: async (req: Request) => {
const { id } = $params.parse(paramsAsJson(req));
const body = $body.parse(await bodyAsJson(req));
await postModel.updateOne(
{ _id: new ObjectId(id) },
{ $set: body }
);
return Response.ok({ updated: true } satisfies z.output<typeof $return>);
},
};
});router.del("{/:id}", function deletePost() {
const $params = z.object({ id: z.string() });
const $return = z.object({ deleted: z.boolean() });
return {
shape: () => ({ params: $params, return: $return }),
handler: async (req: Request) => {
const { id } = $params.parse(paramsAsJson(req));
await postModel.deleteOne({ _id: new ObjectId(id) });
return Response.ok({ deleted: true } satisfies z.output<typeof $return>);
},
};
});Error Handling
Errors are automatically caught and formatted by the framework:
| Thrown value | Resulting response |
|---|---|
ZodError | 400 Bad Request with validation details |
Response | Returned to the client as-is |
Error | 500 with the message (stack only in development) |
Throw a Response for custom error responses, or a plain Error for unexpected failures:
handler: async (req: Request) => {
const doc = await userModel.findOne({ _id: new ObjectId(id) });
if (!doc) {
// Throwing a Response is returned to the client as-is.
throw Response.notFound("User not found");
}
return Response.json(doc);
},// A ZodError thrown by .parse() is automatically converted to a 400.
const body = $body.parse(await bodyAsJson(req));
// Regular errors become a 500.
throw new Error("Something went wrong");Use the built-in Logger for structured output:
import { Logger } from "@/core/utils/logger.ts";
Logger.success("Record inserted");
Logger.info("Processing request...");
Logger.warn("Deprecated field used");
Logger.error("Database connection failed");You may also leverage Hooks for centralized error handling and response modification across all routes.