Bigger Applications
As your Cerces application grows, you'll want to organize your code into logical modules rather than keeping everything in a single file. Routers allow you to split your application into smaller, manageable pieces while maintaining type safety and automatic OpenAPI documentation.
Why Use Routers?
Routers help you:
- Organize code by feature or domain (users, products, admin, etc.)
- Maintain type safety across modules with TypeScript
- Generate consistent OpenAPI docs automatically
- Reuse route groups across different applications
- Apply scoped middleware to specific route groups
- Share parameters and dependencies within route groups
Basic Router Structure
Consider this common application structure:
src/
├── index.ts # Main app
├── routers/
│ ├── users.ts # User-related routes
│ ├── products.ts # Product management
│ └── admin.ts # Admin functionality
└── shared/
├── middleware.ts # Shared middleware
└── dependencies.ts # Shared dependenciesCreating Routers
Routers are created using the Router class and must specify a base that references the parent app type:
import { Router, Base } from "cerces"
import type { app } from "../index.ts" // Import your main app type
const userRouter = new Router({
base: Base<typeof app>() // Type-safe reference to parent app
})Type-Only Imports
Using import type avoids circular import issues and provides several benefits:
- Prevents circular imports between your main app and router files
- Enables type safety - all parent app parameters become available in router route handlers
- Prevents accidental inclusion - the router can only be mounted on apps with the correct type
- Zero runtime overhead - type imports are removed during compilation
Configuration Options
const router = new Router({
base: Base<typeof app>(),
tags: ["users"], // OpenAPI tags for this router
parameters: { /* shared parameters */ }, // Parameters available to all routes
middleware: [ /* router middleware */ ], // Middleware for all routes in this router
defaultResponseClass: JSONResponse, // Default response type
})Router Parameters
Routers can define parameters and dependencies that are automatically available to all routes within that router:
import { Router, Base, Depends, Query } from "cerces"
import { z } from "zod"
const requireAuth = new Dependency({
parameters: { token: Header(z.string()) },
handle: ({ token }) => ({ userId: decodeToken(token) })
})
const apiRouter = new Router({
base: Base<typeof app>(),
parameters: {
// Available to ALL routes in this router
apiVersion: Query(z.string().default("v1")),
user: Depends(requireAuth)
}
})
// These routes automatically get apiVersion and user parameters
apiRouter.get("/profile", {
handle: ({ apiVersion, user }) => ({ version: apiVersion, profile: user })
})
apiRouter.get("/settings", {
handle: ({ apiVersion, user }) => {
return { version: apiVersion, settings: user.settings }
}
})Router Middleware
Apply middleware to all routes in a router:
import { createCORSMiddleware } from "cerces"
const apiRouter = new Router({
base: Base<typeof app>(),
middleware: [
createCORSMiddleware({ origin: ["https://myapp.com"] }),
loggingMiddleware
]
})Defining Routes
Routers support all the same route definition features as the main app:
const productRouter = new Router({
base: Base<typeof app>(),
tags: ["products"]
})
// All route types supported
productRouter.get("/products", { /* ... */ })
productRouter.post("/products", { /* ... */ })
productRouter.put("/products/{id}", { /* ... */ })
productRouter.delete("/products/{id}", { /* ... */ })
// Full feature support
productRouter.get("/search", {
parameters: {
q: Query(z.string()),
limit: Query(z.number().max(100).default(20))
},
responses: {
200: Responds(z.array(z.object({ id: z.number(), name: z.string() })))
},
handle: ({ q, limit }) => {
return searchProducts(q, limit)
}
})Including Routers
Use app.include() to mount routers at specific paths:
import { App } from "cerces"
import userRouter from "./routers/users.ts"
import productRouter from "./routers/products.ts"
import adminRouter from "./routers/admin.ts"
const app = new App({
// Global middleware and config
})
// Mount routers with path prefixes
app.include("/api/users", userRouter) // Routes: /api/users/*
app.include("/api/products", productRouter) // Routes: /api/products/*
app.include("/admin", adminRouter) // Routes: /admin/*
// You can still add routes directly to the main app
app.get("/health", {
handle: () => ({ status: "ok" })
})Path Parameter Type Safety
When a router declares parameters that include path parameters, the prefix path used in include() must also include those path parameters in curly braces. For example, if a router has a projectId path parameter, the prefix must be "/projects/{projectId}", otherwise TypeScript will throw a type error preventing inclusion.
// ✅ Correct: Router with projectId param requires {projectId} in prefix
const projectRouter = new Router({
base: Base<typeof app>(),
parameters: {
projectId: Path(z.string()) // Router declares projectId parameter
}
})
app.include("/projects/{projectId}", projectRouter) // ✅ Type-safe
// ❌ Wrong: Missing {projectId} would cause type error
// app.include("/projects", projectRouter) // ❌ TypeScript errorMounting at Root Level
Mount routers at the root path or mix with main app routes:
// Mount at root - router routes become top-level
app.include("/", apiRouter)
// Mix router and app routes
app.include("/api/v1", v1Router)
app.include("/api/v2", v2Router)
// Direct app routes alongside mounted routers
app.get("/status", { /* ... */ })Nested Routers
Create hierarchical route structures with nested routers:
// Parent router
const apiRouter = new Router({
base: Base<typeof app>(),
tags: ["api"]
})
// Child router
const userRouter = new Router({
base: Base<typeof apiRouter>(), // Base on parent router for type safety
tags: ["users"]
})
// Mount child router on parent
apiRouter.include("/users", userRouter)
// Final routes: /api/users/* (from apiRouter.include("/users", userRouter))Nested Router Types
For nested routers, use Base<typeof parentRouter> to ensure type safety. This makes all parameters from parent routers available to child router routes, maintaining the parameter inheritance chain.
Response Classes
Set default response types for all routes in a router:
const adminRouter = new Router({
base: Base<typeof app>(),
defaultResponseClass: JSONResponse, // All routes default to JSON
statusCode: 200 // Default status for implicit responses
})
// Override per route if needed
adminRouter.get("/dashboard", {
responseClass: HTMLResponse, // Override for specific route
handle: () => "<h1>Admin Dashboard</h1>"
})OpenAPI Documentation
Routers automatically contribute to your OpenAPI documentation:
Router Tags
Group related routes in the documentation:
const userRouter = new Router({
base: Base<typeof app>(),
tags: ["Users", "Authentication"] // Multiple tags supported
})Route Tags
Override or add tags per route:
userRouter.get("/profile", {
tags: ["Users", "Profile"], // Additional tags for this route
handle: () => ({ /* ... */ })
})Documentation Structure
The generated OpenAPI will show:
- Routes organized by tags
- Proper path prefixes from
app.include() - Parameter inheritance from router to routes
- Middleware and dependencies documented where applicable
Best Practices
1. Logical Grouping
Group routes by feature or domain:
// ✅ Good: Feature-based grouping
app.include("/users", userRouter)
app.include("/products", productRouter)
app.include("/orders", orderRouter)
// ❌ Avoid: Technical grouping
app.include("/api", apiRouter)
app.include("/web", webRouter)2. Consistent Naming
Use consistent router naming and path conventions:
// ✅ Consistent patterns
const userManagementRouter = new Router({ /* ... */ })
const productCatalogRouter = new Router({ /* ... */ })
app.include("/users", userManagementRouter)
app.include("/products", productCatalogRouter)3. Parameter Sharing
Use router parameters for common requirements:
const authenticatedRouter = new Router({
base: Base<typeof app>(),
parameters: {
user: Depends(requireAuth), // All routes get authenticated user
tenant: Depends(getTenant) // All routes get tenant context
}
})4. Middleware Scoping
Apply middleware at the appropriate level:
// App-level: Global concerns (CORS, logging, security headers)
const app = new App({
middleware: [corsMiddleware, securityHeaders, requestLogger]
})
// Router-level: Feature-specific concerns (auth, rate limiting)
const apiRouter = new Router({
base: Base<typeof app>(),
middleware: [authMiddleware, rateLimiter]
})This structure keeps your code organized, type-safe, and maintainable as your application grows.