Skip to main content

Routing

Balda provides flexible routing through direct server registration, the router singleton, or controller decorators.

Route Registration Methods

1. Direct Server Routes

import { Server, router } from "balda";
import { z } from "zod";

const server = new Server({ port: 3000 });

// Simple routes
router.get("/users", (req, res) => res.json({ users: [] }));
router.post("/users", (req, res) => res.created(req.body));
router.put("/users/:id", (req, res) => res.json({ id: req.params.id }));
router.delete("/users/:id", (req, res) => res.noContent());

// With inline validation - validated data is written to req.body / req.query
const CreateUserSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
});

router.post(
"/users/validated",
{
body: CreateUserSchema,
},
(req, res) => {
// req.body is validated and typed
res.created({ id: "123", ...req.body });
},
);

// With options
router.post(
"/users",
{
middlewares: [authMiddleware],
body: CreateUserSchema,
responses: {
201: UserResponseSchema,
},
swagger: {
name: "Create User",
},
},
(req, res) => {
res.created(req.body);
},
);

2. Router Singleton

Useful for modular route definitions:

import { router } from "balda";
import { z } from "zod";

// Simple routes
router.get("/health", (req, res) => res.json({ status: "ok" }));

// With inline validation
const LoginSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
});

router.post(
"/login",
{
body: LoginSchema,
},
(req, res) => {
// req.body is validated and typed
const { email, password } = req.body;
res.json({ token: generateToken(email, password) });
},
);

// Query validation
const SearchSchema = z.object({
q: z.string(),
page: z.coerce.number().default(1),
});

router.get(
"/search",
{
query: SearchSchema,
},
(req, res) => {
// query parameters are validated and type-coerced
res.json({ results: [], query: req.query.q, page: req.query.page });
},
);

3. Controller Decorators

Recommended for organized, feature-based routing:

import { controller, get, post } from "balda";

@controller("/users")
export class UsersController {
@get("/") getAll(req, res) {
res.json({ users: [] });
}
@get("/:id") getById(req, res) {
res.json({ id: req.params.id });
}
@post("/") create(req, res) {
res.created(req.body);
}
}

See Controllers for detailed controller documentation.

Route Parameters

Path Parameters

// Single parameter
router.get("/users/:id", (req, res) => {
res.json({ id: req.params.id });
});

// Multiple parameters
router.get("/users/:userId/posts/:postId", (req, res) => {
const { userId, postId } = req.params;
res.json({ userId, postId });
});

Query Parameters

router.get("/users", (req, res) => {
const { page = 1, limit = 10, search } = req.query;
res.json({ page: Number(page), limit: Number(limit), search });
});

Route Patterns

// Static route
router.get("/about", (req, res) => res.json({ message: "About" }));

// Dynamic parameter
router.get("/users/:id", (req, res) => res.json({ id: req.params.id }));

// Optional parameter
router.get("/posts/:id?", (req, res) => {
req.params.id ? res.json({ id }) : res.json({ posts: [] });
});

// Wildcard
router.get("/files/*", (req, res) => {
res.json({ filePath: req.params["*"] });
});

Route Precedence

Routes are matched in registration order. Define specific routes before general ones:

// Specific first
router.get("/users/admin", (req, res) => res.json({ admin: true }));
// General after
router.get("/users/:id", (req, res) => res.json({ id: req.params.id }));

Type-Safe Routing

Balda provides automatic type inference for both path parameters and response bodies.

Path Parameters

Path parameters are automatically inferred from the route path string:

// Path parameters are inferred — no manual typing needed
router.get('/users/:id', (req, res) => {
const { id } = req.params; // ✅ Typed as { id: string }
res.json({ id });
});

Type-Safe Responses

When you define response schemas in the responses option, Balda automatically infers the response body types for each status code. This works with Zod, TypeBox, and plain JSON schemas:

import { z } from 'zod';

const UserSchema = z.object({
id: z.string(),
name: z.string(),
email: z.string(),
});

const ErrorSchema = z.object({
error: z.string(),
});

