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:

routes/home.ts
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.


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

PartPurpose
$paramsValidates URL path parameters (e.g. /users/:id)
$queryValidates URL query string values (e.g. ?page=1&limit=10)
$bodyValidates the request body (primarily for POST, PUT, PATCH)
$returnDefines the expected shape of the response - TypeScript enforces this
shape()Registers all validators with the framework
handlerContains 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.

HTTP Methods

Thunder supports all standard HTTP methods. Use the following methods on your router instance:

MethodRouter CallUse Case
GETrouter.get(...)Retrieve a resource
POSTrouter.post(...)Create a resource
PUTrouter.put(...)Replace a resource
PATCHrouter.patch(...)Partially update a resource
DELETErouter.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 Unauthorized

Complete Method Examples

router.get("{/:id}", function getEnv() {
  const $params = z.object({
    id: z.string(),
  });
  const $return = z.object({
    key:   z.string(),
    value: z.string(),
  });

  return {
    shape: () => ({ params: $params, return: $return }),
    handler: async (req: Request) => {
      const { id } = $params.parse(paramsAsJson(req));
      const doc = await envModel.findOne({ _id: new ObjectId(id) });

      return Response.json(doc satisfies z.output<typeof $return>);
    },
  };
});
router.post("/", function createEnv() {
  const $body = z.object({
    key:   z.string(),
    value: 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 envModel.insertOne(body);

      return Response.created({ _id: insertedId.toString() });
    },
  };
});
router.patch("{/:id}", function updateEnv() {
  const $params = z.object({ id: z.string() });
  const $body   = z.object({ value: 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 envModel.updateOne(
        { _id: new ObjectId(id) },
        { $set: body }
      );

      return Response.ok({ updated: true } satisfies z.output<typeof $return>);
    },
  };
});
router.del("{/:id}", function deleteEnv() {
  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 envModel.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/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.


On this page