A long URL stuffed with a dozen parameters is awkward to paste into an email, read aloud, or show on a slide. A link shortener fixes that in a single step: it takes a long address, hands back a short one, and when someone follows the short link it quietly sends the browser to the original. In this mini-course we build such a service in Rust — on axum, tokio, and a couple of small crates — and along the way we unpack every decision: why this way and not another.

The idea in one sentence: give each long address a sequential number, encode it as a short string, and append it to the end of our domain.

A server on axum in a few lines

The service needs a socket that listens for incoming connections. We take TcpListener from tokio and bind it to an address.

let listener = TcpListener::bind(addr).await?;

The address 0.0.0.0:3000 means "all network interfaces, port 3000". The bind() method is async, so we await it: under the hood it is a system call that can take time and can fail (the port may be busy) — hence the Result, which we unwrap with the ? operator.

Next we need a router — a table of "request path → handler". In axum that is the Router type.

let app = Router::new()
    .route("/", get(index));

The route() method consumes the router and returns a modified one, so the calls chain nicely. The get() function wraps an async handler so it answers only the GET method; without it the Router would not know which HTTP method serves the path.

The handler itself is just an async function that returns something axum can turn into a response.

async fn index() -> &'static str {
    "Link shortener"
}

axum wraps the string literal into an HTTP response with a body and headers on its own. That is the framework's core idea: a handler returns an ordinary value, and turning it into a Response is the job of the IntoResponse trait.

What remains is to tie the listener and the router together and start the processing loop.

axum::serve(listener, app).await?;

The serve() function is async too and also returns a Result — again await and ?. With that, the skeleton of a live server is ready.

Errors that don't hide

For ? to work in main, main itself must return a Result. We take the error type from the anyhow crate — handy for applications where telling specific error variants apart does not matter.

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    // ...
    Ok(())
}

The tokio::main macro spins up the async runtime and calls our main. The Result alias from anyhow already plugs in its own error type, so any ? inside the function just works: a failure from bind() or serve() bubbles up and ends the program cleanly with a readable message, without unwrap() or panics.

Configuration without recompiling

Hard-coding the address is bad: each run might need a different one. The handiest way to parse command-line arguments is the clap crate with the derive feature — it generates a parser straight from a struct.

#[derive(Parser)]
struct Opts {
    #[arg(long, default_value = "0.0.0.0:3000", env)]
    address: SocketAddr,
}

The field type is SocketAddr, and that is no accident: clap validates the string into an address itself, while bind() accepts anything implementing ToSocketAddrs. The default_value attribute removes the need for mandatory input, and env (enabled by the feature of the same name) lets the value come from the ADDRESS environment variable — convenient in containers.

Parsing is one line at the top of main.

let opts = Opts::parse();

The parse() method reads the arguments and environment variables itself, and on error prints a hint and exits the process. After that we substitute opts.address for the literal in bind().

A little later we add a second field to Optshost, with the default value http://localhost:3000. The service needs it to know its own address and to assemble short links from it.

Code: a number that looks short

The short code could be stored as a random string, but a numeric index is better: it maps naturally onto an auto-increment in a database and gives fast lookups. We make a tiny wrapper struct.

#[derive(Debug, Hash, Eq, PartialEq, Clone, Copy)]
pub struct Code(u64);

The u64 type is eight bytes, like BIGSERIAL in PostgreSQL: room for billions of links. The derived traits are not for show: Hash, Eq, and PartialEq are needed to use Code as a hash-map key, and Copy to cheaply duplicate such a small value instead of passing references.

To put the code into a URL we turn it into a string. The number itself would look long, so we encode it in Base62 — digits and letters of both cases, 62 characters in total.

impl ToString for Code {
    fn to_string(&self) -> String {
        base62::encode(self.0)
    }
}

The encode() function from the base62 crate takes a number and returns a compact string: the number 1 becomes "1", while larger values become short alphanumeric codes like "dW3". So the end of the domain gets a tidy tail instead of a long decimal number.

Decoding the code back

Following a short link calls for the reverse trip: from the string in the request path, recover the Code. We implement Deserialize from serde for it by hand — so the decoding plugs into axum's parameter extraction.

let s = String::deserialize(deserializer)?;
let value = base62::decode(&s)
    .map_err(Error::custom)?;

First we pull an ordinary String out of the deserializer, then decode it back into a number. Here is the subtlety: decode() returns its own DecodeError, but we need an error tied to the concrete deserializer. That error type is unknown to us (the deserializer is generic), yet it must implement serde's Error trait, which has a custom() constructor accepting anything with Display. DecodeError does implement Display, so map_err(Error::custom) cleanly converts one error into the other.

There is a second nuance: decode() returns a u128, while we have a u64.

let value: u64 = value
    .try_into()
    .map_err(Error::custom)?;
Ok(Code(value))

Not every 128-bit number fits into 64 bits, so we use try_into() — it returns a Result and honestly reports overflow, again through custom(). At the end we wrap the number into Code. Now the code travels from the URL into the struct and back without loss.

