Skip to main content
Dynamic routes let you capture variable segments of a URL path without defining a separate file for every possible value. Anvil uses a folder-naming convention borrowed from Next.js: square brackets for a named parameter, three dots inside brackets for a catch-all.

Named parameters: [param]

Wrap a folder name in square brackets to make it a dynamic segment. The captured value is available on ctx.params under the same name as the folder.
server/routes/
  users/
    [id]/
      get.ts        → GET /users/:id
                       ctx.params.id = "42" for GET /users/42
Read the captured value in your handler:
server/routes/users/[id]/get.ts
import { HttpError, type Context } from 'anvil';

const USERS = [
  { id: '1', name: 'Alice' },
  { id: '2', name: 'Bob' },
];

export default function handler(ctx: Context) {
  const user = USERS.find((u) => u.id === ctx.params.id);
  if (!user) throw new HttpError(404, `No user with id "${ctx.params.id}"`);
  return user;
}
You can nest multiple dynamic segments. Each one contributes its own key to ctx.params:
server/routes/
  orgs/
    [orgId]/
      repos/
        [repoId]/
          get.ts    → GET /orgs/:orgId/repos/:repoId
server/routes/orgs/[orgId]/repos/[repoId]/get.ts
import type { Context } from 'anvil';

export default function handler(ctx: Context) {
  const { orgId, repoId } = ctx.params;
  return { orgId, repoId };
}

Catch-all segments: [...param]

Prefix with ... to capture the rest of the path as a single string. Everything after the catch-all folder’s parent is joined with / and stored in ctx.params under the param name.
server/routes/
  files/
    [...path]/
      get.ts        → GET /files/*
                       ctx.params.path = "a/b/c" for GET /files/a/b/c
server/routes/files/[...path]/get.ts
import type { Context } from 'anvil';

export default function handler(ctx: Context) {
  // ctx.params.path is the full remaining path string
  const filePath = ctx.params.path; // e.g. "images/2024/photo.png"
  return { requestedPath: filePath };
}
A catch-all directory cannot contain subdirectories. The scanner enforces this at build time: placing a folder inside [...path]/ throws a RouteConflictError.

Router precedence

When multiple route patterns could match the same URL, Anvil resolves the conflict deterministically — insertion order never matters:
  1. Static segments win over dynamic ones (/users/me beats /users/[id] for the path /users/me)
  2. Named parameters win over catch-alls
  3. Backtracking ensures /a/[b]/c still matches when /a/x exists as a static sibling

Validating params with paramsSchema

Export a paramsSchema Zod object from any route file to enable compile-time validation and MCP tool generation. anvil lint checks that every key in the schema corresponds to a real folder param in the path.
server/routes/users/[id]/get.ts
import { HttpError, type Context } from 'anvil';
import { z } from 'zod';

// Tells `anvil lint` to verify `id` matches the [id] folder,
// and tells `anvil mcp` how to describe the tool's input.
export const meta = { mcp: { expose: true, description: 'Fetch a user by ID' } };
export const paramsSchema = z.object({ id: z.string() });

export default function handler(ctx: Context) {
  // ctx.params.id is already validated — safe to use directly
  const user = USERS.find((u) => u.id === ctx.params.id);
  if (!user) throw new HttpError(404, `No user with id "${ctx.params.id}"`);
  return user;
}
paramsSchema is a compile-time and MCP-exposure hint. At runtime, ctx.params values are always plain strings — they are not coerced or parsed by the schema. Use the schema keys as a contract that anvil lint enforces.

Naming rules

Anvil enforces several rules to prevent subtle bugs and cross-platform inconsistencies:

No duplicate param names

The same param name cannot appear twice in one route path. /[id]/items/[id]/get.ts throws a RouteConflictError at scan time.

No conflicting names at the same position

Two routes cannot use different param names at the same depth. /users/[id]/get.ts and /users/[slug]/post.ts conflict — use the same name ([id]) across all files in that folder.

Case-insensitive collision detection

anvil lint rejects Users/ and users/ in the same parent because Windows treats them as the same directory while Linux does not.

Valid JavaScript identifiers only

Param names must match /^[A-Za-z_$][A-Za-z0-9_$]*$/. [123] and [my-param] are rejected at scan time.