Routing & Endpoints
ExisJS uses file-system routing to map directory structures directly to HTTP endpoints. Dropping a route.ts or <name>.route.ts file inside a directory registers endpoints automatically with the native routing engine.
Routing Principles
- Folder-Based URL Mapping: The folder structure relative to
src/http/determines the base URL path. - Dual-Paradigm Support: Routes can be authored using either the Functional Paradigm (
controller(),route.*) or the Class-Based (OOP) Paradigm (@Controller(),@Get(),@Post()). Choose one paradigm per project. - Automatic Parameter Inference: Dynamic URL segments defined in path strings (such as
/:idor/:postId) or folder names ([id]) automatically populate typed route parameters in handler contexts.
File-System Route Conventions
| Directory Path | URL Endpoint | Description |
|---|---|---|
src/http/route.ts | / | Root application endpoint |
src/http/health/route.ts | /health | Static route endpoint |
src/http/users/route.ts | /users | Collection endpoint |
src/http/users/[id]/route.ts | /users/:id | Dynamic parameter route |
src/http/docs/[...slug]/route.ts | /docs/*slug | Catch-all wildcard route |
src/http/(admin)/dashboard/route.ts | /dashboard | Route group (parentheses are omitted from the URL path) |
Route Definitions
Basic Route Handlers
Functional Paradigm
import { controller, route } from 'exisjs/router'
export default controller({ // GET /users listUsers: route.get('/', { summary: 'List users', handle: async () => { return [ { id: '1', name: 'Alice' }, { id: '2', name: 'Bob' }, ] }, }),
// POST /users createUser: route.post('/', { summary: 'Create user', handle: async ({ body }) => { return { id: '3', ...body } }, }),})Class-Based (OOP) Paradigm
import { Controller, Get, Post, Body, ApiOperation } from 'exisjs/decorators'
@Controller()export default class UsersController { @Get('/') @ApiOperation({ summary: 'List users' }) async listUsers() { return [ { id: '1', name: 'Alice' }, { id: '2', name: 'Bob' }, ] }
@Post('/') @ApiOperation({ summary: 'Create user' }) async createUser(@Body() body: Record<string, any>) { return { id: '3', ...body } }}Supported HTTP Methods
ExisJS supports all standard HTTP methods across both paradigms:
| HTTP Verb | Functional Method | OOP Decorator | Description |
|---|---|---|---|
GET | route.get(path, config) | @Get(path, schema) | Retrieve resources |
POST | route.post(path, config) | @Post(path, schema) | Create resources or trigger actions |
PUT | route.put(path, config) | @Put(path, schema) | Replace existing resources |
PATCH | route.patch(path, config) | @Patch(path, schema) | Apply partial updates |
DELETE | route.delete(path, config) | @Delete(path, schema) | Remove resources |
OPTIONS | route.options(path, config) | @Options(path, schema) | Describe communication options / preflight |
HEAD | route.head(path, config) | @Head(path, schema) | Retrieve headers identical to a GET request |
CONNECT | route.connect(path, config) | @Connect(path, schema) | Establish a two-way tunnel |
TRACE | route.trace(path, config) | @Trace(path, schema) | Loop-back testing along network path |
QUERY | route.query(path, config) | @Query(path, schema) | Safe query method with request payload (RFC 9535) |
ALL | route.all(path, config) | @All(path, schema) | Matches any HTTP method for the given path |
Dynamic Route Parameters
Folder-Based Parameters ([param])
Creating a folder named [id] creates a parameterized route segment.
Functional Paradigm
import { controller, route } from 'exisjs/router'import { tex } from 'exisjs/validator'
const UserParamsSchema = tex.object({ id: tex.string({ min: 1 }),})
export default controller({ getUser: route.get('/', { params: UserParamsSchema, handle: async ({ params }) => { return { id: params.id, name: 'Alice' } }, }),})Class-Based (OOP) Paradigm
import { Controller, Get, Param, ApiOperation } from 'exisjs/decorators'import { tex } from 'exisjs/validator'
const UserParamsSchema = tex.object({ id: tex.string({ min: 1 }),})
@Controller()export default class SingleUserController { @Get('/', { params: UserParamsSchema }) @ApiOperation({ summary: 'Get user by ID' }) async getUser(@Param('id') id: string) { return { id, name: 'Alice' } }}In-File Sub-Paths
You can also define multiple sub-paths with route parameters inside a single route.ts file:
import { controller, route } from 'exisjs/router'import { tex } from 'exisjs/validator'
export default controller({ // GET /users list: route.get('/', { handle: async () => [{ id: '1', name: 'Alice' }], }),
// GET /users/:id getById: route.get('/:id', { params: tex.object({ id: tex.string() }), handle: async ({ params }) => ({ id: params.id, name: 'Alice' }), }),
// GET /users/:id/posts/:postId getUserPost: route.get('/:id/posts/:postId', { params: tex.object({ id: tex.string(), postId: tex.string(), }), handle: async ({ params }) => { return { userId: params.id, postId: params.postId, title: 'Hello ExisJS' } }, }),})Catch-All Routes ([...slug])
Folders named [...slug] match all subsequent nested URL paths.
import { controller, route } from 'exisjs/router'
export default controller({ renderDoc: route.get('/', { handle: async ({ params }) => { // For a request to /docs/getting-started/installation // params.slug contains "getting-started/installation" return { slug: params.slug } }, }),})Route Groups ((group))
Parenthesized directory names group routes logically without appending the folder name to the URL path:
src/http/├── (auth)/│ ├── login/│ │ └── route.ts # Maps to /login (not /auth/login)│ └── register/│ └── route.ts # Maps to /register (not /auth/register)└── (marketing)/ └── pricing/ └── route.ts # Maps to /pricing (not /marketing/pricing)Handler Execution Context (SuperContext)
In the functional paradigm, handle receives a single context object containing all request properties and utilities:
export default controller({ endpoint: route.post('/example', { handle: async (ctx) => { const { body, query, params, headers, req, res, app, resolve, file } = ctx // ... }, }),})Context Properties
| Property | Type | Description |
|---|---|---|
ctx.body | TBody | Parsed and validated request body payload |
ctx.query | TQuery | Parsed and validated URL query parameters |
ctx.params | TParams | Extracted route parameters |
ctx.headers | Record<string, string> | Request headers map |
ctx.req | Request | The underlying ExisRequest instance |
ctx.res | Response | The underlying ExisResponse instance |
ctx.resolve | (token) => T | Resolves a dependency from the IoC container |
ctx.file | ExisFile | Uploaded file instance (when multipart upload is enabled) |
ctx.files | ExisFile[] | Uploaded files array (for multiple file uploads) |
ctx.state | Record<string, any> | Request-scoped custom state |
Declarative Route Configuration Options
Each route handler accepts an options object supporting validation, security policies, file uploads, and timeouts:
1. Request Validation Schemas
import { controller, route } from 'exisjs/router'import { tex } from 'exisjs/validator'
const ProductQuerySchema = tex.object({ page: tex.number({ coerce: true, default: 1 }), limit: tex.number({ coerce: true, default: 20 }),})
const ProductBodySchema = tex.object({ name: tex.string({ min: 2 }), price: tex.number({ min: 0 }),})
export default controller({ create: route.post('/', { query: ProductQuerySchema, body: ProductBodySchema, handle: async ({ query, body }) => { return { page: query.page, product: body } }, }),})2. Multipart File Uploads
import { controller, route } from 'exisjs/router'
export default controller({ uploadFile: route.post('/', { upload: { field: 'file', maxBytes: 5 * 1024 * 1024, // 5MB limit mimeTypes: ['image/png', 'image/jpeg', 'application/pdf'], }, handle: async ({ file }) => { return { filename: file?.filename, size: file?.size, mimeType: file?.mimeType, } }, }),})3. Route-Specific Timeout Overrides
You can override default timeouts for long-running endpoints:
import { controller, route } from 'exisjs/router'
export default controller({ generateReport: route.post('/annual', { timeout: 120000, // 120 seconds in milliseconds handle: async () => { // Perform long-running export return { status: 'completed' } }, }),})4. Route-Specific CORS Overrides
Route-level CORS configuration takes precedence over boundary and global settings:
import { controller, route } from 'exisjs/router'
export default controller({ stripeWebhook: route.post('/', { cors: { origin: 'https://hooks.stripe.com', methods: ['POST'], }, handle: async ({ body }) => { return { received: true } }, }),})Reusing Route Types in Services
InferRouteInput and InferRouteOutput derive types from a route definition, so service methods do not need a second copy of the request shape:
import { route, type InferRouteInput, type InferRouteOutput } from 'exisjs/router'import { CreateUserSchema } from './schema'import { UserService } from './service'
export const createUser = route.post('/', { body: CreateUserSchema, async handle({ body, resolve }) { return resolve(UserService).create(body) },})
// { body, query, params } as the handler receives them, after validationexport type CreateUserInput = InferRouteInput<typeof createUser>['body']// What the handler resolves toexport type CreateUserResult = InferRouteOutput<typeof createUser>Dependency Resolution in Functional Routes
Functional routes can resolve dependencies directly from the IoC container using ctx.resolve():
import { controller, route } from 'exisjs/router'import { UserService } from './service'
export default controller({ list: route.get('/', { handle: async ({ resolve }) => { const userService = resolve(UserService) return userService.findAll() }, }),})Next Steps
- Controllers & Decorators: Complete guide to class-based routing and parameter decorators.
- Request Object: Deep dive into the
ExisRequestAPI. - Response Object: Status codes, cookie management, streaming, and Server-Sent Events.
- Boundaries: Scoped middleware cascading, directory guards, and error boundaries.