Routing System

Defining Routes

Learn how to define type-safe route endpoints using Taser.js's fluent route builder. Chain query, params, body schemas, middlewares, and response contracts.

Every route file in src/routes/ exports a Route constant created by invoking the router instance builder (t).


Route Builders

The t router instance provides fluent builder methods corresponding to all standard HTTP methods:

t.get(path);
t.post(path);
t.put(path);
t.patch(path);
t.delete(path);
t.options(path);
t.head(path);
t.query(path);
t.any(path, methods);
t.all(path);

Filesystem Path vs. Builder Path String

The filesystem location (src/routes/...) is the sole runtime source of truth for routing, HTTP dispatch, and cascading middleware pipelines. The path string passed to builder methods (e.g. t.get("/articles"), t.layout("/admin/*")) is used exclusively for TypeScript compile-time type inference (typing ctx.params) and type checking against ambient project routes (RoutePath).

Path String vs. File Location Matrix

DimensionFilesystem Location (src/routes/...)Builder Path String (t.get(...), t.layout(...))
Runtime RoutingSource of truth: determines URL matching and routingIgnored at runtime (does not affect dispatch)
HTTP DispatchExtracted from filename suffix (.get.ts)Must match factory method (t.get) during AST scan
Type InferenceEmits ambient RoutePath unionInfers ctx.params shape via PathParams<Path>
Validation LayerBuild-time duplicate route checksTypeScript compiler (tsc) via ambient RoutePath

Compile-Time vs. Build-Time Validation

  • Build-Time AST Scanning: The router generator verifies that the factory method matches the file verb (e.g. users.get.ts must call t.get(...)). It does not reject mismatches between the path argument and the file's derived URL.
  • Compile-Time Type Checking: Ambient types emitted by @taserjs/router-cli or Vite augment RouterRegister.RoutePath. Passing an unknown or misspelled route path causes TypeScript (tsc) to report a compile error:
Type '"/artikles"' is not assignable to type 'RoutePath'.
  • Mismatched Path String Drift: If you provide a path string that exists elsewhere in your project (e.g. calling t.get("/users/:id") inside src/routes/articles.get.ts), runtime routing still dispatches strictly to /articles, but ctx.params will drift to reflect { id: string } instead of {}.

Before / After: Aligning Route Path Strings

src/routes/articles.get.ts (Before - Path Mismatch)
import { json } from "@taserjs/router/reply";
import { t } from "@taserjs/router";

// ❌ TypeScript error: Type '"/artikles"' is not assignable to type 'RoutePath'.
// ⚠️ Even if ignored, runtime endpoint remains /articles, while type inference breaks.
const GET = t.get("/artikles");

export default GET.handler((ctx) => {
  return json({ ok: true });
});
src/routes/articles.get.ts (After - Aligned Path String)
import { json } from "@taserjs/router/reply";
import { t } from "@taserjs/router";

// ✅ Correct: Path string matches the filesystem-derived endpoint /articles
const GET = t.get("/articles");

export default GET.handler((ctx) => {
  return json({ ok: true });
});

Fluent Chaining Methods

Each builder method returns a RouteBuilder that supports fluent method chaining:

MethodDescription
.query(schema)Validates query string parameters
.params(schema)Validates and coerces path parameters
.body(schema)Validates request payloads (JSON by default)
.body(mode, schema)Validates payloads with explicit mode (json, form, text, raw)
.returns({ [status]: schema })Enforces compile-time and runtime response contracts
.use(middleware)Attaches route-level middleware
.handler(fn)Terminal method executing the route logic

Defining a GET Route

GET, DELETE, OPTIONS, and HEAD routes accept validation schemas for query and params:

src/routes/articles.get.ts
import { json } from "@taserjs/router/reply";
import { z } from "zod";
import { t } from "@taserjs/router";

const GET = t
  .get("/articles")
  .query(
    z.object({
      category: z.string().optional(),
      page: z.coerce.number().int().min(1).default(1),
      limit: z.coerce.number().int().min(1).max(50).default(20),
    }),
  )
  .returns({
    200: z.object({
      articles: z.array(
        z.object({
          id: z.string(),
          title: z.string(),
          category: z.string(),
        }),
      ),
      page: z.number(),
      total: z.number(),
    }),
  });

