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 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: a prefix, and the prefix itself. is allowed only as the last segment. A route can carry one 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 covers the type, not the presence. A \{name*} tail is read the same way, and is the one variable that can come back empty.

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, before route variables are available. 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 keeps winning:

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 covers the same path 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 a route added then would never answer, and it throws IllegalStateException instead. 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 come first, so a route can shadow a file. error(HttpStatus.NOT_FOUND, handler) registers the body that 404 answers with. Filters and Errors covers how an error handler 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.