DEV Community

Cover image for Build a URL shortener with Fitz: HTTP + Postgres + auth in 30 minutes
Martin Palopoli
Martin Palopoli

Posted on

Build a URL shortener with Fitz: HTTP + Postgres + auth in 30 minutes

A step-by-step tutorial through Fitz. We start from fitz new, finish with a native binary running in Docker. No external dependencies. No pip install. Just typed Postgres, JWT auth, and OpenAPI auto-generated.

What we're building

A URL shortener with the four things every real API needs:

  1. HTTP endpoints for create, redirect, and stats.
  2. Postgres persistence for the links and the click counter.
  3. JWT authentication — only logged-in users can create short URLs.
  4. A native binary with fitz build, ready to drop into a container.

Final size: ~120 lines of Fitz. No requirements.txt. No package.json. No cargo add. Just fitz and Postgres.

You'll come out with:

  • POST /login → swap credentials for a JWT.
  • POST /shorten (auth required) → returns the short code.
  • GET /{code} → redirects to the original URL and increments the counter.
  • GET /stats/{code} (auth required) → shows clicks and creation date.
  • An auto-generated OpenAPI 3.1 schema at /openapi.json.
  • The Scalar UI at /docs to test from the browser.

Let's go.

Setup (2 minutes)

Install Fitz:

# Linux / macOS / WSL
curl -sSf https://thegreekman76.github.io/fitz/install.sh | sh

# Windows (PowerShell)
irm https://thegreekman76.github.io/fitz/install.ps1 | iex
Enter fullscreen mode Exit fullscreen mode

Or grab a pre-compiled binary from the releases.

VSCode extension (strongly recommended for this tutorial — you get hover with types, autocomplete, signature help, format on save): from the same releases page grab the fitz-lang-<platform>.vsix matching your OS and install it:

code --install-extension fitz-lang-<platform>.vsix --force
Enter fullscreen mode Exit fullscreen mode

The Language Server is bundled inside the .vsix — no separate install. Reload VSCode once after the install.

Reopen the terminal so the PATH change applies, then check:

fitz --version
# fitz 0.15.0
Enter fullscreen mode Exit fullscreen mode

You'll also need a running Postgres. The fastest is Docker:

docker run -d --name pg-shortener \
  -e POSTGRES_PASSWORD=demo \
  -e POSTGRES_DB=shortener \
  -p 5432:5432 \
  postgres:16
Enter fullscreen mode Exit fullscreen mode

Now create the project:

fitz new url-shortener --http
cd url-shortener
Enter fullscreen mode Exit fullscreen mode

The --http flag templates a main.fitz with @get + @server already wired. Open main.fitz and let's start editing.

Step 1 — Hello world HTTP

Replace main.fitz with this minimum:

@server(8080)
fn main() => 0

@get("/health")
fn health() -> Str => "ok"
Enter fullscreen mode Exit fullscreen mode

Run it:

fitz dev
Enter fullscreen mode Exit fullscreen mode

fitz dev watches the file and respawns on every save — it's the Fitz equivalent of uvicorn --reload. In another terminal:

curl localhost:8080/health
# ok
Enter fullscreen mode Exit fullscreen mode

Open http://localhost:8080/docs in your browser. You already have a Scalar UI with GET /health documented. No decorator-to-register-routes magic, no app.include_router(...). The compiler reads the AST and generates the schema.

Step 2 — Model the domain

A Link is the short code, the original URL, the counter, and the owner.

type Link {
    @primary id: Int,
    code: Str,
    target_url: Str,
    user_email: Str,
    clicks: Int,
    created_at: Str,
}
Enter fullscreen mode Exit fullscreen mode

The @primary decorator marks it as primary key. Int is i64 in the generated binary, bigint in Postgres. The ORM understands this because the type is read in the compiler — not at runtime.

But we haven't told Fitz that this type is a Postgres table. Adding @table:

@table("links")
type Link {
    @primary id: Int,
    code: Str,
    target_url: Str,
    user_email: Str,
    clicks: Int,
    created_at: Str,
}
Enter fullscreen mode Exit fullscreen mode