router.get(
'/users/:id',
{
responses: {
200: UserSchema,
404: ErrorSchema,
},
},
(req, res) => {
const { id } = req.params; // ✅ Typed as { id: string }

res.ok({ id, name: "John", email: "john@example.com" }); // ✅ Typed as { id: string; name: string; email: string }
res.notFound({ error: "User not found" }); // ✅ Typed as { error: string }

// res.ok({ wrong: 123 }); // ❌ Type error — doesn't match UserSchema
// res.notFound({ wrong: "value" }); // ❌ Type error — doesn't match ErrorSchema
},
);

Each shorthand method (ok(), created(), notFound(), etc.) is typed to its corresponding status code schema. Methods for unmapped status codes default to any.

TypeBox and plain JSON schemas

TypeBox schemas provide the same type inference as Zod via Static<T>. Plain JSON Schema objects also support full type inference via json-schema-to-tsas const is required so TypeScript preserves the literal types needed for inference.

Also, you need to install json-schema-to-ts to be able to use plain JSON schemas with type inference.

npm install json-schema-to-ts --save-dev
const UserSchema = {
type: "object",
properties: {
id: { type: "integer" },
name: { type: "string" },
},
required: ["id", "name"],
additionalProperties: false,
} as const; // ← required for type inference

router.get("/users/:id", { responses: { 200: UserSchema } }, (req, res) => {
res.ok({ id: 1, name: "Alice" }); // ✅ typed as { id: number; name: string }
});

Without as const, plain JSON schema objects resolve to any.

Controller Type Safety

For controller-based routes, you can manually type the Response generic:

import { Request, Response } from 'balda';

type UserResponse = { id: string; name: string; email: string };

@post('/')
@validate.body(CreateUserSchema)
async create(
req: Request<{}>,
res: Response<{ 201: UserResponse }>,
body: z.infer<typeof CreateUserSchema> // ✅ Validated & typed
) {
res.created({ id: '123', ...body }); // ✅ Typed as UserResponse
}

See Request-Response for more on type-safe requests and responses.

Route Options

All route methods (get, post, put, patch, delete, options, head) support an optional configuration object:

import { z } from "zod";

const UserSchema = z.object({
name: z.string(),
email: z.string().email(),
});

const QuerySchema = z.object({
includeDeleted: z.coerce.boolean().default(false),
});

router.post(
"/users",
{
// Request validation — inline route validation writes validated data to `req`
body: UserSchema, // Validates and sets `req.body`
query: QuerySchema, // Validates and sets `req.query`
all: CombinedSchema, // Validates body+query merged (mutually exclusive with body/query)

// Middleware
middlewares: [authMiddleware, validationMiddleware], // or single middleware

// Swagger documentation
swagger: {
name: "Create User",
description: "Creates a new user",
responses: {
201: UserResponseSchema,
400: ErrorSchema,
},
},
},
(req, res) => {
// req.body and req.query are typed and validated
res.created({ id: "123", ...req.body });
},
);

Validation Options

  • body: Validates the request body and writes the typed result to req.body.
  • query: Validates the query string and writes the typed result to req.query.
  • all: Validates a merged object composed of body + query and writes the result to req.body (cannot be used with body or query).
Automatic Swagger Integration

When you specify body or query at the route level, they're automatically included in Swagger documentation. No need to specify requestBody or query in the swagger options.

Route Groups

Use router.group() to organize related routes:

import { z } from "zod";

// Basic grouping
router.group("/api/v1", (r) => {
r.get("/users", (req, res) => res.json({ users: [] }));
r.get("/posts", (req, res) => res.json({ posts: [] }));
});

// With middleware
router.group("/admin", [authMiddleware, adminMiddleware], (r) => {
r.get("/dashboard", (req, res) => res.json({ ok: true }));
});

// With validation in grouped routes
router.group("/api/v2", (r) => {
const CreatePostSchema = z.object({
title: z.string(),
content: z.string(),
});

r.post(
"/posts",
{
body: CreatePostSchema,
middlewares: [authMiddleware],
},
(req, res, validatedBody) => {
res.created({ id: "123", ...validatedBody });
},
);
});

See the Server documentation for more details on grouping and middleware.