Skip to content
Kona

URL generation

URL generation turns route names and paths into strings your app can put in JSON, redirects, emails, or HTML. Kona registers a singleton UrlGenerator when you boot with HttpServiceProvider. The same router that serves requests backs named-route lookups — name your routes first (Routing).

  1. HttpServiceProvider binds Router and UrlGenerator.
  2. The generator is constructed with the shared Router, app.url as root (or ""), and app.key as the signing key (or "kona-app-key").
  3. Url.setGenerator(generator) installs global helpers: Url.route, Url.to / Url.url, and Kotlin top-level route(...) / url(...).
  4. Controllers and views call those helpers instead of hard-coding paths.

If you call Url.route before the generator is configured, you get IllegalStateException("URL generator not configured").

val urls = app.resolve(UrlGenerator::class.java)
// Optional overrides after boot:
urls.setRoot("https://example.com")
urls.setKey(app.config.getString("app.key", "kona-app-key"))

setRoot trims a trailing /. An empty root means helpers return path-only strings (/users/42). Absolute http:// / https:// paths passed to to(...) pass through unchanged.

Give routes a name, then generate URLs (with optional parameters):

router.get("/users/{id}") { users.show(it) }.name("users.show")
val pathOnly = router.route("users.show", mapOf("id" to "42"))
// "/users/42" — path only, no root
val absolute = urls.route("users.show", mapOf("id" to "42"))
// "https://example.com/users/42" when root is set
val viaHelper = route("users.show", mapOf("id" to "42")) // Url.route
Response.redirect(route("users.show", mapOf("id" to id)))

Substitution uses Router.route under the hood:

  • Each {key} is replaced with the matching map value.
  • Values are not percent-encoded — encode reserved characters yourself.
  • Missing keys leave {key} in the output.
  • Unknown route name → IllegalArgumentException("Route [name] not defined").
  • Duplicate names → the first registered route wins.
  • Group name prefixes apply at registration time (api. + health → api.health).

to(path) / Url.to / url(path) join a relative path onto the configured root:

Input Root https://example.com Result
/users set https://example.com/users
users set https://example.com/users (leading / added)
https://other.test/x any https://other.test/x (unchanged)
/users empty /users

A signed URL includes an HMAC-SHA256 signature (hex) and optionally an expires unix timestamp. Use it for one-time downloads, email confirmations, or any link that must not be tampered with.

val link = urls.signed("/downloads/report", expirationSeconds = 600)
// https://example.com/downloads/report?expires=…&signature=…
val named = urls.signedRoute(
"users.show",
mapOf("id" to "42"),
expirationSeconds = 120,
)
val forever = urls.signed("/invite/accept") // no expires query param
  1. Split any existing query string from the path.
  2. Optionally add expires = now + expirationSeconds.
  3. Sort query keys, URL-encode keys and values, build path?sortedQuery.
  4. Sign that string with HMAC-SHA256 using the generator’s key.
  5. Append signature=<hex> and prefix with to(...).

Verification rebuilds the payload from request.path plus all query params except signature, sorted the same way. If expires is present and in the past, the signature is invalid.

if (!urls.hasValidSignature(request)) {
throw HttpException(403, "Invalid signature.")
}

Or register ValidateSignature middleware so unsigned or expired links become HTTP 403 with message Invalid signature.:

router.get("/downloads/report") { request ->
Response.download(reportBytes, "report.pdf")
}.middleware(ValidateSignature(urls))
router.get("/downloads/report", request -> Response.download(reportBytes, "report.pdf"))
.middleware(new ValidateSignature(urls));
Helper Behavior
Url.setGenerator / clearGenerator Install or clear (tests)
Url.generator() Current generator or throw
Url.route(name, params) Absolute/rooted named URL
Url.to(path) / Url.url(path) Join path onto root
Kotlin route(...) / url(...) Top-level wrappers
return Response.redirect(route("posts.show", mapOf("id" to post.id)))
Response.json(mapOf(
"self" to route("users.show", mapOf("id" to user.id)),
"avatar" to url("/storage/avatars/${user.id}.png"),
))
val downloadUrl = urls.signedRoute("files.show", mapOf("id" to fileId), 300)
mailer.send(user.email, "Your file", downloadUrl)
Method Role
setRoot(root) / setKey(key) Configure absolute URLs and HMAC key
to(path) Root + path (or pass-through absolute URL)
route(name, params) Named route via router, then to
signed(path, expirationSeconds?) Signed path URL
signedRoute(name, params, expirationSeconds?) Named path then signed
hasValidSignature(request) Verify query signature (+ optional expiry)

Middleware wrapping hasValidSignature; throws HttpException(403, "Invalid signature.") on failure.

Why is my named URL missing the host?
app.url is empty. Set APP_URL / app.url, or call setRoot.

Why does substitution leave {id} in the string?
The params map did not include id. Check the key name.

Can I sign POST bodies?
No. Signatures cover path + query only.

Does changing query order break verification?
No — keys are sorted before sign and verify.

Should I use the default kona-app-key in production?
No. Set a strong app.key / APP_KEY.

  • route(..., absolute = false) as a separate API (use router.route for path-only)
  • Signed middleware that allows relative expiry headers beyond the expires query param
  • Automatic HTTPS forcing in the generator
  • Asset URL helpers (Crema @asset is separate — see Crema forms / rendering)