export type RouteContext = typeof GET.$Infer.Context;
export default GET.handler(async (ctx) => {
  const articles = await ctx.db.getArticles(ctx.query);
  return json({
    articles,
    page: ctx.query.page,
    total: 100,
  });
});

Path Parameters & Type Precedence

Path parameters in dynamic routes (such as /tasks/:id or /orgs/:orgId/users/:id) are automatically inferred as string on ctx.params.

When you supply a .params() schema, the validated schema types take precedence and override the default string types with full type coercion:

src/routes/tasks/$id.get.ts
import { json } from "@taserjs/router/reply";
import { z } from "zod";
import { t } from "@taserjs/router";

export default t
  .get("/tasks/:id")
  .params(
    z.object({
      id: z.coerce.number(), // ctx.params.id is coerced to number
    }),
  )
  .handler(async (ctx) => {
    const task = await ctx.db.getTaskById(ctx.params.id); // ctx.params.id is number
    return json(task);
  });

Any path parameter not explicitly mentioned in the .params() schema retains its inferred string type (for example, /orgs/:orgId/tasks/:id preserves ctx.params.orgId as string).


Defining a POST Route

POST, PUT, PATCH, and QUERY routes accept .body(), .query(), and .params() schemas:

src/routes/articles.post.ts
import { created } from "@taserjs/router/reply";
import { z } from "zod";
import { t } from "@taserjs/router";

const POST = t
  .post("/articles")
  .body(
    z.object({
      title: z.string().min(3).max(120),
      content: z.string().min(10),
      tags: z.array(z.string()).default([]),
    }),
  )
  .returns({
    201: z.object({
      id: z.string(),
      title: z.string(),
      createdAt: z.string(),
    }),
    400: z.object({ message: z.string() }),
  });

export type RouteContext = typeof POST.$Infer.Context;
export default POST.handler(async (ctx) => {
  const article = await ctx.db.createArticle(ctx.body);
  return created(article);
});

JSON & Multipart / File Uploads

The .body() method validates application/json by default and also supports form, text, and raw modes. See the Standard Schema Validation Guide for examples on validating File uploads, sizes, and MIME types.


Route-Level Middlewares

In addition to directory layout middlewares, you can attach route-specific middleware using .use():

src/routes/admin/purge.delete.ts
import { noContent } from "@taserjs/router/reply";
import { t } from "@taserjs/router";
import { verifySuperAdmin } from "../../middleware/super-admin";
import { rateLimit } from "../../middleware/rate-limit";

export default t
  .delete("/admin/purge")
  .use(rateLimit({ max: 5, windowMs: 60000 }))
  .use(verifySuperAdmin())
  .handler(async (ctx) => {
    await ctx.db.purgeDeletedRecords();
    return noContent();
  });

Multi-Method Handlers (t.any and t.all)

When an endpoint needs to handle multiple HTTP methods with shared logic, use t.any() or t.all():

src/routes/webhooks/stripe.post.ts
import { json, ok } from "@taserjs/router/reply";
import { t } from "@taserjs/router";

// Accepts GET and POST requests
export default t.any("/webhooks/stripe", ["GET", "POST"]).handler(async (ctx) => {
  if (ctx.method === "GET") {
    return json({ status: "Stripe webhook endpoint active" });
  }

  const payload = ctx.body;
  await handleStripeEvent(payload);
  return ok();
});

Inferred Route Types ($Infer)

Route builders provide an $Infer namespace for extracting the exact compile-time types of your route context, input arguments, and returns:

src/routes/users/$id.get.ts
import { json, notFound } from "@taserjs/router/reply";
import { z } from "zod";
import { t } from "@taserjs/router";

const GET = t
  .get("/users/:id")
  .params(z.object({ id: z.string().uuid() }))
  .query(z.object({ includeProfile: z.coerce.boolean().default(false) }))
  .returns({
    200: z.object({ id: z.string(), name: z.string() }),
    404: z.object({ message: z.string() }),
  });

// Extract context shape (query, params, body, state, singletons):
export type RouteContext = typeof GET.$Infer.Context;

// Extract input arguments shape:
export type RouteInput = typeof GET.$Infer.Input;

// Extract returns shape:
export type RouteOutput = typeof GET.$Infer.Output;

export default GET.handler(async (ctx) => {
  const user = await ctx.db.findUser(ctx.params.id);
  if (!user) {
    return notFound({ message: "User not found" });
  }
  return json(user);
});