Skip to main content
Anvil turns your directory structure into an HTTP API. Place a file named after an HTTP verb inside server/routes/ and Anvil registers the corresponding route — no import lists, no decorator registration, no central router config. The filesystem is your router.

The convention

Anvil recognizes these file names as HTTP verb handlers:
File nameHTTP method
get.tsGET
post.tsPOST
put.tsPUT
patch.tsPATCH
delete.tsDELETE
head.tsHEAD
options.tsOPTIONS
agent.tsPOST (streaming agent endpoint)
Every file must export a default function (the handler). Any other named export is metadata — meta, paramsSchema, etc.

Full routing tree example

server/routes/
  get.ts                      → GET /
  users/
    _middleware.ts            → scoped middleware for /users/**
    get.ts                    → GET /users
    post.ts                   → POST /users
    [id]/
      get.ts                  → GET /users/:id
  files/
    [...path]/
      get.ts                  → GET /files/* (catch-all)
  (admin)/                    → route group — not part of the URL
    dashboard/
      get.ts                  → GET /dashboard

Writing a handler

A handler is any function that accepts a Context and returns a value. Import Context from anvil for full TypeScript types:
server/routes/get.ts
import type { Context } from 'anvil';

export default function handler(ctx: Context) {
  return {
    name: 'my-api',
    version: '1.0.0',
  };
}
Handlers can be synchronous or async:
server/routes/users/post.ts
import { HttpError, type Context } from 'anvil';

export default async function handler(ctx: Context) {
  const body = await ctx.body<{ name?: string }>();
  if (typeof body !== 'object' || body === null || !body.name) {
    throw new HttpError(400, 'Expected JSON body with a "name" field');
  }
  return ctx.json({ created: { id: Date.now().toString(36), name: body.name } }, { status: 201 });
}

Auto-serialization

You don’t need to call ctx.json() for every response. Anvil normalizes whatever your handler returns:
Return valueResponse
Plain object or array200 application/json
string200 text/plain; charset=utf-8
null or undefined204 No Content
Response instancePassed through unchanged
This means the simplest possible handler is just a function that returns data:
server/routes/users/get.ts
export default function handler() {
  return { users: [{ id: '1', name: 'Alice' }] };
}

Route groups

Wrap a folder name in parentheses to create a route group. The group name is stripped from the URL — it exists only to organize your files or scope middleware.
server/routes/
  (admin)/
    dashboard/
      get.ts        → GET /dashboard   (not GET /(admin)/dashboard)
    settings/
      get.ts        → GET /settings
Route groups are useful for:
  • Applying a _middleware.ts to a subset of routes without affecting their URLs
  • Organizing large route trees into logical sections (e.g., (public)/, (authenticated)/)

The _middleware.ts convention

A file named _middleware.ts in any route folder runs for all routes under that folder. Files starting with _ are never treated as route handlers — they are reserved for middleware, context, and other conventions. See Scoped Middleware for the full guide.

The route manifest

Running anvil build generates a static route manifest at .gen/routes.ts. This file statically imports every handler and middleware, so your production bundle has no runtime filesystem scanning — startup is instant regardless of how many routes you have.
.gen/routes.ts (generated — do not edit)
// Generated by `anvil build`. Do not edit.
import type { Manifest, Middleware, RouteMeta } from 'anvil';
import * as r0 from '../server/routes/get.js';
import * as r1 from '../server/routes/users/get.js';
// ...

export const manifest: Manifest = {
  routes: [
    {
      method: 'GET',
      pattern: '/',
      segments: [],
      handler: r0.default,
      middleware: [],
      meta: metaOf(r0),
      file: '../server/routes/get.js',
    },
    // ...
  ],
};
Never edit .gen/routes.ts directly. It is regenerated every time you run anvil build or anvil dev detects a change.

Compile-time validation

Run anvil lint before deploying to catch routing problems at build time rather than at runtime:
  • Param schema keys must match the actual folder params — if your folder is [id]/ but your paramsSchema has { userId: z.string() }, lint fails.
  • MCP-exposed schemas must convert losslessly to JSON Schema (e.g., no z.transform() without a target type).
  • Case-insensitive collisionsUsers/ and users/ are the same on Windows but different on Linux; lint catches this so your app behaves the same on both.
anvil lint          # warns on issues
anvil lint --strict # exits non-zero on warnings too