Routing
A handler answers a request. Routing decides which handler, by looking at two things in the request, its method and its path. This page builds up the routes of three small programs, and shows what each request gets back.
Methods and paths
The first program registers a handful of routes, each with a method, a path and the handler that answers:
final app = RelicApp()
..get('/', (req) => Response.ok(body: .fromString('You asked for /\n')))
..post('/', (req) => Response.ok(body: .fromString('You posted to /\n')))
..put('/user', (req) => Response.ok(body: .fromString('User replaced\n')))
..delete('/user', (req) => Response.ok(body: .fromString('User deleted\n')))
..add(
Method.patch,
'/user',
(req) => Response.ok(body: .fromString('User updated\n')),
)
..anyOf({Method.get, Method.post}, '/admin', (req) {
return Response.ok(body: .fromString('Admin, by ${req.method.value}\n'));
});
get, post, put and delete are shortcuts for add with the matching Method, as the patch route shows by calling add itself. There is a shortcut for every method Relic knows, get, head, post, put, delete, patch, options, trace and connect. anyOf registers one handler for a set of methods, and any registers it for all of them.
The method picks the handler as much as the path does:
curl http://localhost:8080/
curl -X POST http://localhost:8080/
You asked for /
You posted to /
For a method that the path has no route for, the router answers 405 and lists the methods it does have in the allow header:
curl -i -X DELETE http://localhost:8080/
HTTP/1.1 405 Method Not Allowed
date: Fri, 23 Oct 2026 11:56:48 GMT
content-length: 0
allow: GET, HEAD, POST
A path with no route at all gets 404. The Hello world page shows both, and how to answer unmatched requests yourself with a fallback.
HEAD requests
HEAD is in that allow list, although the program registers no HEAD route. A HEAD request asks for the headers that a GET would get, without the body. When a path has a GET route and no HEAD route, the GET handler answers it:
curl -I http://localhost:8080/
HTTP/1.1 200 OK
content-type: text/plain; charset=utf-8
date: Fri, 23 Oct 2026 11:56:48 GMT
content-length: 16
The 16 is the length of You asked for / and its newline, although no body was sent. The adapter cancels the body's stream without reading it, and a body built with open never makes its stream at all. The handler sees Method.head on request.method, so it can skip work that only the body needs.
A path can still have a HEAD route of its own, and that route takes precedence, whatever order the routes are added in. The one exception to the fallback is a GET handler that returns a Hijack or a WebSocketUpgrade. Relic cannot strip a body from bytes that the handler writes itself, so a HEAD request to such a route gets 405. The Handlers page shows a hijacking handler with a HEAD route of its own.
Path parameters
Fixed paths only go so far. A route segment that starts with a colon is a parameter, which matches any value and remembers it. The second program starts with one:
..get('/users/:id', (req) {
final id = req.pathParameters.raw[#id];
return Response.ok(body: .fromString('User $id\n'));
})
curl http://localhost:8080/users/42
User 42
pathParameters.raw maps the name of each parameter, as a Dart symbol, to the segment it matched, and the value is always a string. To get a number, declare a typed accessor for the parameter and read the value through it:
const orderIdParam = IntPathParam(#id);
Response showOrder(Request req) {
final id = req.pathParameters.get(orderIdParam);
return .ok(body: .fromString('Order $id, and the next one is ${id + 1}\n'));
}
..get('/orders/:id', showOrder)
curl http://localhost:8080/orders/42
Order 42, and the next one is 43
get parses the segment and returns an int, so id + 1 is arithmetic rather than string concatenation. When the segment does not parse, get throws a FormatException, and a request for /orders/abc gets a 500. Besides IntPathParam, there are DoublePathParam and NumPathParam for numbers.
Calling pathParameters with the accessor, instead of calling get, returns null when the route has no such parameter. That lets one handler serve a route with the parameter and a route without it:
const pageParam = IntPathParam(#page);
Response listPosts(Request req) {
final page = req.pathParameters(pageParam) ?? 1;
return .ok(body: .fromString('Posts, page $page\n'));
}
..get('/posts', listPosts)
..get('/posts/:page', listPosts)
Requests for /posts and /posts/3 get:
Posts, page 1
Posts, page 3
For any other type, give PathParam the function that parses it:
const eventDateParam = PathParam<DateTime>(#date, DateTime.parse);
To reuse an accessor like that, make it a class with the parser built in, the way IntPathParam is made:
final class DateTimePathParam extends PathParam<DateTime> {
const DateTimePathParam(Symbol key) : super(key, DateTime.parse);
}
const dateParam = DateTimePathParam(#date);
The program reads the date of /events/:date with it, and a request for /events/2026-10-23 gets:
Events in month 10 of 2026
Wildcards and tails
A * segment matches any value, as a parameter does, but keeps none, for when the value does not matter:
..get(
'/files/*/download',
(req) => Response.ok(body: .fromString('Downloading a file\n')),
)
A request for /files/report.pdf/download gets Downloading a file.
A ** at the end of a route matches the rest of the path, however many segments that is. The part it matched is the request's remainingPath:
..get(
'/static/**',
(req) => Response.ok(body: .fromString('Serving ${req.remainingPath}\n')),
)
..get(
'/static/special/report',
(req) => Response.ok(body: .fromString('The special report\n')),
)
curl http://localhost:8080/static/css/site.css
Serving /css/site.css
This is how a handler for a whole directory learns which file was asked for, and the Static files page relies on it.
Which route wins
The program registers both /static/** and /static/special/report. A request for the report matches both routes, and gets the more specific one:
/static/special/report The special report
/static/special/other Serving /special/other
The router walks the path one segment at a time, and tries a literal segment before a parameter, a wildcard or a tail. When the literal path leads to a dead end, it backs up and tries the alternatives.
Backtracking
The last two routes of the program show that backing up at work:
..get('/:entity/:id', (req) {
final entity = req.rawPathParameters[#entity];
final id = req.rawPathParameters[#id];
return Response.ok(body: .fromString('A $entity with id $id\n'));
})
..get(
'/shop/:id/profile',
(req) => Response.ok(body: .fromString('A shop profile\n')),
);
A request for /shop/789/profile follows the literal shop all the way to the end. A request for /shop/789 starts down the same literal shop, and 789 matches :id, but that route needs a third segment, profile, and the path has ended. So the router backs up to the first segment, tries :entity instead, and /:entity/:id matches:
/shop/789 A shop with id 789
/shop/789/profile A shop profile
Without backtracking, the request for /shop/789 would have committed to the literal shop and failed. Because of these rules, the order you register routes in does not change which route wins, so routers built in separate modules combine without surprises.
The routes live in a trie, a tree with one level per path segment. Without backtracking, a lookup visits one node per segment, so its cost depends on the length of the path and not on the number of routes. With backtracking, a lookup can leave a literal branch and try a parameter in its place, which visits more nodes, but never the same node twice. Even the worst lookup does no more work than trying every route in turn, and a path that never needs to back up still costs one node per segment.
Backtracking is on by default, and run takes a backtrack argument to turn it off. The program passes it through from its command line:
final backtrack = !args.contains('--no-backtrack');
await app.run(
() => IOAdapter.bind(InternetAddress.loopbackIPv4, port: 8080),
backtrack: backtrack,
);
Started with --no-backtrack, the router commits to the literal shop and never tries :entity:
curl -i http://localhost:8080/shop/789
HTTP/1.1 404 Not Found
content-type: text/plain; charset=utf-8
date: Mon, 26 Oct 2026 08:21:34 GMT
content-length: 9
Not Found
The same happens to /static/special/other, which commits to the literal special and never reaches /static/**. Every lookup then costs one node per segment. That is worth having only for a route table in which no literal and no parameter share a prefix, where nothing can need to back up anyway. routeWith, which routes for a router placed in a Pipeline, takes the same argument.
Virtual hosts
One server can answer for several host names. The Host header says which one the client asked for, and with useHostWhenRouting the router reads it too. The third program registers a route for each of two hosts:
final app = RelicApp(useHostWhenRouting: true)
..get(
'www.example.com/',
(req) => Response.ok(body: .fromString('Welcome to example.com\n')),
)
..get(
'api.example.com/users',
(req) => Response.ok(body: .fromString('The users, over the API\n')),
);
The host is the first segment of each route. curl sends the host it connected to, so to ask for another one, set the header by hand:
curl -H 'Host: www.example.com' http://localhost:8080/
curl -H 'Host: api.example.com' http://localhost:8080/users
curl -H 'Host: api.example.com' http://localhost:8080/
Welcome to example.com
The users, over the API
Not Found
The third request asks api.example.com for /, which only www.example.com has a route for, and gets 404. So does a plain curl http://localhost:8080/, since its host is localhost. A port in the header makes no difference, and Host: www.example.com:8080 gets the welcome too.
The router treats the host as one more segment of the path, and nothing else changes, so the rules of this page apply to it. A literal host matches that host alone, * matches any host, and a parameter captures the host for the handler. The program uses that for a dashboard that every tenant reaches on a host of its own:
app.get(':tenant/dashboard', showDashboard);
Response showDashboard(Request req) {
final tenant = req.rawPathParameters[#tenant];
return .ok(body: .fromString('The dashboard of $tenant\n'));
}
curl -H 'Host: acme.example.com' http://localhost:8080/dashboard
curl -H 'Host: globex.example.com' http://localhost:8080/dashboard
The dashboard of acme.example.com
The dashboard of globex.example.com
One handler serves every tenant, and the literal hosts above still win for their own paths. A level of the trie holds a wildcard or a parameter, not both, so */health and :tenant/dashboard cannot be registered together. Pick one, and read the value or ignore it.
routeWith, for a pipeline built by hand, takes useHostWhenRouting as well.
Forwarding a request
Sometimes a handler wants another route to answer, without sending the client a redirect. It makes a copy of the request with a new URL, and forwards the copy through the same router:
app
..get('/v1/users', (req) {
final moved = req.copyWith(url: req.url.replace(path: '/v2/users'));
return req.forwardTo(moved);
})
..get(
'/v2/users',
(req) =>
Response.ok(body: .fromString('The users, from ${req.url.path}\n')),
);
curl http://localhost:8080/v1/users
The users, from /v2/users
forwardTo sends the copy through all of the routing of the router that handled the original, middleware included, which calling the other handler directly would skip. Read the forwarded request's parameters with rawPathParameters. The copy shares its context properties with the original, so pathParameters and queryParameters that were read before forwarding keep their old values. The Requests page has more on copyWith and forwardTo.
Examples & further reading
Examples
- Basic routing example - Routes by method and path, and forwarding.
- Dynamic segments example - Path parameters, wildcards, tails and backtracking.
- Virtual hosts example - Routes with a host, and a parameter that captures it.
API documentation
- RelicApp class - Main application class with routing methods.
- Router class - URL router for mapping path patterns.
- Method enum - HTTP methods enumeration.