Now Link.all(db), Link.insert(db, l), Link.where(...), .preload(...) etc. exist on this type. The checker validates statically that any closure inside .where(...) only references existing fields. Typos die at compile time, not in production.

Step 3 — Connect to Postgres

db.connect(url) opens a connection pool. We do it once at boot:

let DB_URL = env_or("DATABASE_URL", "postgres://postgres:demo@localhost:5432/shortener")
let db = db.connect(DB_URL)
Enter fullscreen mode Exit fullscreen mode

env_or is a built-in: reads the environment variable, falls back to the default if it's not set. Useful for local dev + Docker without conditionals.

We also need the table to exist. The right tool here is schema migrations — Fitz has fitz db diff and fitz db migrate built-in. Declare the table as a @table type, then let the migrator do the SQL:

@table("links")
type Link {
    @primary id: Int,
    code: Str,
    target_url: Str,
    user_email: Str,
    clicks: Int = 0,
    created_at: Str = "",
}
Enter fullscreen mode Exit fullscreen mode

The first time:

$ fitz db diff
+ CREATE TABLE links (
+     id BIGSERIAL PRIMARY KEY,
+     code TEXT NOT NULL,
+     target_url TEXT NOT NULL,
+     user_email TEXT NOT NULL,
+     clicks BIGINT NOT NULL DEFAULT 0,
+     created_at TEXT NOT NULL DEFAULT ''
+ );

$ fitz db migrate
✓ applied migration_20260530_links.sql
Enter fullscreen mode Exit fullscreen mode

fitz db diff reads the @table types in your code, introspects the live DB, computes the SQL needed to bring them in sync, and emits an idempotent migration file. fitz db migrate applies the unapplied migrations in order. Same model as Alembic, but with the types as source of truth instead of separate SQL files you have to keep aligned by hand.

When you add name: Str to Link later, fitz db diff emits ALTER TABLE links ADD COLUMN name TEXT NOT NULL. You re-run fitz db migrate. No hand-written DDL.

For this tutorial, run fitz db diff + fitz db migrate once before starting the server.

Step 4 — Create + redirect

Two endpoints. The interesting thing is how compact they are:

type ShortenRequest { target_url: Str }
type ShortenResponse { code: Str, short_url: Str }

@post("/shorten")
async fn shorten(db: DbConn, req: ShortenRequest, user: User) -> ShortenResponse {
    let code = generate_code()
    let link = Link {
        id: 0,
        code: code,
        target_url: req.target_url,
        user_email: user.email,
        clicks: 0,
        created_at: "",  // DB default fills it in
    }
    Link.insert(db, link).await
    return ShortenResponse {
        code: code,
        short_url: "http://localhost:8080/{code}",
    }
}
Enter fullscreen mode Exit fullscreen mode

Three things to note:

  1. The db: DbConn parameter — the runtime injects the connection automatically. Fitz figures out it's coming from the global pool by type.
  2. The user: User parameter — this comes from auth, which we'll wire up in the next step. The compiler statically validates the handler is @authenticated.
  3. id: 0 — sentinel that tells the ORM "this is the first insert, ignore the field, let BIGSERIAL assign it". The ORM omits the field from the INSERT automatically.

The redirect:

@get("/{code}")
async fn redirect(db: DbConn, code: Str) -> Result<HttpResponse> {
    let link: Link = match Link.where(fn(l) => l.code == code).first(db).await {
        Ok(l) => l,
        Err(_) => return Err("not found"),
    }
    // increment the counter without blocking the redirect
    spawn(increment_clicks(db, link.id))
    return Ok(redirect_to(link.target_url))
}

@background
async fn increment_clicks(db: DbConn, link_id: Int) {
    Link.where(fn(l) => l.id == link_id)
        .update(db, { "clicks": "clicks + 1" })
        .await
}
Enter fullscreen mode Exit fullscreen mode

The closure fn(l) => l.code == code gets translated to parametrized SQL at compile time: WHERE code = $1. There's no eval, no string concat, no SQL injection risk. The code variable becomes a bind parameter.

