Routing
A route maps an HTTP method and a path pattern to a handler, and registers as one statement.
get, post, put, patch, and delete each register one.
head and options are answered automatically, so they are rarely registered.
The routing table is an explicit list.
There is no annotation scanning, so only what is registered runs, and app.routes() hands the list back as data.
Each of those methods also accepts a description between the path and the handler, for what the path itself does not say.
Path patterns
app.get("/decks", decks::list); // this path, exactly
app.get("/decks/{deckId}", decks::show); // {deckId} matches one segment
app.get("/files/{path*}", files::serve); // {path*} matches the rest of the path
app.beforeRoute("/admin/*", req -> requireAdmin(req)); // matched routes under /admin
Patterns are compared segment by segment.
There are no regular expressions.
A {name} segment matches exactly one segment and never a slash, so /decks/{deckId} matches /decks/3 but not /decks or /decks/3/cards.
A trailing slash on the request is ignored: /decks/ and /decks are the same path.
A request path with an empty segment inside it, or a . or .. segment, literal or percent-encoded, answers 400 on every server before routing.
The 400 is the app’s own, so statusPage(HttpStatus.BAD_REQUEST, …) renders it on every server.
/a//b and /files/../secret therefore never bind a variable to "" or a tail to ../secret.
A final matches the rest of the path, including nothing at all.
/admin/ therefore covers /admin, /admin/users, and /admin/users/7, which is what a filter path usually means: the prefix itself and everything under it.
is allowed only as the last segment.
A route can end in as well.
A bare * captures nothing, so a handler that needs the remainder registers a named tail instead.
A final \{name*} matches exactly what matches, and binds what it matched.
/files/\{path} therefore covers /files, /files/a.txt, and /files/docs/2026/a.txt, and req.pathParam("path") reads "", a.txt, and docs/2026/a.txt in turn.
The slashes inside the tail are kept, because the tail is a remainder and not a segment.
An empty tail matches, exactly as it does for , so /files and /files/ reach the route with the variable bound to the empty string.
A named tail is allowed only as the last segment.
/files/ and /files/\{path*} match the same requests, so registering both under one method throws the way any other pair of identical shapes does.
Path variables
A handler reads what a {name} segment matched by name, and states the type at the point of reading:
long deckId = req.pathParamLong("deckId"); // non-numeric input becomes a 400
String name = req.pathParam("name");
Direction direction = req.pathParamEnum("direction", Direction.class);
The value always exists when the handler runs, because a request without the segment would not have matched the route.
The 400 therefore reports a value of the wrong type, not a missing one.
A \{name*} tail is read the same way, and is the one variable that can come back empty.
A variable name appears once per pattern, a named tail included.
/tenants/{id}/items/{id} and /files/{id}/\{id*} throw IllegalArgumentException at registration.
The message names the pattern and the repeated variable.
A second binding would overwrite the first, so the handler would read the wrong identifier.
A group’s prefix is part of the pattern, so a route /items/{id} under the prefix /tenants/{id} throws as well.
Filter paths and Cors.forPath are checked the same way.
A variable needs a name.
{} and {} throw IllegalArgumentException at registration, because they would otherwise match one segment under the name "" or .
A pattern that should match the rest of the path ends in or in a named tail such as \{path}.
Groups
Routes that share a prefix register through path.
The group arrives as an argument rather than as ambient state:
app.path("/api/decks", decks -> {
decks.beforeRoute(req -> requireApiKey(req)); // guards matched routes under /api/decks
decks.get("", api::listDecks); // GET /api/decks
decks.get("/{deckId}", api::showDeck); // GET /api/decks/{deckId}
decks.path("/{deckId}/cards", cards -> // groups nest, prefixes concatenate
cards.get("", api::listCards)); // GET /api/decks/{deckId}/cards
});
An empty path, or "/", registers the prefix itself.
A group’s beforeRoute and afterRoute filter its matched routes when given no path of their own.
beforeRequest uses the same prefix but also covers static files and unmatched paths, and runs before route matching, so route variables are not available yet.
The prefix is resolved at registration, so app.routes() reports full paths.
A group changes how routes are written, not what is registered.
Matching order
Registration order breaks ties between overlapping patterns. A literal route registered before a variable one therefore always wins:
app.get("/study/today", study::today); // wins for /study/today
app.get("/study/{mode}", study::byMode); // everything else under /study
Registering a second route that matches exactly the same requests throws IllegalStateException on the spot.
That includes the same path registered twice, and the same shape with a variable renamed.
Such a route could never run, and the exception reports it at registration instead of leaving a dead entry in the table.
Registration closes while an AppServlet serves the application, whether start(), a server started directly, or an external container runs it.
The servlet serves a copy of the table taken when it was initialized, so registering a route while it serves throws IllegalStateException instead of adding a route that would never answer.
stop() opens registration again, and the next start serves what was registered in between.
When nothing matches
A path no route knows answers 404.
A path that only other methods know answers 405, with an Allow header listing the methods that would have worked.
For a GET or HEAD, static files are consulted before the 404.
Routes are matched first, so a route can shadow a file.
statusPage(HttpStatus.NOT_FOUND, handler) registers the body that 404 answers with.
Filters and Errors covers how a status page fills in the body.
HEAD and OPTIONS
Both are answered without registering anything.
A HEAD runs the GET route and drops the body.
The headers are kept, including a Content-Length counted from what the GET would have sent.
An OPTIONS answers with the Allow header the path’s routes imply, and answers 404 when the path has no routes:
$ curl -X OPTIONS -i localhost:8080/decks
HTTP/1.1 200 OK
Allow: GET, POST, HEAD, OPTIONS
Register a route of your own with app.head(…) or app.options(…) when the automatic answer is not the one you want, which usually means a CORS preflight.
Neither automatic answer appears in app.routes(), which reports only what was registered.