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').
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 });
},
};
});Response Helpers
Thunder extends the native Response object with static helper methods for common HTTP responses:
return Response.ok(); // 200 OK
return Response.created(data); // 201 Created
return Response.unauthorized(); // 401 UnauthorizedComplete 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
Throw standard JavaScript errors to signal failures. Thunder will catch these and respond appropriately:
handler: async (req: Request) => {
const doc = await userModel.findOne({ _id: new ObjectId(id) });
if (!doc) {
throw new Error("User not found");
}
return Response.json(doc);
},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.