spawn(increment_clicks(...)) is fire-and-forget — the response goes back without waiting. The @background decorator authorizes the function to be called from a spawn. It's a fence-of-intent: nothing accidentally goes to a background scheduler.

Step 5 — JWT auth

Three pieces: the User type, the @auth_provider, the /login endpoint.

type User { email: Str, name: Str }
type LoginRequest { email: Str, password: Str }
type LoginResponse { token: Str }

let JWT_SECRET = env_or("JWT_SECRET", "demo-secret-change-me")
let DEMO_HASH = hash.password("demo123")

@auth_provider
fn check_token(headers: Map<Str, Str>) -> Result<User> {
    let auth: Str = match headers.get("authorization") {
        Ok(v) => v,
        Err(_) => return Err("missing Authorization"),
    }
    let parts = auth.split(" ")
    if (parts.len() != 2 or parts[0] != "Bearer") {
        return Err("expected 'Bearer <token>'")
    }
    let claims = jwt.decode(parts[1], JWT_SECRET)?
    return Ok(User { email: claims["email"], name: claims["name"] })
}

@post("/login")
fn login(creds: LoginRequest) -> LoginResponse {
    if (creds.email != "ada@example.com") {
        return 401 { "error": "invalid credentials" }
    }
    if (not hash.verify(creds.password, DEMO_HASH)) {
        return 401 { "error": "invalid credentials" }
    }
    let claims = { "email": creds.email, "name": "Ada" }
    return LoginResponse { token: jwt.encode(claims, JWT_SECRET) }
}
Enter fullscreen mode Exit fullscreen mode

What's happening:

  • hash.password(...) is Argon2id (the OWASP recommendation for password hashing in 2026). At program boot we hash the demo password "demo123" — in production this would be a SQL query.
  • jwt.encode(...) signs with HS256 by default. The claims are a Map<Str, Str>.
  • @auth_provider is the global singleton of the program. The checker enforces only one @auth_provider per program and that handlers @authenticated reference a valid User type.
  • The ? operator on jwt.decode(...)? propagates the error upward — invalid token, expired, wrong signature — everything ends up as Err that the auth runtime maps to a 401.

Now to require auth on the handlers, stack the decorator:

@authenticated
@post("/shorten")
async fn shorten(db: DbConn, req: ShortenRequest, user: User) -> ShortenResponse { ... }

@authenticated
@get("/stats/{code}")
async fn stats(db: DbConn, code: Str, user: User) -> Result<Link> {
    return Link.where(fn(l) => l.code == code and l.user_email == user.email)
               .first(db)
               .await
}
Enter fullscreen mode Exit fullscreen mode

The user: User parameter is automatically injected by the runtime after the provider validates the token. If the token is missing/invalid → 401 without the handler ever running. The OpenAPI schema reflects all of this: securitySchemes.bearerAuth appears, the protected handlers carry security: [{bearerAuth: []}], and 401 is documented automatically.

The @admin decorator (not used here) takes it further: it requires user.role == "admin" in addition to the token. The compiler enforces statically that User has a role: Str field. If it doesn't, error at compile time.

Step 6 — Test it

With fitz dev running on another terminal:

# Login
curl -X POST localhost:8080/login \
  -H 'Content-Type: application/json' \
  -d '{"email":"ada@example.com","password":"demo123"}'
# {"token":"eyJ0eXAiOiJKV1Qi..."}

TOKEN="eyJ0eXAi..."  # paste the token from the response

# Create a short URL
curl -X POST localhost:8080/shorten \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -d '{"target_url":"https://github.com/Thegreekman76/fitz"}'
# {"code":"abc123","short_url":"http://localhost:8080/abc123"}

# Redirect (browser follows; with curl use -I)
curl -I localhost:8080/abc123
# HTTP/1.1 302 Found
# location: https://github.com/Thegreekman76/fitz