State: two maps and a counter

The heart of the service is the shared state where all the links live. We call it Shortener.

pub struct Shortener {
    address: Url,
    counter: u64,
    links: HashMap<Url, Code>,
    redirects: HashMap<Code, Url>,
}

The counter field is the source of new numbers, one per link; that is exactly the auto-increment a database would have. There are two maps because we need fast lookups in both directions: links (Url → Code) — to always return the same code for the same URL and avoid duplicates; redirects (Code → Url) — to find the target by the code from a short link. The address is stored as a Url from the url crate: that type makes sure any path edits stay a valid URL.

Lookup is trivial and does not change state, so it takes &self.

pub fn lookup(&self, code: &Code) -> Option<&Url> {
    self.redirects.get(code)
}

The get() method on HashMap already returns an Option<&Url> — exactly what we want: Some with the target, or None if the code is absent. No extra handling is needed, we just return the result.

Registration changes state, so it takes &mut self and takes ownership of the Url.

pub fn register(&mut self, url: Url) -> Url {
    if let Some(code) = self.links.get(&url) {
        return self.shorten(code);
    }
    self.counter += 1;
    let code = Code(self.counter);
    // ...
}

First we check whether we have shortened this URL before: if so, we return the already issued code, otherwise the old short link would stop working. Only for a new URL do we bump the counter and mint a fresh Code.

Then we put the pair into both maps and assemble the short address.

self.links.insert(url.clone(), code);
self.redirects.insert(code, url);
let mut short = self.address.clone();
short.set_path(&code.to_string());
short

The url is cloned because both maps must own the value. We build the short link from a copy of address: the set_path() method replaces the path with the encoded Code while keeping the URL valid. We return the finished address — there is no point storing it, it is derived from the code in nanoseconds.

Sharing state across threads

axum processes requests on several threads, so the state passed into the router must be cloneable. But Shortener is deliberately not Clone: each clone would hold its own set of links, while we need a single shared one. The solution is to wrap it.

let state = Shortener::new(opts.host);
let shared = Arc::new(RwLock::new(state));
let app = Router::new()
    // ... .route(...)
    .with_state(shared);

Arc (a reference counter) gives many cheap pointer-clones to one object. RwLock (the async one, from tokio) adds mutable access guarded by a lock. We pick RwLock over Mutex because we expect far more link follows than creations, and RwLock lets many readers in at once while making only writes exclusive. The with_state() method hands this state to every handler.

Two handlers — and the service is ready

We hang link creation on GET /new?link=<url>. The query parameter is pulled with the Query extractor, the state with the State extractor.

async fn create_link(
    State(state): State<Shared>,
    Query(req): Query<CreateLink>,
) -> String {
    let mut state = state.write().await;
    state.register(req.link).to_string()
}

Query<CreateLink> deserializes the query string straight into a struct with a link: Url field. Since we will mutate the state, we take a write lock via write().await. The register() method returns a Url, and its to_string() gives a string that axum turns into a response.

Following a short link is hung on the path /:code. The code is pulled with the Path extractor, and the state is locked for reading.

async fn follow_link(
    State(state): State<Shared>,
    Path(code): Path<Code>,
) -> Response {
    let state = state.read().await;
    if let Some(url) = state.lookup(&code) {
        Redirect::to(&url.to_string()).into_response()
    } else {
        let msg = "no link for this code";
        (StatusCode::NOT_FOUND, msg).into_response()
    }
}

Path<Code> triggers our hand-written Deserialize: the path segment is decoded from Base62 into a Code. If the link is found, we answer with Redirect::to() and status 303 See Other — the browser will move on to the original URL itself. If not, we return a 404 with a readable message. The two branches return different types, so we coerce them to a common Response with into_response(); otherwise the compiler would not let the function return two different types.

We register both paths in the router, and the service is ready to respond.

let app = Router::new()
    .route("/new", get(create_link))
    .route("/:code", get(follow_link))
    .with_state(shared);

The request GET /new?link=https://www.rust-lang.org/ returns something like http://localhost:3000/1, and following that address redirects back to rust-lang.org.

Where to go next, and what production needs

The skeleton is enough to feel out the idea, but there are gaps before a battle-ready service. Link creation currently sits on GET — yet the path /new could in theory collide with the short link for the Code whose value is 190894; it is cleaner to move creation to POST so the paths never overlap. The state lives in memory and is lost on restart — in production a real database takes its place, where counter becomes a BIGSERIAL and the maps become indexes. Input URL validation, metrics, and rate limiting will come in handy too.

The main point is that the whole service is a few dozen lines: an async server, a small Code struct with Base62 both ways, state of two maps under an RwLock, and two handlers. Building something like this in an evening pays off twice: you get a working tool you actually want to keep on your own domain, and you feel out how axum, serde, and ownership in Rust come together into a tidy microservice.