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

  1. When defineRoutes(factory) is called, a collector (route collector) is created internally
  2. factory(collector) is executed, and app.get/post/... in the user code actually calls the collector method.
  3. Each route is pushed into the internal routes array
  4. Return the RouteDefinition object
  5. 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.

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

MethodDescription
app.get(path, ...)GET request
app.post(path, ...)POST request
app.put(path, ...)PUT request
app.patch(path, ...)PATCH request
app.delete(path, ...)DELETE request
app.head(path, ...)HEAD request
app.options(path, ...)OPTIONS request

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:

File pathURL prefixExample
src/routes/users.ts/usersapp.get('/list')GET /users/list
src/routes/api/orders.ts/api/ordersapp.post('/')POST /api/orders
src/routes/index.ts/app.get('/health')GET /health
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

LocationData SourceDescription
paramreq.paramsPath dynamic parameters (such as /:id)
queryreq.queryURL query parameters
headerreq.headersRequest headers
cookiereq.cookiesParsed cookie values
bodyreq.bodyRequest body

Verify execution order: paramqueryheadercookiebody

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

DSLDescriptionExamples
'string'Required stringname: 'string'
`'string:1-50''A string of length 1-50`name: 'string:1-50''
'string?'Optional stringnickname: 'string?'
'number'Required numberage: 'number'
'number:0-'A number greater than or equal to 0page: 'number:0-'
`'number:1-100''A number between 1 and 100limit: 'number:1-100'
'boolean'Required Boolean valueactive: 'boolean'
'email'Email formatemail: 'email'
'url'URL formatwebsite: 'url'
`'date''Date formatbirthday: 'date'
'uuid'UUID formatid: 'uuid'
'enum:a,b,c'Enumeration valuestatus: 'enum:active,inactive'
'array'arraytags: 'array'
'object'Objectmetadata: 'object'
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

Configure whitelist

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:

CodeHTTP statusMeaning
AUTH_REQUIRED401No authenticated identity is present
AUTH_INVALID401A credential was present but invalid
AUTH_FORBIDDEN403Authenticated identity failed role, scope, permission, or custom checks
AUTH_CONFIG_ERROR500The auth middleware or permission provider is misconfigured
AUTH_PROVIDER_ERROR500The auth provider or custom check threw unexpectedly

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:

ConfigurationDescription
cache: falseDisable response caching for this route
cache: 30000Enables response caching with a TTL of 30000 milliseconds
cache: { ttl: 30000 }Use full configuration object
headers: ["accept-language"]Specifies the request headers that participate in caching key; it is not recommended to include all request headers in key
partitionKeyGenerate user, tenant or region isolation dimensions to prevent different visitors from sharing the same cached response
allowCookieCacheAllow requests with a Cookie header to participate in cache; keep disabled unless the cookie input is part of a safe cache key

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

FieldTypeDefault ValueDescription
summarystringOne sentence summary of the interface
descriptionstringDetailed description of the interface (supports Markdown)
tagsstring[]IgnoredDeprecated. Operation tags are inferred automatically from the route path/source.
operationIdstringAutomatic inferenceOperation ID (globally unique; generation fails on conflicts)
hiddenbooleanfalseWhether to hide from the document
accessobject | stringDocs access metadata passed to openapi.docs.access.resolver; visible: false hides directly, and tryItOut: false disables Try it out
deprecatedbooleanfalseWhether to mark it as deprecated
securityarrayInference from auth / middlewaresSecurity scheme overrides
extensionsobjectCustom x-* extension fields
responsesobjectresponse definition

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:

method + pathinferred operationId
GET /usersgetUsers
POST /userscreateUsers
GET /users/:idgetUsersById
PUT /users/:idupdateUsersById
DELETE /users/:iddeleteUsersById

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:

  1. docs.security if explicitly set, including [].
  2. RouteOptions.auth when it is true or an object; auth: { required: false } without roles/scopes/permissions/check emits public security.
  3. 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 });
  },
);
SubfieldTypeDescription
enabledbooleanRoute-level parser switch. true opts in; false opts out; omitted follows global config
maxFileSizenumberPer-file byte limit for this route, overriding global multipart.maxFileSize
maxFilesnumberMaximum file count for this route, overriding global multipart.maxFiles
allowedMimeTypesstring[]MIME allowlist for this route, overriding global multipart.allowedMimeTypes
filesRecord<string, string | object>File field mapping; the string value is the description, and the object can be configured with more
files[].descriptionstringField description (for OpenAPI documentation)
files[].requiredbooleanWhether at least one file for this field is required at runtime (default false)

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,
);
FieldTypeDescription
rateLimitobject | falseRoute-level current limiting configuration, false is disabled
timeoutnumberDeprecated compatibility field; prefer top-level timeout
maxBodySizestring | numberMaximum request body size
corsVextCorsConfigRoute-level CORS configuration

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;
}
FieldTypeDescription
routesRouteRecord[]List of collected route records
sourceFilestringSource file path (injected by router-loader)
register()FunctionRegister the route with the underlying adapter

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:

prefixsubpathfinal path
/users/list/users/list
/users//users
/users/:id/users/:id
///
//health/health
/api/users``/api/users