# Stats
curl localhost:8080/stats/abc123 -H "Authorization: Bearer $TOKEN"
# {"id":1,"code":"abc123","target_url":"...","clicks":1,"created_at":"..."}
Enter fullscreen mode Exit fullscreen mode

Open http://localhost:8080/docs and you'll see the full Scalar UI: all four endpoints with the right schema, the Authorize button that paste the token works, the protected endpoints with the lock icon. None of this was set up by hand — it came from the compiler reading the AST.

Step 7 — Compile to a native binary

So far we ran with fitz run (interpreted). Now we ship:

fitz build
Enter fullscreen mode Exit fullscreen mode

That produces url-shortener (or url-shortener.exe on Windows) — a single self-contained native binary. Everything is statically linked except libc.

ls -lh url-shortener
# -rwxr-xr-x  1  user  user   18M  May 29 14:00 url-shortener

DATABASE_URL=postgres://postgres:demo@localhost:5432/shortener ./url-shortener
# server listening on 127.0.0.1:8080
Enter fullscreen mode Exit fullscreen mode

The hard requirement of Fitz is that fitz run and fitz build produce bit-for-bit identical behavior. Same JSON output, same status codes, same SQL queries. If they diverge, it's a bug.

For Docker:

FROM gcr.io/distroless/cc-debian12
COPY url-shortener /url-shortener
ENV DATABASE_URL=postgres://...
ENV JWT_SECRET=...
EXPOSE 8080
CMD ["/url-shortener"]
Enter fullscreen mode Exit fullscreen mode

Distroless image with the binary inside. ~30 MB final. No python, no node, no cargo — just your compiled binary and a minimal libc.

Step 8 — Deploy with one command

You don't actually have to write that Dockerfile by hand. Fitz reads the shape of your program and generates it for you:

fitz docker init
Enter fullscreen mode Exit fullscreen mode

This creates three files in your project:

  • Dockerfile — multi-stage, builder image plus a gcr.io/distroless/cc-debian12 runtime stage. EXPOSE 8080 because there's an @server(8080) in the code.
  • .dockerignore — sane defaults (target/, .git/, .env*, __pycache__/).
  • docker-compose.yml — your app plus a postgres:16-alpine service with a healthcheck and pgdata volume, because there's a db.connect(...) in the code. The DATABASE_URL env var is wired automatically.

The detection is AST-only (~50ms). No need to run the program to figure out what infrastructure it wants. If you had @cron, it would add restart: unless-stopped. If you had from python import ..., it would pick python:3.12-slim-bookworm over distroless. It generates what you'd write by hand, you commit it, you edit it when you need to.

Now you ship:

# Build the image, push to a registry.
fitz deploy docker --tag mycorp/shortener:v1

# Or bring up locally with compose (handy for local QA).
fitz deploy compose
Enter fullscreen mode Exit fullscreen mode

fitz deploy is a thin wrapper over the native docker/docker compose CLIs. The point isn't to invent new tools — it's that you stop chasing wrong Dockerfile mistakes for two days every time you start a project. The right ones are already there.

If you want the binary deployment without Docker, you can keep doing what we did in step 7 — scp the binary, run it. The fitz build output is genuinely standalone.

Production niceties: healthchecks, secrets, observability

While we're talking about production, Fitz already has the rest of the production checklist as part of the language:

@healthz
fn liveness() -> Bool => true

@readyz
async fn readiness(db: DbConn) -> Bool {
    return match db.exec("SELECT 1").await {
        Ok(_) => true,
        Err(_) => false,
    }
}
Enter fullscreen mode Exit fullscreen mode

/healthz and /readyz auto-mount on the HTTP router. Kubernetes is happy.

let JWT_SECRET: Secret<Str> = secret("JWT_SECRET")  // never prints, never logs the value
let LOG_LEVEL: Str = config("LOG_LEVEL", "info")    // typed env var with default
Enter fullscreen mode Exit fullscreen mode

Secret<T> is an opaque type — print(JWT_SECRET) prints "***", JSON serialization redacts it, the log.info(...) calls strip it from structured fields. The only way to expose the value is .expose() — explicit and grep-able for code review.

