Route definition
This page details the route definition API of VextJS, including defineRoutes, routing options, parameter validation, middleware references and document configuration.
Use this page to look up contracts and limits; follow the Routing guide for a complete workflow. The project must provide handlers, business services, and middleware mentioned in fragments. HTTP registrations belong inside the factory. See HTTP and routing specification for normative constraints.
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.
Function signature
The route factory must be synchronous: do not mark it async and do not
return a Promise. Individual route handlers may still be async. This keeps
runtime registration, build indexing, Doctor, and type generation on the same
statically projectable route set.
Working principle
- During module evaluation,
defineRoutes(factory)checks the synchronous function and registration syntax, then returns aRouteDefinition. It has not executed the factory yet, soroutesis empty. - The loader reads the default export, supplies source information, and executes the factory with a facade backed by the real app.
- HTTP methods on that facade collect routes; services, config, logger, and other capabilities forward to the real app. The HTTP collection entry closes when the factory finishes, and collection from a failing factory is cleared.
- The loader validates route identity, middleware references, and configuration, prepares the request chain, then registers routes through the adapter. Application code does not call
register().
defineRoutes returns a route definition, not the app. Its factory parameter is a facade, not a snapshot of app properties. Inline synchronous arrow functions or function expressions work, as do bindings that can be statically resolved to such functions.
Arguments, return value, and failure boundary
Route registration syntax
VextJS supports two route registration syntaxes: three-stage and two-stage.
Three-stage (recommended)
Complete syntax with options configuration, supporting parameter verification, middleware reference, document configuration, etc.:
Two-stage
Simplified syntax without options, suitable for simple routes that do not require validation, middleware or document configuration:
Supported HTTP methods
Routing path
Static path
Dynamic parameters
Use :paramName to define dynamic path parameters, accessed through req.params or req.valid('param'):
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
File routing mapping
The directory path of the routing file is automatically mapped to the URL prefix:
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.
Fields and omission behavior
Frontend freshness
RouteOptions.frontend keeps page freshness on the existing route declaration:
staticParams is valid only for "static". revalidate is valid only for
"revalidate" and is a positive interval in seconds. clientOnly keeps the
route document/data/assets while intentionally skipping the server page body;
it is not PPR or a second page route.
For static generation, explicitly set frontend.page. The builder passes { params } from staticParams directly to the page; it does not run route handlers, authentication, or service queries as if handling a business request. Keep pages requiring handler-prepared data on dynamic SSR. See Rendering modes.
hydration: "none" does the opposite of clientOnly: it requires and keeps
the SSR page body but removes the Vext/React browser runtime, hydration data,
and route JS preload. It cannot be combined with clientOnly or disabled SSR.
seo is static, JSON-safe route metadata and is merged before per-render SEO.
Static projection boundary
Build-indexed paths and route metadata use a finite static grammar so the build
index and runtime cannot diverge. The index accepts literals, same-file const
bindings, imports resolvable to source modules, and TypeScript as const / simple as Type / satisfies wrappers.
A route-options helper call is rejected because the index does not execute the
helper body and cannot know whether it adds, removes, or replaces contract
fields. Inline the helper's final object or store that final object in a
same-file const. Comments, strings, template text, and regular expressions
are ignored during structural matching.
Each app.get(...) / app.post(...) registration must be a direct top-level
statement in the defineRoutes callback. Conditional or nested registration
fails the static projection because the build index cannot guarantee whether
runtime control flow executes it.
The index follows resolvable source imports and re-exports without executing user helpers or arbitrary runtime module code. Opaque imported values, computed expressions, and template literals with
interpolation are not executed. If a route path, validate location, or
response schema cannot be projected, build/doctor/typegen fails with file,
HTTP method, and route context instead of silently omitting the route or
emitting an empty contract. Use res.render(..., { seo }) for
request-dependent metadata. See
SEO, Sitemap, and Robots.
Complete example
The following is a composition fragment inside a defineRoutes factory. Supply the auth middleware, its declaration, and the business handler in your project.
validate
Declarative parameter validation, based on schema-dsl DSL syntax. The
framework validates before the handler runs. An invalid param (path
parameter) returns HTTP 400; invalid query, header, cookie, or body
data returns HTTP 422.
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:
The static projector recognizes only schemaAdapter imported by name from
vextjs (an alias is allowed), compileField(<static string>), and at most one
.description(<static string>). The complete builder may be stored in an
unambiguous same-file const. Imported builders, dynamic arguments, other call
chains, and opaque Zod/Yup objects fail the build instead of producing a partial
request contract.
These descriptions will enter the OpenAPI schema while retaining constraints such as required, enumeration, and length.
Validation locations
Verify execution order: param → query → header → cookie → body
Basic usage
DSL syntax quick check
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.
Read validated data
Use req.valid(location) to obtain validated and type-converted data:
The handler type is inferred from the route schema without a duplicate interface:
An explicit generic remains available only as an escape hatch for dynamic or external schemas and overrides the inferred contract.
Validation failure response
For query, header, cookie, or body, validation failure returns HTTP
422 with a structured response such as the following. A validate.param
failure uses the same error shape with HTTP/code 400 because the URL path is
invalid.
middlewares
Route-level middleware reference. The referenced middleware must first be declared in the config.middlewares whitelist.
String reference
Object reference (with configuration override)
VextMiddlewareRef type
Execution order
Routing-level middleware is executed after global middleware and before handler:
Configure whitelist
Middleware referenced in routes must be declared in the configuration file:
References to middleware not declared in the whitelist will throw an error on startup:
auth
RouteOptions.auth is the route guard contract. It is separate from identity parsing:
auth()middleware reads the request credential and fillsreq.auth.auth: truerequires an authenticated request.- Object form can require roles, scopes, permissions, or a custom
check. auth: { required: false }makes identity optional; without roles, scopes, permissions, orcheck, OpenAPI marks the route as public.auth: falsemarks the route as explicitly public and disables legacy OpenAPI security inference frommiddlewares.
VextAuthRequirement
required defaults to true. Omitted or empty roles, scopes, and permissions add no check for that group. mode defaults to "any" within each group; different declared groups must all pass. Then check(req, auth) runs: false denies, and an exception follows the provider-error path. A permission string is an action; an object may also supply resource and context.
With required: false and no further authorization rule, anonymous requests are allowed, but an invalid credential recorded as req.auth.error is still rejected. The guard runs before automatic route validation, so check cannot assume req.valid() has data. security affects documentation only, not these runtime checks.
The fixed demo-token below only illustrates the contract; a real application must verify credentials. Declare the middleware and its allowlist, then register the route inside a factory.
The build index accepts the final inline object or a same-file const such as updatePostOptions. It rejects route-options helper calls because it does not execute helper bodies. Keep each route's complete guard contract in one of these statically projectable forms; shared runtime authorization logic still belongs in middleware or the permission provider.
Inside src/routes/posts.ts, the file prefix /posts and subpath /:id make POST /posts/:id. The permission resource is an application convention; the framework does not derive it from the URL. Update both the auth provider and route declaration if you change that string.
Runtime auth, OpenAPI security, and Docs access
These are related but independent layers:
auth.roles,auth.scopes,auth.permissions, andauth.checkare runtime route guards. They decide whether the current request reaches the handler.auth.securityis OpenAPI metadata. It selects the documented security scheme, and an object array can declare OAuth scopes such as[{ oauth2: ["posts:write"] }]; it does not grant or enforce that scope.docs.securityonly overrides the generated OpenAPI security metadata. It does not disable a runtimeauthrequirement.docs.accessis Vext Docs visibility/Try it out metadata sent toopenapi.docs.access.resolver. It does not protect the route; useauthfor API access control.
It is valid for an application to use the same string in a runtime scope and an OAuth scope, but they remain separate declarations. Keep both explicit when both are required.
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 caching is configured with RouteOptions.cache. It occurs on the server and is distinct from the browser Cache-Control header.
With response caching enabled, repeated GET /cache-demo reuses the response within the TTL. A different query, vary header, or partition produces a different entry.
If object ttl is missing or zero, runtime tries a positive global default TTL; a negative value disables this route's cache. Use cache: false or numeric cache: 0 to disable, not { ttl: 0 }. Typed configuration should supply an explicit positive TTL.
config.cache.enabled: false disables route caching. Authentication and authorization run before partitioned cache lookup. Authorization requests bypass by default unless a nonempty partition or explicit allowance is present. The current Cookie default only prevents writing origin responses; an existing public cache entry may still be read. To exclude Cookie requests entirely, use condition: (req) => req.headers.cookie === undefined or disable caching.
See the Response Caching Guide for details.
responses — runtime response schema
Declare this map at top-level RouteOptions.responses. Selectors support exact status (201), family (2xx), or default. The final status after response:before chooses exact → family → default. Vext compiles each JSON schema once during route registration and reuses it. The same closed schema projects into OpenAPI, route manifests, static build indexing, and generated client types.
Schemas describe business data passed to res.json(), without duplicating the response envelope. Undeclared properties are removed recursively; missing required values fail before committing bytes. HEAD, exact 204, raw JSON, text, redirect, file/download, stream, and render/SSR bypass this serializer. See OpenAPI response contracts.
docs
OpenAPI documentation configuration, controls how routes are displayed in automatically generated API documentation.
RouteDocsConfig
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. This is documentation access metadata only: hiding an operation or disabling Try it out does not add authentication or authorization to the route.
Complete example
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
Mark obsolete
Security solution coverage
By default, security schemes are inferred in this order:
docs.securityif explicitly set, including[].RouteOptions.authwhen it istrueor an object;auth: { required: false }without roles/scopes/permissions/check emits public security.- Legacy
middlewaresinference throughconfig.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:
Documented response metadata
Keep descriptions, examples, headers, and content type in docs.responses.
Do not repeat schema there when the same normalized selector already exists
in top-level responses; registration fails on this dual declaration.
Multi-example response:
Custom response header:
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. Built-in parsing is memory-only: it creates no framework-managed temporary files, so there is no tmp directory, file TTL, or periodic cleanup setting. Use a streaming upload plugin for large files or persistent storage.
For multipart requests using the built-in parser, a missing required file field returns 400 with its name. Optional and undeclared upload fields are accepted subject to maxFiles, maxFileSize, and allowedMimeTypes. Non-multipart requests skip those file checks; if the endpoint requires a file, also inspect req.files in the handler.
Built-in multipart parsing puts files in req.files, but does not put ordinary text form fields in req.body. With both multipart.files and validate.body, OpenAPI describes multipart first, yet runtime validation still checks the current req.body. A required body field therefore fails with 422 even when a form submitted a same-named text field through only the built-in parser. For mixed files and text, use a custom parser that fills req.body and coordinates body reading, or send text in a separate JSON request. See Request files and form fields and Custom upload ownership.
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.
bodyParser
bodyParser?: VextBodyParserConfig overrides the global body-parser settings for a route. An already installed body parser consumes it; this option does not install one when global parsing is disabled.
Once a bodyParser object is declared, it takes precedence over legacy override.maxBodySize; an omitted size in that object falls back to the global value. Without that object, override.maxBodySize is read. After disabling built-in parsing, handlers must not assume req.body is parsed. See the Configuration guide.
csrf
csrf?: false is a skip switch only. When omitted, the global CSRF setting applies. It does not enable CSRF or establish an authenticated identity. Decide whether Cookie/Session routes may skip it based on how they are called; see Cookies and Session.
override
Route-level override. override.rateLimit adjusts an already enabled global limiter; it does not enable rate limiting. Its window is in seconds, while timeout is in milliseconds.
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.
RouteRecord
Internal data structure of a single route:
VextHandler
Type definition of route processing function:
The handler is the last link in the middleware chain and does not call next(). Three-part routes infer validated-data types from options.validate. An explicit generic changes TypeScript typing only; it does not add runtime validation.
Basic example
Access App Capabilities
In the factory callback of defineRoutes, access app through the closure:
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
A factory can declare multiple HTTP methods and subpaths. Each registration is a direct top-level statement in its block. Different methods may share a path; the same normalized method and path cannot be registered twice. For a complete business example, follow the Routing guide. First-time readers can run that guide's dependency-free introductory example.
Notes
Do not call HTTP methods directly on the app
defineRoutes returns a RouteDefinition; the factory parameter is an app facade with a closable HTTP collection entry. HTTP methods on the root app are placeholders and throw if called directly:
The routing file must be default export
Build-time consumers must resolve the default export to defineRoutes imported by name from vextjs (an alias is allowed). A named export alone is not a route entry; property/namespace calls and opaque helpers do not meet this identity rule. Prefer an inline synchronous arrow or function expression in a direct default export. A synchronous block-body factory may also be bound first:
Creating a definition first and using export { routeDefinition as default } is also supported. Re-exports work when fully resolvable: for example, src/routes/account.ts can use export { default } from "../features/account.js"; /account remains the route prefix while the referenced module defines it. The target must be in analyzable source and its exports and bindings resolvable; arbitrary dynamic imports are not supported.
Expression-body (app) => app.get(...), async factories, registration inside conditions/loops/nested helpers, computed or extracted HTTP methods, and non-undefined factory returns are unsupported.
Routing path normalization
The framework automatically handles the following path edge cases:
The static index also checks entry prefixes: users.ts and users/index.ts cannot both be route entries. A normalized method/path pair cannot repeat, including case and trailing-slash variants.
Related specification
- HTTP and routing specification: Rule IDs for route modules, factories, validation, and middleware.