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 namesinvalid_route_pattern,reserved_route_patternandroute_ambiguousthat 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:
- Reserved and hidden paths. A first path segment
_adminor_jobs(any letter case, also encoded or after dot-segment normalization) gets a generic 404. A path with any segment that starts with.or equalsmicrohost.jsongets a generic 404, even if a route would match. Malformed paths get 400. - Exact explicit route: a manifest route without parameters whose path equals the request path.
- A real static file in
public/(/and trailing-slash paths map toindex.html). - Patterned explicit route (a manifest route with
:nameor*name). - Convention route:
/api/<name>runsapi/<name>.js. - Not found: the
404entry oferrorPagesif the manifest has one, otherwisepublic/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:
- An exact route, the winning pattern, or a static file that matches the path but not the method answers 405 and does not fall through to the next step.
Allowlists the allowed methods deterministically, withHEADadded wheneverGETis allowed (and a route that acceptsGETalso acceptsHEAD). - Static files accept
GETandHEAD; anything else gets 405 withAllow: GET, HEAD. - Convention routes accept every method except the ones the edge refuses (CONNECT, TRACE). Check
request.methodin your handler. - Scripts under
api/_admin/are never run for public requests (404), whatever route points at them. They exist for admin extensions.
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:
- Starts with
/./alone is the root route. Otherwise no trailing slash. - At most 1,024 bytes and 32 segments. No empty segments, no
.or..segments. - No
?,#,\,%or control characters. Paths are written decoded: a literal segment may use only ASCII letters, digits and-._~!$&'()+,;=@. - A segment is one of:
- a literal (
posts), - a named parameter
:namethat matches exactly one segment, - a catch-all
*name, only as the last segment, that matches one or more segments (so/files/*restdoes not match/files).
- a literal (
- A parameter marker must be the whole segment start:
a:bora*bare invalid literals (:and*are not in the literal alphabet). Names match[A-Za-z_][A-Za-z0-9_]*, 1 to 32 bytes, unique within the route. At most 8 captures per route. - The first segment must be a literal. It cannot be
_adminin any letter case, except for exact private extension routes of the form/_admin/...described in Admin extensions. - At most 256 routes per manifest and 64 KiB for the manifest.
{ "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 });
// ...
}
- The catch-all value is the matched segments joined with
/(/files/a/b.txtgivesrestequal toa/b.txt). - Exact and convention routes get an empty
params. - Do not decode again. Request paths are percent-decoded exactly once by the router. A segment sent as
%252Farrives as the literal text%2F. Treat captures as untrusted input: validate before using them in SQL (always bound), in HTML (escape) or as storage keys. request.urlstill carries the decoded path and the trailing slash if the visitor sent one. A trailing slash matches the canonical route (/posts/x/reaches the route/posts/:slug).
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 });
}
}