# Observability with one env var, zero code changes.
OTEL_EXPORTER_OTLP_ENDPOINT=http://jaeger:4318 ./url-shortener
Enter fullscreen mode Exit fullscreen mode

When the env var is set, every HTTP request opens a span that exports to OpenTelemetry. trace_id/span_id are propagated to every log.info(...) inside the handler — you grep Jaeger by trace_id and instantly find every related log line. When the env var is not set, there is zero network overhead — the exporter is a no-op.

You don't have to bolt any of this on. It's already there.

How fast is the thing you just built?

While we're talking production: the api-postgres-python boilerplate in the repo implements the same shortener-shaped CRUD that you wrote in this tutorial, but with FastAPI + SQLAlchemy + asyncpg. Same Postgres, same schema, same endpoints, same JSON shape, same docker compose. From the reproducible bench on v0.10.13 (Intel Core Ultra 7 155H, Docker 29.2.1, 30s sustained, concurrency 10):

Metric Fitz ORM Python + SQLAlchemy Speedup
Memory peak 9.2 MB 51 MB 5.5× leaner
GET /users p50 4.88 ms 37.85 ms 7.76×
GET /users RPS 1944 246 7.91×
GET /users/{id} p50 3.60 ms 31.87 ms 8.85×
GET /users/{id} RPS 2604 296 8.80×
Cold start 0.14 s 0.22 s 1.57×
Image size 131 MB 258 MB 2× lighter

Same machine, same Docker network, same Postgres. The binary you just compiled in step 7 is in that left column. Run bash benchmarks/orm-vs-sqlalchemy/run.sh from the repo to reproduce on your hardware (~5–8 min with hot Docker cache; needs oha + jq). Full methodology and raw output in benchmarks/orm-vs-sqlalchemy/README.md.

What you got

A working URL shortener in ~120 lines of Fitz:

  • HTTP server with OpenAPI 3.1 + Scalar UI.
  • Postgres ORM with typed closure-to-SQL and fitz db diff/migrate for schema.
  • JWT auth + Argon2id passwords.
  • Background jobs with spawn.
  • Native binary built with fitz build.
  • Dockerfile + compose generated from the AST with fitz docker init.
  • fitz deploy wrapping docker build/push and docker compose up.
  • Health checks via @healthz/@readyz, secrets via Secret<T>, observability via OpenTelemetry — all built in.

What you didn't install:

  • FastAPI, Uvicorn, Pydantic.
  • SQLAlchemy, asyncpg, alembic.
  • python-jose, passlib, argon2-cffi.
  • Celery, Redis (for the spawn).
  • OpenTelemetry SDK + auto-instrumentation packages.
  • A Dockerfile linter, a compose generator, a deploy script.

Total external dependencies of your project: 0. Just Fitz and Postgres.

What's missing (for context)

In a real shortener you'd still want:

  • Real users — we hardcoded Ada. A real User table with @table is two minutes more.
  • Rate limiting per user — middleware on top of @authenticated. A @middleware(rate_limit) plus a small Redis/Postgres counter.
  • Custom analytics — beyond click counter, you'd want user agent, referer, etc. Adds in 5 more lines.
  • Background tracking with retryspawn is fire-and-forget. For real retries, the @cron jobs with persistence + retry config (@cron("expr", retry={max: 5, backoff: "exponential"}, store=db)) are already supported — wire a clicks_queue table and a @cron that drains it.

All achievable today with what's in the box.

What comes next in the series

In the next post we add WebSockets to the shortener: a real-time dashboard that watches clicks live, with the same auth, with AsyncAPI generated automatically.

If you got stuck somewhere or built something interesting, drop a note in issues on GitHub — I read every one.

Final repo: the full code of this tutorial is at github.com/Thegreekman76/fitz/tree/main/boilerplates/api-orm-full.

Docs and course: thegreekman76.github.io/fitz
Guide (34 chapters): thegreekman76.github.io/fitz/guide/
Roadmap: docs/roadmap.md
CHANGELOG: CHANGELOG.md

Until the next one.

Top comments (0)