Route definition
This page details the route definition API of VextJS, including defineRoutes, routing options, parameter validation, middleware references and document configuration.
defineRoutes
defineRoutes is the core function for creating route files. It receives a factory callback in which the route is registered via the app object.
import { defineRoutes } from "vextjs";
export default defineRoutes((app) => {
app.get("/hello", async (req, res) => {
res.json({ message: "Hello World" });
});
});
Function signature
function defineRoutes(factory: RouteFactory): RouteDefinition;
type RouteFactory = (app: VextApp) => void;
Working principle
- When
defineRoutes(factory) is called, a collector (route collector) is created internally
factory(collector) is executed, and app.get/post/... in the user code actually calls the collector method.
- Each route is pushed into the internal
routes array
- Return the
RouteDefinition object
router-loader scans the src/routes/ directory and calls register() on the default export of each file to register with the underlying adapter
Tip
In the factory callback, app not only has HTTP methods (get/post/put/...), but also can access complete capabilities such as app.services, app.config, app.throw, app.logger, etc. These properties are injected by router-loader before executing the factory.
Route registration syntax
VextJS supports two route registration syntaxes: three-stage and two-stage.
Three-stage (recommended)
app.method(path, options, handler);
Complete syntax with options configuration, supporting parameter verification, middleware reference, document configuration, etc.:
export default defineRoutes((app) => {
app.post(
"/users",
{
validate: {
body: { name: "string:1-50", email: "email" },
},
middlewares: ["audit-log"],
docs: {
summary: "Create user",
},
},
async (req, res) => {
const data = req.valid("body");
const user = await app.services.user.create(data);
res.json(user, 201);
},
);
});
Two-stage
app.method(path, handler);
Simplified syntax without options, suitable for simple routes that do not require validation, middleware or document configuration:
export default defineRoutes((app) => {
app.get("/health", async (_req, res) => {
res.json({ status: "ok" });
});
});
Supported HTTP methods
Routing path
Static path
app.get("/users", handler);
app.get("/users/profile", handler);
Dynamic parameters
Use :paramName to define dynamic path parameters, accessed through req.params or req.valid('param'):
app.get(
"/users/:id",
{
validate: {
param: { id: "string:1-" },
},
},
async (req, res) => {
const { id } = req.valid("param");
const user = await app.services.user.findById(id);
res.json(user);
},
);
If a dynamic path reads from req.params without declaring validate.param, OpenAPI automatically adds a required: true string path parameter for :paramName or *paramName so the generated path template is valid. Declare validate.param when you need format constraints.
Wildcard
app.get("/files/*", async (req, res) => {
// req.params['*'] contains the wildcard matching part
res.json({ path: req.params["*"] });
});
File routing mapping
The directory path of the routing file is automatically mapped to the URL prefix:
Tip
The path registered in the routing file is a relative subpath, and the framework automatically splices the file path prefix. For example, app.get('/:id') in src/routes/users.ts is ultimately registered as GET /users/:id.
RouteOptions
The second parameter of the routing three-part syntax is the declarative configuration object.
interface RouteOptions {
validate?: {
query?: Record<string, VextSchemaField>;
body?: Record<string, VextSchemaField>;
param?: Record<string, VextSchemaField>;
header?: Record<string, VextSchemaField>;
cookie?: Record<string, VextSchemaField>;
};
cache?: false | number | RouteCacheOptions;
middlewares?: VextMiddlewareRef[];
docs?: RouteDocsConfig;
auth?: false | true | VextAuthRequirement;
csrf?: false;
securityHeaders?: false;
session?:
| boolean
| {
enabled?: boolean;
rolling?: boolean;
autoCommit?: boolean;
};
timeout?: number | false;
multipart?: {
files?: Record<
string,
string | { description?: string; required?: boolean }
>;
};
override?: {
rateLimit?: { max?: number; window?: number; keyBy?: string } | false;
/** @deprecated Use top-level timeout. */
timeout?: number;
maxBodySize?: string | number;
cors?: VextCorsConfig;
};
}
Complete example
import type { RouteOptions } from "vextjs";
function requireAuth(options: RouteOptions): RouteOptions {
return {
...options,
middlewares: ["auth"],
auth: { required: true, security: "bearerAuth" },
};
}
app.put(
"/users/:id",
requireAuth({
validate: {
param: { id: "string:1-" },
body: {
name: "string:1-50",
email: "email",
age: "number:0-200?",
},
},
cache: false,
docs: {
summary: "Update user",
responses: {
200: { description: "Update successful" },
404: { description: "User does not exist" },
},
},
override: {
rateLimit: { max: 10, window: 60 },
maxBodySize: "5mb",
},
}),
handler,
);
validate
Declarative parameter validation, based on schema-dsl DSL syntax. The framework automatically performs verification before the handler is executed. If verification fails, a 422 error is returned.
The field type is VextSchemaField, which supports schema-dsl strings, field-level DslBuilders, nested objects, and object arrays. Field-level DslBuilder is often used to add business descriptions to OpenAPI documents:
import { schemaAdapter } from "vextjs";
app.post(
"/translate",
{
validate: {
body: {
content: schemaAdapter
.compileField("string:1-20000!")
.description("Text to be translated, length 1-20000 characters"),
format: schemaAdapter
.compileField("enum:plain_text,preserve_line_breaks")
.description("output format"),
},
},
},
handler,
);
These descriptions will enter the OpenAPI schema while retaining constraints such as required, enumeration, and length.
Verify location
Verify execution order: param → query → header → cookie → body
Basic usage
app.get(
"/users",
{
validate: {
query: {
page: "number:1-", // A number greater than or equal to 1
limit: "number:1-100", // Number between 1 and 100
keyword: "string?", // optional string
},
},
},
async (req, res) => {
const { page, limit, keyword } = req.valid("query");
// page: number, limit: number, keyword: string | undefined
},
);
DSL syntax quick check
Tip
schema-dsl will automatically do type conversion. For example, '2' (string) in the query parameter ?page=2 will be automatically converted to 2 (number), provided that the schema is declared as 'number' type.
Get the verified data
Use req.valid(location) to obtain the verified and type-converted data:
app.post(
"/users",
{
validate: {
body: { name: "string:1-50", email: "email" },
query: { notify: "boolean?" },
},
},
async (req, res) => {
const body = req.valid("body"); // { name: string, email: string }
const query = req.valid("query"); // { notify?: boolean }
// ...
},
);
More precise type hints can be obtained via generics:
interface CreateUserBody {
name: string;
email: string;
}
const body = req.valid<CreateUserBody>("body");
// body.name → IDE knows it is string
// body.email → IDE knows it is string
Verification failure response
When verification fails, the framework automatically returns 422 status code:
{
"code": 422,
"message": "Validation failed",
"errors": [
{ "field": "email", "message": "must be a valid email address" },
{ "field": "name", "message": "length must be between 1 and 50" }
],
"requestId": "550e8400-e29b-41d4-a716-446655440000"
}
middlewares
Route-level middleware reference. The referenced middleware must first be declared in the config.middlewares whitelist.
String reference
app.get(
"/profile",
{
middlewares: ["audit-log"],
},
handler,
);
Object reference (with configuration override)
app.get(
"/admin/users",
{
middlewares: [
"audit-log",
{ name: "rate-limit", options: { window: 60_000, max: 30 } },
],
},
handler,
);
VextMiddlewareRef type
type VextMiddlewareRef = string | { name: string; options?: unknown };
Execution order
Routing-level middleware is executed after global middleware and before handler:
request → [global middleware chain] → [routing-level middleware] → [validate middleware] → handler → response
Middleware referenced in routes must be declared in the configuration file:
// src/config/default.ts
export default {
middlewares: [
{ name: "auth" },
{ name: "role", options: { required: "user" } },
{ name: "client-cache", options: { maxAge: 300 } },
],
};
// src/middlewares/auth.ts
import { defineMiddleware } from "vextjs";
export default defineMiddleware(async (req, _res, next) => {
const token = req.headers.authorization?.replace("Bearer ", "");
if (!token) {
req.app.throw(401, "Authentication token not provided");
}
//Verify token...
req.user = decoded;
await next();
});
Warning
References to middleware not declared in the whitelist will throw an error on startup:
[vextjs] Route GET "/profile" references middleware "auth" which is not
registered in config.middlewares whitelist.
auth
RouteOptions.auth is the route guard contract. It is separate from identity parsing:
auth() middleware reads the request credential and fills req.auth.
auth: true requires an authenticated request.
- Object form can require roles, scopes, permissions, or a custom
check.
auth: { required: false } makes identity optional; without roles, scopes, permissions, or check, OpenAPI marks the route as public.
auth: false marks the route as explicitly public and disables legacy OpenAPI security inference from middlewares.
// src/middlewares/auth.ts
import { auth, defineMiddleware } from "vextjs";
export default defineMiddleware(
auth({
provider: "app",
async verify(token) {
if (token !== "demo-token") return false;
return {
subject: "user:1",
userId: "1",
roles: ["admin"],
scopes: ["posts:write"],
can(action, resource) {
return action === "post:update" && resource === "post-1";
},
};
},
}),
);
// src/auth/route-guards.ts
import type { RouteOptions } from "vextjs";
export function requirePostUpdate(options: RouteOptions): RouteOptions {
return {
...options,
middlewares: ["auth"],
auth: {
roles: ["admin"],
scopes: ["posts:write"],
permissions: [
{ action: "post:update", resource: (req) => req.params.id },
],
mode: "all",
security: "bearerAuth",
},
};
}
app.post(
"/posts/:id",
requirePostUpdate({
docs: { summary: "Update post" },
}),
handler,
);
Use the raw auth object directly only for a one-off route or low-level API reference examples. In real applications, keep middleware names, security schemes, roles, scopes, and permission resources in local helpers.
Guard failures use stable error codes:
requestContext.getStore()?.auth stores only a safe snapshot of identity metadata. It intentionally excludes raw credentials and claims; use req.auth inside the route when provider claims are needed.
cache
Route-level response cache configuration. Response caching occurs on the server side and caches interface response content; it is not custom middleware, nor is it the browser Cache-Control response header.
import { route } from "vext";
route({
method: "GET",
path: "/posts",
cache: {
ttl: 30_000, // milliseconds
methods: ["GET"],
headers: ["accept-language"],
partitionKey: (req) => req.user?.tenantId ?? "public",
},
handler: async () => {
return await listPosts();
},
});
Commonly used writing methods:
See the Response Caching Guide for details.
docs
OpenAPI documentation configuration, controls how routes are displayed in automatically generated API documentation.
RouteDocsConfig
interface RouteDocsConfig {
summary?: string;
description?: string;
/** @deprecated ignored; operation tags are inferred automatically */
tags?: string[];
operationId?: string;
hidden?: boolean;
access?: VextRouteDocsAccessConfig | string;
deprecated?: boolean;
security?: Array<Record<string, string[]>>;
extensions?: Record<string, unknown>;
responses?: Record<string | number, ResponseConfig>;
}
Field description
docs.access is emitted on the OpenAPI operation as the x-vext-docs-access vendor extension and passed to openapi.docs.access.resolver as the access field of a kind: "operation" descriptor during Vext Docs filtering. String values are useful for role, tenant, or group labels; object values can carry roles, permissions, group, visible, and tryItOut metadata.
Complete example
app.post(
"/users",
{
validate: {
body: {
name: "string:1-50",
email: "email",
role: "enum:admin,user?",
},
},
middlewares: ["audit-log"],
docs: {
summary: "Create user",
description:
"Create a new user account and record the operation in the audit log.",
operationId: "createUser",
responses: {
201: {
description: "User created successfully",
schema: {
id: "string",
name: "string",
email: "email",
createdAt: "date",
},
example: {
id: "usr_abc123",
name: "Alice",
email: "alice@example.com",
createdAt: "2026-01-01T00:00:00Z",
},
},
422: { description: "Request parameter verification failed" },
409: { description: "Email has been registered" },
},
},
},
handler,
);
operationId automatically inferred
When operationId is not specified, the framework is automatically generated based on the HTTP method and path:
Explicit docs.operationId values and inferred operationId values share the same global uniqueness constraint. If a conflict exists, OpenAPI generation fails; set a unique docs.operationId on the conflicting route or change the route method/path so inferred values differ.
Hidden route
app.get(
"/internal/debug",
{
docs: { hidden: true },
},
handler,
);
Mark obsolete
app.get(
"/v1/users",
{
docs: {
deprecated: true,
description: "Deprecated, please use /v2/users",
},
},
handler,
);
Security solution coverage
By default, security schemes are inferred in this order:
docs.security if explicitly set, including [].
RouteOptions.auth when it is true or an object; auth: { required: false } without roles/scopes/permissions/check emits public security.
- Legacy
middlewares inference through config.openapi.guardSecurityMap.
auth:false disables the legacy fallback for that route. If auth: { required: false } also declares roles, scopes, permissions, or check, runtime still requires authentication and OpenAPI emits authentication security.
Can be manually overridden:
//Explicit declaration requires bearerAuth
app.get(
"/secure",
{
docs: {
security: [{ bearerAuth: [] }],
},
},
handler,
);
// Declare no authentication required (even if there are global security requirements)
app.get(
"/public",
{
docs: {
security: [],
},
},
handler,
);
Response definition
interface ResponseConfig {
description?: string;
schema?: Record<string, unknown> | string;
contentType?: string;
example?: unknown;
examples?: Record<
string,
{
summary?: string;
description?: string;
value: unknown;
}
>;
headers?: Record<
string,
{
description?: string;
schema?: { type: string };
}
>;
}
Multi-example response:
docs: {
responses: {
200: {
description: 'Query successful',
examples: {
admin: {
summary: 'Administrator user',
value: { id: '1', name: 'Admin', role: 'admin' },
},
normal: {
summary: 'Ordinary user',
value: { id: '2', name: 'User', role: 'user' },
},
},
},
},
}
Custom response header:
docs: {
responses: {
200: {
description: 'Success',
headers: {
'X-RateLimit-Remaining': {
description: 'Number of remaining requests',
schema: { type: 'integer' },
},
},
},
},
}
multipart
Route-level file upload configuration. multipart.files automatically outputs an OpenAPI multipart/form-data requestBody without manually writing docs.requestBody. Set multipart.enabled: true to opt one route into built-in parsing when global config.multipart.enabled is off; set multipart.enabled: false to opt one route out when global parsing is on.
app.post(
"/upload/avatar",
{
multipart: {
enabled: true,
files: {
avatar: { description: "Avatar image (JPEG/PNG)", required: true },
thumbnail: "optional thumbnail",
},
},
docs: { summary: "Upload avatar" },
},
async (req, res) => {
const file = req.files?.find((f) => f.fieldname === "avatar");
res.json({ filename: file?.filename, size: file?.size });
},
);
When a required file field is missing, Vext returns 400 with the missing field names. Optional fields and undeclared upload fields are accepted; they are still limited by maxFiles, maxFileSize, and allowedMimeTypes.
note
multipart.files and validate.body are mutually exclusive. When configured at the same time, multipart.files takes priority in OpenAPI document generation.
session
Controls Session for one route. false opts out of a globally enabled Session runtime. true opts in when the global runtime is disabled. The object form also overrides rolling and autoCommit; Store identity, cookie name, and session id length remain application-level settings.
app.get("/health", { session: false }, healthHandler);
app.post(
"/preview",
{ session: { enabled: true, rolling: true } },
previewHandler,
);
override
Route-level configuration override, overrides the global configuration in src/config/default.ts.
app.post(
"/upload",
{
timeout: 30000, // timeout 30 seconds
override: {
maxBodySize: "50mb", // Override global body size limit
rateLimit: { max: 5, window: 60 }, // Tighten the current limit
},
},
handler,
);
app.get(
"/public/data",
{
override: {
rateLimit: false, // Completely disable rate limiting
cors: {
origins: ["*"],
credentials: false,
},
},
},
handler,
);
Routes can set top-level { timeout: number } to enforce a positive request deadline in milliseconds and send HTTP 504 on timeout. Top-level { timeout: false } explicitly disables the route timeout middleware and takes precedence over the legacy override.timeout field.
Routes can also set top-level { securityHeaders: false } when an embeddable page, webhook callback, or fully custom response header stack must skip the global Security Headers preset.
RouteDefinition
The route definition object returned by defineRoutes() (internal data structure, usually does not need to be manipulated directly).
Factory and collector internals are not part of the public object shape and should only be driven through defineRoutes() and the router loader lifecycle.
interface RouteDefinition {
readonly routes: RouteRecord[];
sourceFile: string;
register(
adapter: VextAdapter,
prefix: string,
middlewareDefs: Map<string, VextMiddleware>,
globalMiddlewares: VextMiddleware[],
): void;
}
RouteRecord
Internal data structure of a single route:
interface RouteRecord {
method: string; // HTTP method (uppercase)
path: string; // relative subpath
options: RouteOptions; // Routing configuration
handler: VextHandler; //Route processing function
}
VextHandler
Type definition of route processing function:
type VextHandler = (
req: VextRequest,
res: VextResponse,
) => Promise<void> | void;
Handler is the last link in the middleware chain and does not call next().
Basic example
const handler: VextHandler = async (req, res) => {
const users = await app.services.user.findAll();
res.json(users);
};
Access App Capabilities
In the factory callback of defineRoutes, access app through the closure:
export default defineRoutes((app) => {
app.get("/users/:id", async (req, res) => {
const { id } = req.params;
const user = await app.services.user.findById(id);
if (!user) {
app.throw(404, "User does not exist");
}
app.logger.info({ userId: id }, "Query user successfully");
res.json(user);
});
});
If you want to actively return clear HTTP errors such as 404, 401, 409, etc., you should use app.throw(...) first. The normal throw new Error("...") will also be caught by the framework, but it represents an unknown runtime exception and will eventually go down the 500 error path; field-level validation failures should use VextValidationError.
Multiple route registration
Multiple routes can be registered in a routing file:
// src/routes/users.ts
import { defineRoutes, type RouteOptions } from "vextjs";
function requireAuth(options: RouteOptions): RouteOptions {
return {
...options,
middlewares: ["auth"],
auth: { required: true, security: "bearerAuth" },
};
}
export default defineRoutes((app) => {
// GET /users/list
app.get(
"/list",
{
validate: {
query: { page: "number:1-", limit: "number:1-100" },
},
docs: { summary: "User List" },
},
async (req, res) => {
const { page, limit } = req.valid("query");
const result = await app.services.user.findAll({ page, limit });
res.json(result);
},
);
// GET /users/:id
app.get(
"/:id",
{
validate: {
param: { id: "string:1-" },
},
docs: { summary: "Get user details" },
},
async (req, res) => {
const { id } = req.valid("param");
const user = await app.services.user.findById(id);
if (!user) app.throw(404, "User does not exist");
res.json(user);
},
);
// POST /users
app.post(
"/",
requireAuth({
validate: {
body: { name: "string:1-50", email: "email" },
},
docs: { summary: "Create user" },
}),
async (req, res) => {
const data = req.valid("body");
const user = await app.services.user.create(data);
res.json(user, 201);
},
);
// PUT /users/:id
app.put(
"/:id",
requireAuth({
validate: {
param: { id: "string:1-" },
body: { name: "string:1-50?", email: "email?" },
},
docs: { summary: "Update user" },
}),
async (req, res) => {
const { id } = req.valid("param");
const data = req.valid("body");
const user = await app.services.user.update(id, data);
res.json(user);
},
);
// DELETE /users/:id
app.delete(
"/:id",
requireAuth({
validate: {
param: { id: "string:1-" },
},
docs: { summary: "Delete user" },
}),
async (req, res) => {
const { id } = req.valid("param");
await app.services.user.delete(id);
res.status(204).json(null);
},
);
});
Notes
Do not call HTTP methods directly on the app
The app returned by defineRoutes is a collector, not a real application instance. Calling the HTTP method directly on the application instance throws an error:
// ❌ Incorrect usage
import { createApp } from "vextjs";
const { app } = createApp(config);
app.get("/hello", handler); // Throw an error!
// ✅ Correct usage
import { defineRoutes } from "vextjs";
export default defineRoutes((app) => {
app.get("/hello", handler); // OK
});
The routing file must be default export
// ✅ Correct
export default defineRoutes((app) => { ... });
// ❌ Error — router-loader not recognized
export const routes = defineRoutes((app) => { ... });
Routing path normalization
The framework automatically handles the following path edge cases: