Menu

Routing

A request reaches either a static file or a script. This page defines how the public router decides, the grammar of manifest routes (including patterns such as /posts/:slug), and how captured values reach your handler. The data-plane router lives in crates/http-server/src/data_plane.rs, the route validation in crates/core/src/manifest.rs.

Status. Implemented and unit-tested in the production data plane. microhost serve --dev (static development service) does not dispatch scripts at all. The deploy-time error names invalid_route_pattern, reserved_route_pattern and route_ambiguous that appear in the design documents are not produced by the code: invalid routes fail manifest validation with the messages listed below.

Precedence

For a tenant request the router goes through these steps and stops at the first that answers:

  1. Reserved and hidden paths. A first path segment _admin or _jobs (any letter case, also encoded or after dot-segment normalization) gets a generic 404. A path with any segment that starts with . or equals microhost.json gets a generic 404, even if a route would match. Malformed paths get 400.
  2. Exact explicit route: a manifest route without parameters whose path equals the request path.
  3. A real static file in public/ (/ and trailing-slash paths map to index.html).
  4. Patterned explicit route (a manifest route with :name or *name).
  5. Convention route: /api/<name> runs api/<name>.js.
  6. Not found: the 404 entry of errorPages if the manifest has one, otherwise public/404.html (GET and HEAD only), otherwise a generic 404. (Single-page-app fallback exists in the router but is not enabled for sites; see Manifest.)

Method rules:

So, a file public/api/hello shadows the convention route /api/hello, and an exact route to / beats public/index.html.

Every error the platform itself produces in these steps (404, 405, a failing script and so on) can be replaced by a page you configure: see Error pages. The page script gets the status in request.params.status.

Convention routes

/api/<name> runs api/<name>.js; nested paths work: /api/users/list runs api/users/list.js. The request path must have at least two segments, start with api, have no trailing slash, and not end in .js. No manifest is needed. A request to a path whose script does not exist answers 404. Convention routes are a runtime rule and have no request.params (the object is empty).

Route grammar

Explicit routes live in the manifest: entrypoints.routes in microhost.json, or routes with route(...) in microhost.config.js. Each has path, script and methods.

A path is a canonical absolute path:

{ "path": "/posts/:slug",     "script": "api/post.js",  "methods": ["GET"] }
{ "path": "/files/*rest",     "script": "api/files.js", "methods": ["GET", "HEAD"] }
{ "path": "/shop/:cat/:item", "script": "api/item.js",  "methods": ["GET"] }

Specificity

Several patterns can match the same path. The most specific wins, compared segment by segment from the left: at the first segment where two patterns differ, literal beats parameter beats catch-all. /posts/new (literal) beats /posts/:slug, which beats /posts/*rest. Two routes that overlap with equal specificity and share a method are ambiguous and are rejected at deploy. The same path (or overlapping patterns) may be declared twice when the method lists are disjoint, for example GET to one script and POST to another.

request.params

Captured values are in request.params, an immutable (frozen, null-prototype) object of strings:

// route /posts/:slug  ->  GET /posts/hello-world
export default async function handle(request, ctx) {
  const slug = request.params.slug;               // "hello-world"
  if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug)) return new Response("Not found", { status: 404 });
  // ...
}

Path handling, before any route

The router decodes the path once and rejects, with 400: malformed percent escapes, encoded or raw slashes inside a segment, backslashes, NUL and control bytes, . and .. segments, empty internal segments, segments over 255 bytes, more than 32 segments, a raw path over 4,096 bytes, and a request target over 8 KiB. Query strings are not part of routing; read them with new URL(request.url).search.

Validation errors

Manifest validation rejects the whole manifest. Messages you may see after the invalid manifest field: prefix (surfaced as invalid_manifest: ... by the deploy):

Message Cause
route path must start with '/', must not end with '/' Path shape.
route path contains a forbidden character ?, #, \, % or a control character.
route path has an invalid segment count More than 32 segments or an empty segment.
route path contains a dot segment . or ...
route path contains an invalid literal segment A character outside the literal alphabet (including a:b, a*b).
route parameter is invalid or repeated Bad name, more than 8, or a repeated name.
catch-all route parameter must be terminal *name not last.
route pattern must begin with a literal segment First segment is :x or *x.
route pattern uses the reserved first segment _admin as first segment of a public pattern.
ambiguous route patterns overlap for a shared method Equal-specificity overlap.
route needs at least one method, unsupported method Method list.

Examples

The blog example routes /posts, /posts/:slug and /api/posts to one script and branches on request.params.slug and the pathname. A catch-all is useful for a single dispatcher script:

// route "/app/*rest" -> api/app.js, methods ["GET", "POST"]
export default async function handle(request) {
  const parts = request.params.rest.split("/");
  switch (parts[0]) {
    case "items": return Response.json({ id: parts[1] ?? null });
    default: return new Response("Not found", { status: 404 });
  }
}