URL Shortener
This guide builds a small URL shortener on top of Firefly. It accepts a URL through an HTML form (or JSON), stores it under a random short code, and redirects visitors from /{code} to the original link. Along the way it shows how Firefly handles form and JSON input through one schema, route parameters, redirect responses, route groups with middleware, and a custom not-found handler.
What you'll learn
- Serving an HTML page and setting
Content-Typeon a text response - Parsing a request body with
Schema.parse— the same schema handles both form-encoded and JSON bodies - Reading route parameters via
req.Params.["code"] - Returning 301/302 redirects with
Response.redirect - Grouping routes under a prefix and attaching
RateLimitmiddleware to the group - Registering a custom 404 handler with
App.notFound - Wiring routes and config together and starting the app with
App.run
The schema
A schema { ... } block declares the expected input once. Schema.required validates a non-empty, trimmed, well-formed URL, and the same schema is reused for both form and JSON requests.
open Firefly
type ShortUrl = { Code: string; Url: string; Clicks: int; CreatedAt: DateTime }
let createUrlSchema = schema {
let! url = Schema.required "Url" Schema.string [ Schema.nonempty; Schema.url; Schema.trim ]
return {| Url = url |}
}Serving the landing page
The home handler returns the HTML form as text and sets the content type explicitly so browsers render it as a page. The form posts to /api/shorten as application/x-www-form-urlencoded.
let homePage : Handler = fun _req -> task {
return
Response.text landingPage
|> Response.header "Content-Type" "text/html; charset=utf-8"
}<form method="POST" action="/api/shorten" enctype="application/x-www-form-urlencoded">
<input type="url" name="url" placeholder="https://example.com/very/long/url" required />
<button type="submit">Shorten</button>
</form>Parsing the body and creating a short URL
Schema.parse auto-detects the body format: a form POST and a JSON POST both flow through the same code. On success it generates a code, stores the entry, and returns 201; on failure it returns the validation errors with 400.
let createShortUrl : Handler = fun req -> task {
// Auto-detects: JSON → zero-alloc buffer path, form → form path
match! Schema.parse createUrlSchema req with
| Ok input ->
let code = generateCode ()
let entry = { Code = code; Url = input.Url; Clicks = 0; CreatedAt = DateTime.UtcNow }
store.[code] <- entry
return Response.json {| code = code; shortUrl = $"/{code}"; originalUrl = input.Url |} |> Response.status 201
| Error errors ->
return Response.json {| errors = errors |} |> Response.status 400
}Reading route params and redirecting
The catch-all /:code route reads the parameter from req.Params. If the code exists, the handler bumps the click counter and issues a 302 redirect to the original URL; otherwise it returns a JSON 404.
let redirectToUrl : Handler = fun req -> task {
let code = req.Params.["code"]
match store.TryGetValue(code) with
| true, entry ->
store.[code] <- { entry with Clicks = entry.Clicks + 1 }
return Response.ok |> Response.redirect entry.Url 302
| false, _ ->
return Response.json {| error = "short URL not found" |} |> Response.status 404
}Stats handlers follow the same pattern — getStatsForCode looks up req.Params.["code"] and returns the stored entry or a 404.
A custom 404
App.notFound registers a handler that runs when no route matches. Here it returns a friendly plain-text message with a 404 status.
let custom404 : Handler = fun _req -> task {
return
Response.text "404 — Nothing here. Try creating a short URL at /"
|> Response.status 404
}Routes, groups, and rate limiting
Routes are built with the Route.* combinators. The /api group shares a rate-limit middleware (10 requests per minute, keyed by IP). The final /:code route handles redirects.
let createRateLimit =
RateLimit.fixedWindow 10 (TimeSpan.FromMinutes 1.0) RateLimit.byIp
let routes =
Route.start
|> Route.get "/" homePage
|> Route.group "/api" (fun api ->
api
|> Route.middleware createRateLimit
|> Route.post "/shorten" createShortUrl
|> Route.get "/stats" getStats
|> Route.get "/stats/:code" getStatsForCode
)
|> Route.get "/:code" redirectToUrlApp config and startup
The config pipeline sets the port and wires in the custom 404. App.run takes the routes, the config, and a cancellation token, then runs the server.
let config =
App.defaults
|> App.port 3000
|> App.notFound custom404open System.Threading
open Firefly
open UrlShortener
let (routes, config) = App.create()
printfn "Fire URL Shortener running on http://localhost:3000"
App.run routes config CancellationToken.None
|> fun t -> t.GetAwaiter().GetResult()Running it
dotnet run --project examples/url-shortener# Create a short URL via the form-encoded endpoint
curl -i -X POST http://localhost:3000/api/shorten \
-d "url=https://example.com/very/long/url"
# Same endpoint accepts JSON too
curl -X POST http://localhost:3000/api/shorten \
-H "Content-Type: application/json" \
-d '{"url":"https://example.com/very/long/url"}'
# Follow the redirect from a short code (use the code returned above)
curl -iL http://localhost:3000/abc123
# List every stored link
curl http://localhost:3000/api/statsSource
The full example lives at examples/url-shortener/.