Building a Clojure/Datomic SaaS from Scratch
A hands-on, chapter-by-chapter guide to building a production-grade SaaS application in Clojure and Datomic — from a reproducible development environment all the way to automated CI/CD deployment.
Each chapter is self-contained but builds on the last. Use the sidebar to
navigate, or the arrow keys to move between chapters. Press s or click
the magnifier to search the full text.
What's covered
- A reproducible devcontainer dev environment (Docker, Caddy, Mailpit, TLS)
- Strict compilation to catch reflection and boxed math early
- A web server with Ring, http-kit, and Reitit
- Live reload, DOM-morphing hot reload, and a Hiccup source inspector
- Datomic schema and queries, i18n, and Tailwind styling
- Server-rendered views with Hiccup and E2E testing with Playwright
- Passwordless auth with single-use, HMAC-signed magic links
- An admin dashboard, a hardened asset pipeline (hashing, SRI, CSP), Lighthouse audits, and CI/CD
Start with the primer for what we are building and why, or jump to any topic from the table of contents.
What We Are Building, and Why
This is a book about building a real web application -- a multi-tenant SaaS -- in Clojure and Datomic, server-rendered, from an empty directory to automated deployment. Not a toy, and not a survey of every library that exists. One application, built the way it would actually be built if correctness, security, and the daily experience of working on it all mattered.
This opening chapter does three things: it tells you what the application is, it explains the single principle that drives every technical decision in the book, and it shows you the shape every chapter takes so you know how to read them.
The application: a recipe versioning site
The running example is a recipe versioning site -- think "Git for recipes." Anyone can browse recipes. A signed-in user can fork one, tweak the ingredients and steps, and publish their own version. The app shows the diff between any two versions, the lineage of a recipe ("this carbonara descends from four earlier versions"), and a point-in-time view of how a recipe looked at any moment in its history.
The domain was chosen on purpose, not for novelty. Two of its properties make it an honest test of the stack:
- It is history-shaped. Versions, forks, diffs, and "as of last Tuesday" are not bolted-on features; they are the product. That makes it a natural fit for Datomic, a database that treats time as a first-class dimension and never overwrites the past. We get edit history from
d/historyand point-in-time reads fromd/as-of-- not from a hand-rolled audit table. - It is content-heavy and read-mostly. Recipes are mostly text, mostly read, and benefit from being indexable and fast to first paint. That is exactly the workload server-side rendering is best at, and it lets us justify not reaching for a single-page-app framework.
If your real domain is invoicing or scheduling or analytics, the domain details will differ, but the spine -- a server that renders HTML, a database that remembers everything, passwordless auth, internationalization, a hardened asset pipeline, audited deployment -- transfers directly.
The principle: the best build, not the easiest explanation
Every choice in this book is made to produce the best server-rendered web application we know how to build -- the most correct, the most secure, the fastest, the most pleasant to maintain. That is the only tiebreaker. When a simpler approach would be easier to explain but produce a worse application, we take the harder approach and explain it properly.
This matters because it is the opposite of how most tutorials are written. A tutorial optimizes for the reader's first twenty minutes: it picks whatever is quickest to demonstrate, defers the hard parts, and quietly ships choices you would have to undo in production. This book optimizes for the application you are left holding at the end. Sometimes that means a chapter is harder than it would strictly need to be to "work on my machine," because working on my machine was never the goal.
A few consequences you will see throughout:
- We turn on strict compilation and a strict Content-Security-Policy on day one, and never relax them for convenience.
- We render HTML on the server and progressively enhance it, rather than shipping a client framework -- and where we do add client behavior, it is a few small ES modules under a policy that forbids
eval. - We use Datomic's immutability as a feature, not a constraint to work around.
- We build the developer experience -- live reload, a source inspector that maps rendered HTML back to the Clojure that produced it -- as real infrastructure, early, so it pays off across every later chapter.
That last point shapes the order of the book: developer affordances come as early as their dependencies allow, so you are building the rest of the application with the tools already in hand.
How each chapter is built: problem, options, choice
Every chapter addresses one problem -- one feature, one piece of infrastructure, one decision. And every chapter is built the same way, because that structure is the argument the book is making:
- The problem. What are we actually trying to do, and why does it matter? Stated before any code.
- The options considered. The real alternatives -- including the naive one you would reach for first, and the one a different engineer would defend. Each with its genuine trade-offs, not a strawman.
- The choice, and why. Which option we take, what it costs, and the reasoning that makes that cost worth paying.
This is deliberate. A decision you can see the alternatives behind is a decision you can re-make when your constraints differ from ours. If you are building on a different database, or you must support a client-heavy UI, or your security posture is stricter or looser than ours, the chapter that shows its work tells you exactly which assumption to revisit. A tutorial that only shows the final answer leaves you guessing.
So when you read "we considered X, but chose Y," that is not throat-clearing -- it is the most reusable part of the chapter.
The journey
The book proceeds in roughly five movements. You can read straight through; each chapter assumes the ones before it.
- Foundations. A reproducible dev environment, strict compilation that catches reflection and boxed math from the first commit, your first Ring/http-kit/reitit web server, and -- right away -- live reload, so the feedback loop is tight before there is much to build.
- Data and rendering. Datomic schema and the
java.timebridge; internationalization wired in from the start; styling with Tailwind; server-rendered HTML with Hiccup and the escaping renderer that is our first line of defense against XSS. - The development loop, sharpened. A source inspector that turns a click on the page into a jump to the Clojure that rendered it, and an upgrade of live reload into a per-edit delivery matrix -- morphing the live DOM for view edits, hot-swapping CSS, full reloads only where they are actually required. Then end-to-end testing with Playwright.
- Features. Passwordless authentication with HMAC-signed, single-use magic links; the full email login flow with sessions and a terms gate; and an admin dashboard.
- Production hardening. The asset pipeline -- content hashing, Subresource Integrity, an import map, and a strict Content-Security-Policy; perfect Lighthouse scores enforced in CI; and CI/CD with Forgejo Actions, Podman, and automated deployment.
What you should already know
This book assumes you can read basic Clojure -- the level of Clojure for the Brave and True: functions, the core data structures, let/->/->>, basic protocols and macros, and enough Java interop not to flinch at (.method obj). It assumes basic HTML, CSS, and JavaScript.
It does not assume you have built web applications in Clojure before. Ring, reitit, Datomic, Hiccup, the java.time bridge, content-hashed assets, import maps, DOM morphing, Content-Security-Policy, and the deployment tooling are all introduced as we reach them -- each as its own problem, with its own options and its own justified choice.
Let us start by making the environment itself reproducible.
A Reproducible Clojure Dev Environment with Devcontainers
Why Reproducible Environments Matter
"It works on my machine" is the most expensive sentence in software development. Not because it is hard to fix in the moment, but because it drains time from every new contributor, every OS upgrade, and every debugging session where the root cause turns out to be a missing system dependency.
For a Clojure/Datomic SaaS, the problem compounds. You need a JDK, the Clojure CLI, Node.js (for CSS tooling and browser testing), a mail server for transactional email testing, TLS certificates so your local environment mirrors production, and a reverse proxy to tie it all together. Setting this up manually once is tedious. Keeping it consistent across machines and months of drift is a losing battle.
Devcontainers solve this. You define your entire development environment -- tooling, services, networking -- as code in your repository. Open the project in VS Code, and the environment builds itself. Every time, the same way.
This post walks through a complete devcontainer setup for a Clojure SaaS application. By the end, you will have:
- A Dockerfile with Java, Clojure, and Node.js
- A
devcontainer.jsonthat wires it into VS Code with Calva - A
compose.ymlthat orchestrates your app container alongside supporting services - A Caddy reverse proxy providing local HTTPS with auto-generated TLS certificates
- A Mailpit instance for testing transactional emails
- Everything accessible via
.landomains, just like production uses real domains
Let's build it.
Project Layout
Here is how the devcontainer-related files are organized in the repository:
myapp/
.devcontainer/
Dockerfile
devcontainer.json
importcerts.sh
caddy/
Caddyfile
certificates/
createcerts.sh
openssl.cnf
compose.yml
The .devcontainer/ directory is the standard location that VS Code looks for. The compose.yml lives at the project root because it defines the full development topology, not just the devcontainer itself.
The Dockerfile
The Dockerfile is the foundation. It builds a single image that contains everything you need to develop, test, and debug a Clojure application.
We start from Microsoft's devcontainer base image, which includes utilities that make the VS Code Remote Containers extension work smoothly (the vscode user, sudo, common shell configuration):
FROM mcr.microsoft.com/devcontainers/base:trixie
ARG DEBIAN_FRONTEND=noninteractive
Locale Configuration
Setting up UTF-8 locales properly matters more than you might expect. Clojure applications deal with text constantly, and locale mismatches cause subtle bugs in string handling, sorting, and file I/O:
ENV LANG=en_US.UTF-8
ENV LANGUAGE=en_US:en
ENV LC_ALL=en_US.UTF-8
RUN sed -i -e 's/# en_US.UTF-8 UTF-8/en_US.UTF-8 UTF-8/' /etc/locale.gen && \
dpkg-reconfigure --frontend=noninteractive locales && \
update-locale LANG=en_US.UTF-8
System Tools
Next comes a layer of system utilities. These are not strictly necessary for the application itself, but they make the devcontainer a productive place to live. When you are debugging a network issue at 10pm, you want tcpdump and netcat already installed, not fighting apt-get in a container with no internet:
RUN apt-get update && apt-get install -y \
bat \
btop \
build-essential \
ca-certificates \
curl \
fd-find \
fzf \
git \
gnupg \
htop \
httpie \
inotify-tools \
jq \
libnss3-tools \
netcat-openbsd \
ripgrep \
rlwrap \
tmux \
unzip \
wget \
yq \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
A few highlights: ripgrep and fd-find make searching large codebases fast. inotify-tools is needed for file-watching (hot reload). libnss3-tools provides certutil, which we will use later to import our development CA into Chromium's certificate store. rlwrap gives readline support to the Clojure REPL.
Java (Eclipse Temurin)
Clojure runs on the JVM, so we need a JDK. Eclipse Temurin is the community-driven successor to AdoptOpenJDK and provides production-quality builds:
RUN wget -qO - https://packages.adoptium.net/artifactory/api/gpg/key/public \
| gpg --dearmor | tee /etc/apt/trusted.gpg.d/adoptium.gpg > /dev/null \
&& echo "deb https://packages.adoptium.net/artifactory/deb \
$(awk -F= '/^VERSION_CODENAME/{print$2}' /etc/os-release) main" \
| tee /etc/apt/sources.list.d/adoptium.list \
&& apt-get update \
&& apt-get install -y --no-install-recommends temurin-25-jdk rlwrap \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
ENV JAVA_HOME=/usr/lib/jvm/temurin-25-jdk-amd64
We install from the Adoptium APT repository rather than downloading a tarball. This way the JDK integrates properly with the system (alternatives, man pages) and gets security updates through apt-get upgrade.
Clojure CLI and Tooling
With Java in place, we install the Clojure CLI along with two essential development tools -- Babashka (a fast Clojure scripting runtime) and clj-kondo (a linter):
# Babashka - fast Clojure scripting
RUN curl -s https://raw.githubusercontent.com/babashka/babashka/master/install \
| bash
# clj-kondo - static analysis
RUN curl -s https://raw.githubusercontent.com/clj-kondo/clj-kondo/master/script/install-clj-kondo \
| bash
# Clojure CLI
RUN curl -L -O https://github.com/clojure/brew-install/releases/latest/download/linux-install.sh \
&& chmod +x linux-install.sh \
&& ./linux-install.sh \
&& clojure -M -e '(println "Clojure installed")'
That last line -- clojure -M -e '(println "Clojure installed")' -- is not just a smoke test. It forces the Clojure CLI to download its core dependencies during the image build, so the first clojure invocation inside the container does not hit the network.
Node.js
Node.js is needed for CSS tooling (Tailwind CSS), browser testing (Playwright), and other frontend build tasks. We install it via nvm so we can pin to the LTS version:
ENV NVM_DIR=/usr/local/nvm
RUN mkdir -p "$NVM_DIR" \
&& curl -fsSL https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash \
&& . "$NVM_DIR/nvm.sh" \
&& nvm install --lts \
&& nvm alias default lts/* \
&& ln -s "$NVM_DIR/versions/node/$(nvm version)/bin/node" /usr/local/bin/node \
&& ln -s "$NVM_DIR/versions/node/$(nvm version)/bin/npm" /usr/local/bin/npm \
&& ln -s "$NVM_DIR/versions/node/$(nvm version)/bin/npx" /usr/local/bin/npx \
&& npm install -g @playwright/test tailwindcss @tailwindcss/cli \
&& playwright install --with-deps
ENV NODE_PATH=/usr/local/lib/node_modules
The symlinks from /usr/local/bin/ are important. nvm manages Node through shell functions that only work in login shells, but many tools (VS Code tasks, CI scripts, make) run in non-login shells. The symlinks ensure node, npm, and npx are available everywhere.
The playwright install --with-deps line downloads browser binaries (Chromium, Firefox, WebKit) and their system dependencies. This is a large download, but doing it at build time means browser tests run instantly during development.
devcontainer.json
The devcontainer.json file tells VS Code how to build and connect to the development container:
{
"name": "myapp",
"dockerComposeFile": ["../compose.yml"],
"service": "myapp",
"workspaceFolder": "/workspace",
"customizations": {
"vscode": {
"extensions": [
"betterthantomorrow.calva"
]
}
},
"remoteUser": "root"
}
A few things to note:
dockerComposeFile points to compose.yml at the project root, not a standalone Dockerfile. This is important. When you use Compose, VS Code starts the entire service topology -- your app container plus Caddy, Mailpit, and any other services -- in one operation. You do not need to remember to start supporting services separately.
service names which container VS Code attaches to. The other containers run alongside but VS Code's terminal, file explorer, and extensions all operate inside this one.
workspaceFolder is /workspace, where the project source gets mounted.
Calva is the only required extension. Calva provides Clojure language support, REPL integration, structural editing (paredit), and inline evaluation. It connects to your running application's nREPL server, giving you the ability to evaluate code in the context of your live application, not just a standalone REPL.
remoteUser is root. In a devcontainer that is throwaway by nature, running as root avoids permission headaches with mounted volumes and installed tools. This is a development environment, not production.
compose.yml
The compose.yml defines the full development topology. Here is the structure with each service explained:
The Application Container
services:
myapp:
build:
context: ./.devcontainer
dockerfile: Dockerfile
hostname: myapp
depends_on:
certificates:
condition: service_completed_successfully
entrypoint: ["/bin/bash"]
command: ["-c", "/workspace/.devcontainer/importcerts.sh && tail -f /dev/null"]
environment:
- CA_CERTS=/certificates/rootCA.crt
volumes:
- .:/workspace:cached
- certificates:/certificates
networks:
default:
myapp-network:
The app container waits for the certificates service to complete (more on that below), then runs importcerts.sh to trust the development CA, and finally tail -f /dev/null to keep the container running. VS Code attaches to this container and takes over from there.
The :cached mount flag on the workspace volume tells Docker to optimize for read performance on the host side. This makes file access noticeably faster on macOS, where Docker's filesystem bridging adds latency.
The container joins two networks: default (for inter-service communication) and myapp-network (a dedicated bridge network). This separation keeps the service topology clean and allows the Caddy reverse proxy to reach the application.
TLS Certificate Generation
Local HTTPS matters. If your development environment uses plain HTTP while production uses HTTPS, you will hit bugs related to secure cookies, CORS, mixed content, and redirect behavior that only appear after deployment. Better to catch them during development.
The certificate setup has three parts.
First, a one-shot init container generates a root CA and per-host certificates:
certificates:
image: eclipse-temurin:25-jre-alpine
entrypoint: ["/bin/ash"]
command: "/opt/createcerts.sh"
working_dir: /certificates
volumes:
- certificates:/certificates
- ./certificates/createcerts.sh:/opt/createcerts.sh:ro
- ./certificates/openssl.cnf:/opt/openssl.cnf:ro
This container runs once, creates the certificates in a shared Docker volume, and exits. The script is idempotent -- if the root CA key already exists, it skips everything:
#!/bin/env bash
set -euo pipefail
HOSTS='myapp.lan mailpit.lan'
if [ -e "rootCA.key" ]; then
echo "Certificates already created, skipping."
exit 0
fi
echo "Creating root CA"
openssl genrsa -out rootCA.key 4096
openssl req -x509 -new -nodes -key rootCA.key -sha256 -days 825 \
-out rootCA.crt \
-subj "/C=NL/ST=Utrecht/L=Amersfoort/O=MyApp/OU=IT/CN=MyApp development CA" \
-addext "basicConstraints = CA:TRUE" \
-addext "keyUsage = keyCertSign, cRLSign"
for CN in $HOSTS; do
echo "Creating certificate for $CN"
mkdir -p "$CN"
openssl req -new \
-newkey rsa:2048 -nodes -keyout "$CN/$CN.key" \
-out "$CN/$CN.csr" \
-subj "/C=NL/ST=Utrecht/L=Amersfoort/O=MyApp/OU=IT/CN=$CN" \
-addext "subjectAltName = DNS:$CN"
openssl x509 -req -in "$CN/$CN.csr" \
-CA rootCA.crt -CAkey rootCA.key -CAcreateserial \
-out "$CN/$CN.crt" -days 500 -sha256 \
-extfile <(cat openssl.cnf <(printf "\nDNS.1 = $CN"))
openssl verify -CAfile rootCA.crt -verify_hostname $CN "$CN/$CN.crt"
done
The OpenSSL configuration for the SAN (Subject Alternative Name) extension is minimal:
basicConstraints = CA:FALSE
authorityKeyIdentifier = keyid:always, issuer:always
keyUsage = nonRepudiation, digitalSignature, keyEncipherment, dataEncipherment
subjectAltName = @alt_names
[ alt_names ]
The DNS.1 entry is appended dynamically by the script for each host.
Second, the app container imports the root CA into every trust store it needs:
#!/bin/bash
set -euo pipefail
CERT_FILE="/certificates/rootCA.crt"
if [ ! -f "$CERT_FILE" ]; then
echo "No root CA certificate found, skipping import."
exit 0
fi
# Java applications need the CA in the JVM trust store
echo "Importing root CA into Java trust store..."
keytool -import -file "$CERT_FILE" -alias development -noprompt \
-trustcacerts -keystore "$JAVA_HOME/lib/security/cacerts" \
-storepass changeit || true
# System-level trust (curl, wget, etc.)
echo "Importing root CA into system CA store..."
cp "$CERT_FILE" /usr/local/share/ca-certificates/myapp-dev.crt
update-ca-certificates
# Chromium uses NSS, not the system store
echo "Importing root CA into NSS database (Chromium)..."
rm -rf "$HOME/.pki/nssdb"
mkdir -p "$HOME/.pki/nssdb"
certutil -d "sql:$HOME/.pki/nssdb" -N --empty-password
certutil -d "sql:$HOME/.pki/nssdb" -A -t 'C,,' -n 'MyApp Dev CA' \
-i "$CERT_FILE" -f /dev/null
echo "All certificates imported successfully."
Three trust stores, three import mechanisms. The Java trust store (cacerts) is needed for any HTTP client calls the Clojure application makes (calling external APIs, OIDC discovery). The system CA store covers command-line tools. The NSS database is what Chromium (and therefore Playwright) uses. Missing any one of these causes hard-to-diagnose TLS failures.
Third, the certificates are stored in a named Docker volume:
volumes:
certificates:
driver: local
This means certificates survive container restarts. They are only regenerated if you explicitly delete the volume (docker volume rm). No waiting for certificate generation on every restart.
Caddy Reverse Proxy
Caddy serves as the ingress layer, providing HTTPS termination and routing to backend services:
ingress:
image: docker.io/library/caddy:2-alpine
hostname: ingress
depends_on:
certificates:
condition: service_completed_successfully
volumes:
- ./caddy/Caddyfile:/etc/caddy/Caddyfile:ro
- ./myapp/static:/static:ro
- certificates:/certs
expose:
- "80"
- "443"
networks:
default:
aliases:
- "myapp.lan"
- "mailpit.lan"
myapp-network:
The aliases on the default network are the key detail. They make myapp.lan and mailpit.lan resolve to the Caddy container from within the Docker network. Your Clojure application can call https://myapp.lan and it works -- the request goes to Caddy, which terminates TLS and proxies to the app container.
We use expose (not ports) because we access services through the Docker network, not from the host. This avoids port conflicts with anything else running on your machine.
The Caddyfile itself is straightforward:
myapp.lan {
tls /certs/myapp.lan/myapp.lan.crt /certs/myapp.lan/myapp.lan.key
encode zstd gzip
root * /static
@static file
handle @static {
# Hashed filenames (styles.<hash>.css) are immutable
@hashed path_regexp \.([a-f0-9]{8})\.(css|js)$
header @hashed Cache-Control "public, max-age=31536000, immutable"
# Static assets that rarely change
@assets path *.svg *.png *.jpg *.woff2
header @assets Cache-Control "public, max-age=604800"
file_server
}
handle {
reverse_proxy myapp:3000
}
}
mailpit.lan {
tls /certs/mailpit.lan/mailpit.lan.crt /certs/mailpit.lan/mailpit.lan.key
reverse_proxy mailpit:8025
}
There are two routing strategies in the myapp.lan block. Requests that match a file in /static are served directly by Caddy with appropriate cache headers. Everything else is proxied to the Clojure application on port 3000. This mirrors a common production pattern where static assets are served by a CDN or web server, not the application.
The cache headers distinguish between two kinds of static assets:
- Hashed filenames like
styles.a1b2c3d4.cssget a one-year cache withimmutable. The hash in the filename changes when the content changes, so aggressive caching is safe. - Other assets (images, fonts) get a one-week cache. These change less frequently but their filenames do not include content hashes.
The Mailpit block is simpler -- just TLS termination and a straight proxy to the Mailpit web UI.
Mailpit for Email Testing
Every SaaS application sends email: signup confirmations, password resets, invoice notifications. You need to test this locally without actually sending email to real addresses.
Mailpit is an SMTP server and web UI that captures outgoing email. Your application sends to mailpit:1025 (SMTP), and you view the results at https://mailpit.lan (through Caddy):
mailpit:
image: axllent/mailpit:latest
container_name: myapp-mailpit
expose:
- "1025" # SMTP
- "8025" # Web interface
networks:
- myapp-network
healthcheck:
test: ["CMD-SHELL", "/mailpit readyz"]
interval: 10s
timeout: 5s
retries: 5
In your Clojure application configuration, you point the SMTP host to mailpit and port to 1025. Every email your application sends shows up in the Mailpit web UI instantly, with full HTML rendering, headers, and attachment inspection.
The health check ensures that services depending on Mailpit (indirectly, through your application) do not start sending emails before Mailpit is ready to receive them.
Networks
networks:
myapp-network:
driver: bridge
A dedicated bridge network keeps inter-service communication clean. All services that need to talk to each other join this network. Docker's built-in DNS resolves service names to container IPs automatically, so mailpit:1025 and myapp:3000 just work.
VS Code and Calva Integration
When you open this project in VS Code, the Remote Containers extension detects .devcontainer/devcontainer.json and offers to reopen the project in a container. Accept, and VS Code will:
- Build the Docker image from the Dockerfile (first time only, then cached)
- Start all services defined in
compose.yml - Wait for the certificate init container to complete
- Import the development CA into the app container's trust stores
- Attach VS Code to the app container
- Install the Calva extension inside the container
From there, you start your Clojure application (typically clojure -M:dev or however your project is configured), and Calva connects to the nREPL server. Once connected, you can:
- Evaluate expressions inline -- put your cursor on an expression and hit
Ctrl+Enter(orCmd+Enteron macOS) to evaluate it in the running application - Load files -- save a file and it gets loaded into the running REPL automatically (with a file watcher)
- Inspect values -- evaluate expressions and see results inline in your editor
- Navigate code -- jump to definitions, find references, all powered by clj-kondo's static analysis
This is the core of Clojure development: a live, running application that you modify interactively through your editor.
Putting It All Together
Here is what happens when you open the project for the first time:
- VS Code reads
.devcontainer/devcontainer.json - Docker Compose builds and starts all services
- The
certificatesinit container generates a root CA and per-host TLS certificates, then exits - The app container starts, runs
importcerts.shto trust the CA, and waits - Caddy picks up the certificates and begins serving HTTPS on
myapp.lanandmailpit.lan - Mailpit starts accepting SMTP on port 1025
- VS Code attaches to the app container and installs Calva
From this point, you start your Clojure application and begin developing. The environment provides:
https://myapp.lan-- your application, served through Caddy with TLS, static file serving, and compressionhttps://mailpit.lan-- the Mailpit web UI, showing every email your application sends- A fully configured JDK, Clojure CLI, and Node.js -- ready for application development, CSS builds, and browser testing
- Calva in VS Code -- connected to your running application's REPL for interactive development
And because all of this is defined in files checked into your repository, the next time you (or anyone else) opens the project, they get exactly the same environment. No setup guide to follow. No "which version of Java do I need?" No "I can't get the certificates to work." It just works.
What Comes Next
This post covered the development environment -- the foundation everything else builds on. In future posts in this series, we will build on this foundation: setting up Datomic, structuring the Clojure application, implementing authentication, and deploying to production.
But for now, you have something valuable: a reproducible, fully-featured development environment that mirrors production from day one. Every hour invested in getting this right pays dividends for the lifetime of the project.
Strict Compilation: Catching Reflection and Boxed Math from Day One
Most Clojure projects add performance checks late -- after mysterious slowdowns in production, after profiling reveals a hot path doing reflective method calls. By then the warnings number in the hundreds and fixing them is a grind.
There is a better way. If you wire up strict compilation from the very first commit, you catch reflection warnings and boxed math warnings the moment they appear. One warning at a time is easy to fix. Three hundred is a project.
This post walks through the build hardening setup I use: tools.build with fail-on-warnings, zprint for consistent formatting, and clj-kondo for static analysis. By the end you will have a build that refuses to produce an artifact with performance problems baked in, plus formatting and linting scripts that keep the codebase clean with minimal effort.
The :build Alias
Everything starts in deps.edn. You only need one extra alias:
:build {:deps {io.github.clojure/tools.build {:mvn/version "0.10.7"}}
:ns-default build}
That is it. The :ns-default build tells clojure -T:build to look for functions in a build.clj file at the project root. No build framework, no plugin ecosystem, just a Clojure file you can read and understand.
The Build Namespace
Here is the full build.clj:
(ns build
(:require
[clojure.string :as str]
[clojure.tools.build.api :as b]))
(def lib
'com.myapp/myapp)
(def version
"0.1.0")
(def class-dir
"target/classes")
(def uber-file
"target/myapp.jar")
(def basis
(delay (b/create-basis {:project "deps.edn"})))
(defn clean
[_]
(b/delete {:path "target"}))
(defn- fail-on-warnings!
"Scan stderr for reflection/boxed-math warnings from our code and throw."
[s]
(let [hits (->> (str/split-lines s)
(filter #(and (or (str/includes? % "Reflection warning,")
(str/includes? % "Boxed math warning,"))
(str/includes? % "myapp/")))
(take 50)
(vec))]
(when (seq hits)
(throw (ex-info "Performance warnings detected — add type hints to fix." {:warnings hits})))))
(defn compile-strict
"AOT-compile src with *warn-on-reflection* and *unchecked-math* :warn-on-boxed,
then fail if any warnings from our code were emitted. compile-clj runs in a
subprocess, so we capture stderr via :err :capture and scan it."
[_]
(let [{:keys [err]} (b/compile-clj
{:basis @basis
:src-dirs ["src"]
:class-dir class-dir
:err :capture
:bindings {#'*warn-on-reflection* true
#'*unchecked-math* :warn-on-boxed}})]
(when err
(binding [*out* *err*]
(print err)
(flush))
(fail-on-warnings! err))
(println "compile-strict: OK")))
(defn uber
[_]
(clean nil)
(b/copy-dir
{:src-dirs ["src" "resources"]
:target-dir class-dir})
(compile-strict nil)
(b/uber
{:class-dir class-dir
:uber-file uber-file
:basis @basis
:main 'myapp.core}))
Let me break down the important parts.
Delayed Basis
(def basis
(delay (b/create-basis {:project "deps.edn"})))
The basis (resolved dependency tree) is wrapped in a delay so it is only computed when actually needed. If you call clojure -T:build clean, there is no reason to resolve the entire classpath. Small thing, but it keeps the fast path fast.
The Two Compiler Flags
The heart of strict compilation is two bindings passed to compile-clj:
:bindings {#'*warn-on-reflection* true
#'*unchecked-math* :warn-on-boxed}
*warn-on-reflection* tells the Clojure compiler to emit a warning any time it cannot resolve a Java method or field call at compile time. Without a type hint, the compiler falls back to runtime reflection -- scanning the class hierarchy on every call. This is slow (orders of magnitude slower than a direct call) and completely avoidable with a type hint.
Concretely, a "type hint" is a ^Class metadata tag that tells the compiler what type a value is, so it can compile a direct method call instead of a reflective lookup:
;; Reflection warning: compiler does not know what `s` is, so the
;; `.length` call is resolved by scanning the class at runtime.
(defn char-count [s] (.length s))
;; => Reflection warning ... call to method length can't be resolved.
;; Hinted: `^String` tells the compiler `s` is a String, so `.length`
;; compiles to a direct, warning-free call.
(defn char-count [^String s] (.length s))
The hint goes on the argument (or on a let binding, or as a ^Type tag in front of any expression). That one annotation is the entire fix the build is asking for.
*unchecked-math* :warn-on-boxed warns when a math operation forces boxing of primitive values. Boxing means wrapping a primitive long or double into a Long or Double object, which means heap allocation on what should be a register operation. In hot loops this is death by a thousand cuts.
Why compile-clj Needs :err :capture
Here is a subtlety: b/compile-clj runs compilation in a subprocess. The warnings go to stderr of that subprocess. By default they scroll past and disappear. The :err :capture option collects them into a string so we can inspect them programmatically.
fail-on-warnings! -- The Gate
(defn- fail-on-warnings!
"Scan stderr for reflection/boxed-math warnings from our code and throw."
[s]
(let [hits (->> (str/split-lines s)
(filter #(and (or (str/includes? % "Reflection warning,")
(str/includes? % "Boxed math warning,"))
(str/includes? % "myapp/")))
(take 50)
(vec))]
(when (seq hits)
(throw (ex-info "Performance warnings detected — add type hints to fix." {:warnings hits})))))
This function scans the captured stderr line by line. It filters for lines that are both a warning (reflection or boxed math) and come from our own code (the myapp/ namespace prefix). That second filter is important -- third-party libraries will emit their own warnings and you cannot fix those. You only fail on warnings you can actually act on.
The (take 50) is a safety valve. If you somehow accumulate a huge number of warnings (maybe you added a new dependency that triggered transitive compilation), you get the first 50 rather than a wall of text.
When any hits are found, it throws an ex-info with the warnings attached as data. The build fails. No jar is produced. You fix the type hints, run again, and move on.
The Uberjar Pipeline
(defn uber
[_]
(clean nil)
(b/copy-dir
{:src-dirs ["src" "resources"]
:target-dir class-dir})
(compile-strict nil)
(b/uber
{:class-dir class-dir
:uber-file uber-file
:basis @basis
:main 'myapp.core}))
The uberjar build is four steps: clean, copy sources and resources, compile strictly, then package. The strict compilation is not an optional lint step -- it is part of the build pipeline. You cannot produce a jar without passing the check. This is the key design decision. Making it part of the artifact build means it can never be skipped or forgotten.
Run it with:
clojure -T:build uber
Code Formatting with zprint
Consistent formatting eliminates an entire class of review noise. I use zprint with a .zprintrc at the project root:
{:width 100
:style [:community :respect-bl :sort-require]
:map {:comma? false :force-nl? true}
:vector {:respect-nl? true}
:list {:hang? false :indent 2 :indent-arg 2}
:pair {:force-nl? true}
:fn-gt2-force-nl #{:fn}
:fn-force-nl #{:noarg1-body :noarg1 :force-nl-body :force-nl :flow :flow-body :binding}
:fn-map {"cond" [:pair-fn {:list {:respect-nl? true}}]
"def" :arg1-force-nl-body
"defn" :arg1-force-nl-body
"defn-" :arg1-force-nl-body
"try" :flow-body
"d/q" [:hang {:vector {:respect-nl? true}}]
"->" [:noarg1-body {:list {:hang? true}}]
"->>" [:noarg1-body {:list {:hang? true}}]
"some->" [:noarg1-body {:list {:hang? true}}]
"some->>" [:noarg1-body {:list {:hang? true}}]
"cond->" [:arg1-pair-body {:list {:hang? true}}]
"cond->>" [:arg1-pair-body {:list {:hang? true}}]
"assoc" [:arg1-pair {:list {:hang? true}}]}}
A few choices worth calling out:
:style [:community :respect-bl :sort-require]-- Start with community defaults, respect intentional blank lines (they carry meaning), and sortrequireclauses alphabetically.:map {:comma? false :force-nl? true}-- No commas in maps (this is Clojure, not JSON), and every key-value pair on its own line.:list {:hang? false :indent 2 :indent-arg 2}-- Disable hanging indentation globally. This is opinionated but it means function bodies always indent consistently at 2 spaces rather than aligning to the first argument.- The
:fn-map-- Custom formatting for specific forms. Threading macros (->,->>, etc.) andassocget hanging enabled because they read better that way.condgets pair formatting. Datomic queries (d/q) get special vector handling because query vectors have their own structure.
The reformat script applies zprint across the entire codebase:
#!/usr/bin/env bash
cd "$(dirname "$0")"
find src dev test -name '*.clj' -o -name '*.cljc' -o -name '*.edn' \
| xargs -P4 -I{} zprint '{:search-config? true}' -w {}
The -P4 runs four parallel zprint processes. The {:search-config? true} tells each invocation to walk up the directory tree to find the .zprintrc file. The -w flag writes the formatted output back to the file in place.
Run it after every edit:
./reformat
Static Analysis with clj-kondo
clj-kondo catches bugs, style issues, and questionable patterns at lint time without running your code. Here is the .clj-kondo/config.edn:
{:linters
{;; Documentation
:missing-docstring {:level :warning}
:docstring-no-summary {:level :warning}
:docstring-leading-trailing-whitespace {:level :warning}
;; Correctness
:shadowed-var {:level :warning}
:condition-always-true {:level :warning}
:equals-float {:level :warning}
:used-underscored-binding {:level :warning}
;; Consistency
:equals-expected-position {:level :warning
:position :first
:only-in-test-assertion true}
:unsorted-required-namespaces {:level :warning}
:warn-on-reflection {:level :warning
:warn-only-on-interop true}
;; Simplification
:redundant-fn-wrapper {:level :warning}
:redundant-call {:level :warning}
:def-fn {:level :warning}
:single-key-in {:level :warning}
:unused-alias {:level :warning}
:plus-one {:level :warning}
:minus-one {:level :warning}}}
The linters fall into four categories.
Documentation. Every public function needs a docstring. When you are the only person on the project, future-you is the audience for these docstrings. They cost seconds to write and save minutes of re-reading code later.
Correctness. Shadowed variables are almost always a bug. Floating-point equality is always a bug. A condition that is always true is dead code at best and a logic error at worst.
Consistency. In test assertions, the expected value comes first: (is (= expected actual)). Require clauses are sorted alphabetically. These are small things that add up to a codebase that reads the same everywhere.
Simplification. Redundant function wrappers (#(f %) instead of just f), unnecessary (get-in m [:k]) instead of (get m :k), (+ x 1) instead of (inc x). These are not bugs but they are noise, and clj-kondo catches them automatically.
The :warn-on-reflection linter with :warn-only-on-interop true is a nice complement to the compile-time check. It flags missing type hints during editing, before you even run the build. The :warn-only-on-interop true setting avoids false positives by only warning on actual Java interop calls, not every untyped binding.
The lint script is minimal:
#!/usr/bin/env bash
cd "$(dirname "$0")"
clj-kondo --lint src test
Run it:
./lint
A Custom Hook for defn-
clj-kondo's built-in :missing-docstring linter only checks defn, not defn- (private functions). If you want docstrings on private functions too -- and you should, because private does not mean self-explanatory -- you need a hook.
Create .clj-kondo/hooks/missing_docstring.clj:
(ns hooks.missing-docstring
(:require [clj-kondo.hooks-api :as api]))
(defn check-defn- [{:keys [node]}]
(let [children (rest (:children node))
name-node (first children)
after-name (second children)]
(when (and name-node after-name
(not (api/string-node? after-name)))
(api/reg-finding!
(assoc (meta name-node)
:message "Missing docstring."
:type :missing-docstring)))))
Then register it in config.edn:
:hooks
{:analyze-call {clojure.core/defn- hooks.missing-docstring/check-defn-}}
This walks the AST of every defn- form: if the token after the function name is not a string node (i.e., not a docstring), it registers a finding. Simple, effective.
Putting It Together
Your project now has three scripts and a build function:
| Command | What it does |
|---|---|
./reformat | Format all Clojure files with zprint |
./lint | Static analysis with clj-kondo |
clojure -T:build compile-strict | AOT compile with warnings-as-errors |
clojure -T:build uber | Build uberjar (includes compile-strict) |
The workflow is:
- Write code.
- Run
./reformatto fix formatting. - Run
./lintto catch static issues. - Run
clojure -T:build uberto produce an artifact, which will fail if any reflection or boxed math warnings exist in your code.
These checks are fast (seconds, not minutes) and deterministic. Wire them into CI so they run on every push, and you have a codebase that stays clean without discipline -- the tools enforce it.
What You Have Now
After this setup, you have:
- A
tools.buildconfiguration that AOT-compiles your code with*warn-on-reflection*and*unchecked-math* :warn-on-boxedenabled, and fails the build if any warnings come from your namespaces. - A zprint configuration that formats your Clojure code consistently, including custom rules for threading macros, Datomic queries, and map formatting.
- A clj-kondo configuration with linters for documentation, correctness, consistency, and code simplification, plus a custom hook for
defn-docstrings. - Shell scripts to run formatting and linting with a single command.
The investment is small -- one build.clj file, two config files, two shell scripts. The payoff compounds over the life of the project. Every reflection warning you catch now is one you never debug in production. Every formatting argument you never have (even with yourself) is time saved. Every lint warning is a potential bug caught before it ships.
Start strict. Stay strict. Your future self will thank you.
Your First Clojure Web Server: Ring, http-kit, and Reitit
You have Clojure installed and a project skeleton. Now you need to serve HTTP requests. In most ecosystems this means picking a framework -- Rails, Django, Express -- and letting it dictate your project structure. Clojure takes a different approach. You compose small, focused libraries into your own stack. This gives you exactly what you need and nothing you don't.
In this post we will build a running web server with routing, configuration, and a clean development workflow. By the end you will have a curl-able health endpoint and a REPL you can use to start, stop, and inspect your server without leaving your editor.
The Stack
Here is what we are using and why:
- Ring -- The HTTP abstraction. Requests and responses are plain Clojure maps. Middleware are functions that wrap handlers. This is the foundation that nearly every Clojure web library builds on.
- http-kit -- A fast, lightweight HTTP server with WebSocket support. It implements the Ring spec, starts in milliseconds, and stays out of your way.
- Reitit -- Data-driven routing from Metosin. Routes are vectors, not macros. You can inspect, transform, and test your route tree as data.
- Aero -- Configuration as EDN with profile support (
:dev,:prod). Environment variables, defaults, and profile switching without a framework.
deps.edn: Your Project File
Clojure projects use deps.edn to declare dependencies and paths. Here is the relevant subset for our web server:
{:paths ["src" "resources"]
:deps {org.clojure/clojure {:mvn/version "1.12.4"}
;; Web server
ring/ring-core {:mvn/version "1.15.3"}
http-kit/http-kit {:mvn/version "2.8.0"}
metosin/reitit-ring {:mvn/version "0.10.0"}
;; JSON
metosin/jsonista {:mvn/version "0.3.12"}
;; Secure random (dev key generation, tokens)
crypto-random/crypto-random {:mvn/version "1.2.1"}
;; Logging
org.clojure/tools.logging {:mvn/version "1.3.0"}
ch.qos.logback/logback-classic {:mvn/version "1.5.16"}
;; Configuration
aero/aero {:mvn/version "1.1.6"}}
:aliases
{:dev {:extra-paths ["dev"]
:extra-deps {org.clojure/tools.namespace {:mvn/version "1.5.1"}
ring/ring-devel {:mvn/version "1.15.3"}
nrepl/nrepl {:mvn/version "1.5.1"}
cider/cider-nrepl {:mvn/version "0.58.0"}}}
:repl {:main-opts ["-m" "nrepl.cmdline" "--port" "7888" "--interactive"
"--middleware" "[cider.nrepl/cider-middleware]"]}}}
A few things to notice:
:pathstells Clojure where to find source code (src) and resources like config files (resources).:depslists your runtime dependencies with explicit Maven versions. No lock files, no version ranges. You pin exactly what you want.:aliasesdefine optional configurations. The:devalias adds adevdirectory to the classpath and pulls in development-only dependencies like nREPL andtools.namespace. The:replalias configures nREPL to start on port 7888 with CIDER middleware for editor integration.
To start a REPL with both aliases active:
clj -M:dev:repl
This gives you a running nREPL server that your editor (Emacs/CIDER, VS Code/Calva, IntelliJ/Cursive) can connect to.
Ring: Requests and Responses as Maps
Ring is not a framework. It is a specification. An HTTP request is a Clojure map:
{:request-method :get
:uri "/health"
:headers {"accept" "application/json"}
:query-string nil}
A handler is a function that takes a request map and returns a response map:
{:status 200
:headers {"Content-Type" "application/json"}
:body "{\"status\":\"ok\"}"}
That is the entire abstraction. No magic, no inheritance, no annotations. A request comes in as data, a response goes out as data. You can construct and test these maps in the REPL without starting a server.
Middleware
Middleware is a function that takes a handler and returns a new handler, typically adding behavior before or after the original handler runs. Here is the shape:
(defn wrap-something [handler]
(fn [request]
;; Do something before
(let [response (handler modified-request)]
;; Do something after
response)))
Ring ships with middleware for common needs: parsing query parameters, managing sessions, handling cookies. You compose them into a stack, and each request flows through the layers.
Configuration with Aero
Before we write routes, we need configuration. Aero reads an EDN file and supports profile-based values. Create resources/config.edn:
{:server {:port 3000
:host #profile {:dev "0.0.0.0"
:prod "127.0.0.1"}}
:base-url #profile {:dev "https://myapp.lan"
:prod "https://myapp.example.com"}
:smtp {:host #profile {:dev "mailpit" :prod "localhost"}
:port #profile {:dev 1025 :prod 25}
:from "noreply@myapp.example.com"}
:session-key #env "SESSION_KEY"
:signing-key #env "SIGNING_KEY"}
The #profile reader tag selects a value based on the active profile. In dev, the server binds to 0.0.0.0 (all interfaces); in prod, to 127.0.0.1 (localhost only, behind a reverse proxy). The #env tag reads from environment variables.
:base-url and :smtp are placeholders for now -- we use them later for magic-link emails (the email login-flow chapter). They live in config from the start so every environment has them, and so the config test we write in the testing chapter can assert their presence.
Now the configuration namespace:
(ns myapp.config
(:require
[aero.core :as aero]
[clojure.java.io :as io]
[crypto.random :as random]))
(defn generate-session-key
"Generate a secure random 16-byte key for session encryption."
[]
(random/bytes 16))
(defn- active-profile
"Returns the active config profile, defaulting to :dev."
[]
(keyword (or (System/getenv "MYAPP_PROFILE") "dev")))
(defn- resolve-keys
"Convert string keys to bytes, generate dev keys when not configured."
[config]
(-> config
(update :session-key
(fn [^String k]
(or (when k (.getBytes k "ISO-8859-1"))
(do (println "Warning: Generating random session key (dev mode)")
(println "Warning: Sessions will not survive server restart")
(generate-session-key)))))))
(defn load-config
"Load and resolve config.edn for the given profile."
([] (load-config (active-profile)))
([profile]
(-> (io/resource "config.edn")
(aero/read-config {:profile profile})
resolve-keys)))
(def config
"Delayed config map. Deref triggers a one-time load."
(delay (load-config)))
(defn get-config
"Get configuration value by path."
[& path]
(get-in @config path))
Key design decisions here:
configis adelay, not eagerly loaded at compile time. This matters because you don't want configuration loading to happen when the namespace is compiled -- only when it's first needed at runtime.get-configtakes a variable path, so(get-config :server :port)drills into nested maps naturally.- Dev mode generates random keys when environment variables are not set. This means you can start the REPL without configuring anything, but sessions won't survive a restart. Good enough for development, impossible to forget in production (it will fail loudly if
SESSION_KEYis not set).
Routing with Reitit
Reitit represents routes as data -- nested vectors that describe your URL tree. Here is the routes namespace:
(ns myapp.web.routes
(:require
[myapp.config :as config]
[myapp.web.handler :as handler]
[reitit.ring :as ring]
[ring.middleware.keyword-params :as keyword-params]
[ring.middleware.params :as params]
[ring.middleware.session :as session]
[ring.middleware.session.cookie :as cookie]
[ring.util.response :as response]))
The Route Tree
(def routes
[[""
["/" {:get handler/home}]
["/health"
{:get (fn [_] (handler/json-response {:status "ok"}))}]
["/auth/request" {:post handler/request-magic-link}]
["/auth/verify" {:get handler/verify-magic-link}]
["/dashboard" {:get handler/dashboard}]]])
Each route is a vector of [path data]. The data map associates HTTP methods with handler functions. Routes nest naturally -- you can group related paths under a common prefix.
The /health endpoint is defined inline since it's trivial: return {"status":"ok"} with a 200. This is the endpoint your load balancer or monitoring tool will poll.
The json-response helper is straightforward:
(defn json-response
[data & {:keys [status] :or {status 200}}]
{:status status
:headers {"Content-Type" "application/json"
"Cache-Control" "no-store"}
:body (json/write-value-as-string data)})
It builds a plain Ring response map: set the content type, serialize the data to JSON, done. The json alias is jsonista.core (the metosin/jsonista dependency from our deps.edn); write-value-as-string turns a Clojure value into a JSON string.
The Middleware Stack
Middleware wraps around the entire application. Order matters -- the first middleware listed is the outermost layer:
(def ^:private app*
(delay
(ring/ring-handler
(ring/router routes)
(ring/create-default-handler)
{:middleware [[params/wrap-params]
[keyword-params/wrap-keyword-params]
[session/wrap-session
{:store (cookie/cookie-store
{:key (config/get-config :session-key)})
:cookie-name "session"
:cookie-attrs {:http-only true
:secure true
:same-site :lax
:max-age (* 30 24 60 60)}}]]})))
(defn app
"Ring handler entry point."
[request]
(@app* request))
Let's walk through the middleware stack:
wrap-params-- Parses query string and form body parameters into a:paramsmap on the request.wrap-keyword-params-- Converts string parameter keys to keywords, so you get(:email params)instead of(get params "email").wrap-session-- Manages sessions using encrypted cookies. The session key comes from our config. Cookie attributes are set for security:http-onlyprevents JavaScript access,securerequires HTTPS,same-site :laxprovides CSRF protection, andmax-agesets a 30-day expiry.
Two structural choices worth noting:
app*is adelay, just like our config. This prevents the middleware stack from being built at compile time (which would try to read config, which might not be available yet). It's built once on the first request.appis a plain function that derefs the delay. The server receives#'routes/app(a var reference), which means you can redefineappat the REPL and the server picks up changes without restarting.
The Entry Point
The myapp.core namespace ties everything together:
(ns myapp.core
(:require
[myapp.config :as config]
[myapp.web.routes :as routes]
[org.httpkit.server :as http-kit])
(:gen-class))
(defonce server (atom nil))
(defn start-server!
"Start the web server."
[]
(let [port (config/get-config :server :port)
host (config/get-config :server :host)]
(println (str "Starting server on " host ":" port "..."))
(reset! server
(http-kit/run-server
#'routes/app
{:port port
:ip host}))
(println (str "Server running at http://" host ":" port))
@server))
(defn stop-server!
"Stop the web server."
[]
(when-let [stop-fn @server]
(println "Stopping server...")
(stop-fn :timeout 100)
(reset! server nil)
(println "Server stopped")))
(defn restart-server!
"Restart the web server."
[]
(stop-server!)
(start-server!))
(defn -main
"Application entry point."
[& _args]
(start-server!))
Several important patterns here:
defonce for the server atom. Using defonce instead of def means reloading this namespace at the REPL won't reset the atom. If you have a running server, the stop function stays accessible. Without defonce, reloading the namespace would create a new atom (set to nil), and you'd lose the ability to stop the old server -- it would keep running on the port with no way to shut it down cleanly.
Var reference with #'routes/app. We pass #'routes/app (the var itself) to http-kit, not routes/app (the current value). This is the key to REPL-driven development with http-kit. When you redefine app or any function it calls, the server automatically uses the new definition because it dereferences the var on each request. Without the #', you'd have to restart the server after every code change.
http-kit's stop function. http-kit/run-server returns a zero-argument function. Calling it stops the server. We store this function in the atom and call it in stop-server!. The :timeout 100 gives existing connections 100ms to complete before being closed.
(:gen-class). This tells the Clojure compiler to generate a Java class with a static main method, which is what java -jar expects when running the uberjar in production.
REPL-Driven Development
The dev/user.clj file is automatically loaded when you start a REPL with the :dev alias (because dev is on the classpath, and Clojure looks for a user namespace on startup). This is where you put development shortcuts:
(ns user
"Development REPL helpers"
(:require
[myapp.core :as core]))
(println "Loading development environment...")
(println "Available commands:")
(println " (start!) - Start the server")
(println " (stop!) - Stop the server")
(println " (restart!) - Restart the server")
(defn start!
"Start the dev server"
[]
(core/start-server!))
(defn stop!
"Stop the server"
[]
(core/stop-server!))
(defn restart!
"Restart the server"
[]
(core/restart-server!))
The workflow looks like this:
- Start your REPL:
clj -M:dev:repl - In the REPL (or from your editor):
(start!) - The server starts on port 3000
- Edit code in your editor, save the file
- Evaluate the changed namespace (your editor sends it to the REPL)
- Changes take effect immediately -- no restart needed, thanks to the var reference
This is profoundly different from the edit-compile-restart cycle. You keep the server running, keep your application state, and see changes in milliseconds. Need to test a handler? Call it directly in the REPL with a fake request map:
(require '[myapp.web.routes :as routes])
(routes/app {:request-method :get
:uri "/health"
:headers {}})
;; => {:status 200,
;; :headers {"Content-Type" "application/json", "Cache-Control" "no-store"},
;; :body "{\"status\":\"ok\"}"}
No browser, no curl, no HTTP overhead. Just call the function with data and inspect the result.
Try It
Start the REPL and server:
clj -M:dev:repl
(start!)
;; Starting server on 0.0.0.0:3000...
;; Server running at http://localhost:3000
Hit the health endpoint:
curl http://localhost:3000/health
# {"status":"ok"}
Stop it:
(stop!)
;; Stopping server...
;; Server stopped
What You Have Now
You have a running Clojure web server with:
- Ring handling the HTTP abstraction -- requests and responses as plain maps
- http-kit serving requests on a configurable host and port
- Reitit routing requests to handler functions based on path and method
- Aero loading environment-specific configuration from a single EDN file
- A clean entry point in
myapp.corewith start/stop/restart lifecycle - A REPL workflow where code changes take effect immediately without restarting
This is a foundation you can build on. The pieces are independent and composable. When you need authentication, you add middleware. When you need more routes, you extend the route vector. When you need a database, you add it to your startup sequence. Nothing forces you into a structure you don't want.
Next, we sharpen the development loop with live reload so changes show up in the browser the instant you save. Soon after, a test suite -- covering config loading, routing, and the middleware stack -- lets us keep refactoring this foundation without fear of breaking it.
Live Reload: A File Watcher and WebSocket Browser Refresh
The REPL closes the feedback gap for return values: you change a function, evaluate it, and the result appears in your editor. But a web application has a second surface that the REPL does not touch -- the browser. "Seeing the result" of a markup change or a handler change means the page updating, not a value printing. So the question for a server-rendered app is: how do you close the gap between saving a file and seeing it reflected in the browser, without leaving your flow state?
There are three honest answers, and only one of them is good.
(a) Refresh by hand. Save, alt-tab to the browser, hit reload. It works, and it costs you a context switch on every single edit. Over a day of iterating on markup that tax is enormous, and worse, it pulls you out of the editor every few seconds. We can do better.
(b) clojure.tools.namespace full refresh. The standard reloaded workflow scans the whole source tree, computes the dependency graph, and reloads changed namespaces in dependency order. It is powerful, but it is also slow (it walks everything, every time) and occasionally surprising -- it tears down and rebuilds namespaces, which can wipe defonce state you were relying on. For a large app where you might edit one file, that is a lot of machinery and a lot of risk for one save.
(c) A targeted file watcher plus a WebSocket push. Watch the source tree. When one file changes, load exactly that file and tell the browser to refresh. No tree scan, no dependency graph, no defonce reset -- just the one file you actually touched, and a message down a socket.
For a server-rendered app where you edit one file at a time, (c) wins decisively. The common case -- tweak a handler, save, see the page -- is a single load-file and a single socket message, both effectively instantaneous. We do not pay for the rest of the tree because we never look at it. That is the system this chapter builds: a Java NIO file watcher that detects the change and reloads the one file, and a WebSocket connection that tells the browser to reload itself.
dev/ classpath separation
Before any of the watcher code, one structural decision underpins all of it: none of this infrastructure can be allowed to reach production. We get that guarantee for free from the classpath.
All of the dev tooling lives in a dev/ directory that is on the classpath only in development, via a deps.edn alias:
;; deps.edn (excerpt)
:aliases
{:dev {:extra-paths ["dev"]
:extra-deps {org.clojure/tools.namespace {:mvn/version "1.5.1"}
org.clojure/tools.reader {:mvn/version "1.5.0"}
ring/ring-devel {:mvn/version "1.15.3"}
nrepl/nrepl {:mvn/version "1.5.1"}
cider/cider-nrepl {:mvn/version "0.58.0"}}}}
Because dev/ is an extra path, the namespaces in it -- hot-reload, dev-reload, user -- exist on the classpath when you develop with the :dev alias and do not exist at all in a production build. This is not a convention or a runtime flag; it is a structural fact about what code is present. We will lean on it twice: the WebSocket route and the browser-side script both check, at runtime, whether the dev namespace can be resolved, and both become inert when it cannot. requiring-resolve returning nil in production is the same guarantee viewed from the calling side.
The File Watcher
The core of the system is built on Java NIO's WatchService. It monitors the src/ tree and reacts when a file is modified.
(ns hot-reload
"A namespace for hot code reloading during development."
(:require
[clojure.tools.logging :as log]
[dev-reload :as dev-reload]
[myapp.core :as core])
(:import
[java.nio.file FileSystems Files Path StandardWatchEventKinds WatchEvent]
[java.nio.file.attribute BasicFileAttributes]
[java.util.function BiPredicate]))
(set! *warn-on-reflection* true)
(defonce ^{:doc "Holds the active file watcher state, or nil."} file-watcher (atom nil))
The file-watcher atom holds the WatchService and the daemon thread that polls it. It is a defonce so that reloading this namespace does not restart the watcher out from under you -- you start and stop it explicitly via the functions below.
Detecting and loading a changed file
For now there is exactly one thing we care about: a changed .clj file. A cheap predicate identifies it:
(defn- clj-file?
"Returns true if the path has a .clj extension."
[^java.nio.file.Path path]
(.endsWith (str path) ".clj"))
When a .clj file changes, we load it and then tell the browser to do a full reload:
(defn- load-changed-file
"Loads a changed file."
[{:keys [event-type path]}]
(when (= event-type :modify)
(when (clj-file? path)
(let [file-path (str path)
start-time (System/nanoTime)]
(log/info "File changed" {:file-path file-path})
(try
(load-file file-path)
(dev-reload/notify-reload!)
(let [duration-seconds (/ (- (System/nanoTime) start-time) 1e9)]
(log/info "Successfully reloaded file"
{:file-path file-path
:duration-seconds duration-seconds}))
(catch Exception e
(let [duration-seconds (/ (- (System/nanoTime) start-time) 1e9)]
(log/error e "Error reloading file"
{:file-path file-path
:duration-seconds duration-seconds}))))))))
A few things to note. We load-file the one path that changed -- not a tree refresh, just that file. We then call dev-reload/notify-reload!, which pushes a reload message to every connected browser. The whole thing is wrapped in a timing block: during development you want to know how long a reload takes, and if it ever creeps above a fraction of a second something is wrong and you want to catch it early.
The failure path is the important detail. A broken file -- a syntax error, an unbalanced paren -- does not crash the watcher. The exception is caught and logged, the watcher keeps running, and your next save gets a fresh chance to succeed. A crash-on-error watcher that you have to restart every time you fat-finger a paren would defeat the entire purpose.
This
load-changed-fileis deliberately the basic path: one branch, full reload. Once we have real server-rendered views and an asset pipeline, a later chapter widens thiscondso that different edits get different responses -- but the skeleton is exactly this.
Registering directories and the watch loop
The WatchService works at the directory level: you register directories, not individual files, and you get events for the files inside them. So on startup we walk the src/ tree and register every subdirectory.
(defn start-file-watcher
"Start file watcher using Java NIO WatchService."
[]
(when @file-watcher (stop-file-watcher))
(let [ws (.newWatchService (FileSystems/getDefault))
kinds (into-array
[StandardWatchEventKinds/ENTRY_MODIFY
StandardWatchEventKinds/ENTRY_CREATE])
_ (doseq [r ["src"]
:when (.exists (java.io.File. ^String r))
^Path dir (->> (Files/find (.toPath (java.io.File. ^String r))
Integer/MAX_VALUE
(reify
BiPredicate
(test [_ _path attrs]
(.isDirectory ^BasicFileAttributes attrs)))
(make-array java.nio.file.FileVisitOption 0))
.iterator
iterator-seq)]
(.register dir ws kinds))
thread (Thread.
(fn []
(try
(loop []
(when-let [watch-key (.take ws)]
(doseq [^WatchEvent event (.pollEvents watch-key)]
(let [^Path changed (.context event)
^Path dir (.watchable watch-key)
full-path (.resolve dir changed)]
(when
(Files/isDirectory full-path (make-array java.nio.file.LinkOption 0))
(.register full-path ws kinds))
(load-changed-file
{:event-type :modify
:path full-path})))
(.reset watch-key)
(recur)))
(catch java.nio.file.ClosedWatchServiceException _)
(catch Exception e (log/error e "File watcher error")))))]
(.setDaemon thread true)
(.start thread)
(reset! file-watcher
{:watch-service ws
:thread thread})
(log/info "File watcher started" {:watch-path "src"})))
The details worth calling out:
- We register both
ENTRY_MODIFYandENTRY_CREATE. When you create a new directory undersrc/-- a new namespace package -- the watch loop sees the create event, notices it is a directory, and registers it too. You never have to restart the watcher to pick up newly added namespaces. - The poll thread is a daemon thread. It dies when the JVM exits, so a forgotten watcher never keeps the process alive.
.takeis blocking. The thread sleeps until there is an event, consuming zero CPU while idle.ClosedWatchServiceExceptionis caught silently. This is the normal shutdown path: when we close the WatchService, the blocking.takethrows this exception, the loop exits, and the thread ends.
We watch src/ here. A later chapter, once the asset pipeline enters the picture, adds the static/ tree as a second root so that built assets trigger refreshes too -- but for code reload, src/ is all we need.
Stopping the watcher is the mirror image:
(defn stop-file-watcher
"Stop the file watcher."
[]
(when-let [{:keys [^java.nio.file.WatchService watch-service]} @file-watcher]
(.close watch-service)
(reset! file-watcher nil)
(log/info "File watcher stopped")))
Closing the WatchService makes the daemon thread's blocking .take throw the ClosedWatchServiceException we catch, so the thread unwinds on its own. Clean and simple.
WebSocket Browser Refresh
The watcher knows that a file changed. The browser needs to be told. That is the job of the dev-reload namespace: it holds the set of connected browser sockets and broadcasts messages to them.
(ns dev-reload
(:require
[clojure.tools.logging :as log]
[jsonista.core :as json]
[org.httpkit.server :as http-kit]))
(set! *warn-on-reflection* true)
;; defonce so reloading this ns keeps live connections instead of orphaning them
;; (the running channels would otherwise be lost to a fresh empty atom).
(defonce websocket-clients
;; All connected dev clients (browser tabs).
(atom #{}))
websocket-clients is a set of http-kit channels, one per connected browser tab. It is a defonce for the same reason the watcher atom is: reloading the dev-reload namespace must keep your live connections, not orphan them into a fresh empty atom and leave every open tab silently disconnected.
The reload notification is a single typed JSON message:
(defn notify-reload!
"Tell every connected browser to do a full reload."
[]
(send-json! @websocket-clients {:type "reload"}))
The send itself has a sharp edge worth handling carefully. http-kit's send! returns false for a channel that has already closed -- without throwing. A naive broadcast that ignored the return value would accumulate dead channels in the set forever. So send-json! prunes a client on both a false return and a thrown exception:
(defn- send-json!
"Send `msg` (a map) to `channels`. http-kit's send! returns false for a closed
channel WITHOUT throwing, so prune on both false and exceptions. Returns the
number of channels the message was actually delivered to."
[channels msg]
(let [s (json/write-value-as-string msg)]
(reduce (fn [n client]
(if (try
(http-kit/send! client s)
(catch Exception e
(log/warn e "Failed to send dev message to client")
false))
(inc n)
(do (remove-client! client) n)))
0
channels)))
(defn remove-client!
"Forget a disconnected client."
[channel]
(swap! websocket-clients disj channel))
It returns the count of channels actually reached, which is handy for logging and, later, for callers that want to know whether anything was listening.
For driving a reload by hand from the REPL, there is a plain trigger:
(defn reload!
"Manual reload trigger for REPL usage."
[]
(log/info "Manual reload triggered")
(notify-reload!))
The socket endpoint itself accepts a connection, registers it, and forgets it on close:
(defn add-client!
"Register a newly connected client."
[channel]
(swap! websocket-clients conj channel))
(defn websocket-handler
"Sets up a /dev/ws connection."
[request]
(http-kit/as-channel
request
{:on-open (fn [channel]
(log/debug "Dev WS client connected")
(add-client! channel)
(http-kit/send! channel (json/write-value-as-string {:type "connected"})))
:on-close (fn [channel status]
(log/debug "Dev WS client disconnected" {:status status})
(remove-client! channel))
:on-error (fn [channel throwable]
(log/error throwable "Dev WS error")
(remove-client! channel))}))
Wiring the route -- and keeping it out of production
The /dev/ws route is registered with requiring-resolve, which returns nil when the dev-reload namespace is not on the classpath:
;; In the route definitions
["/dev/ws"
{:get (fn [request]
(if-let [handler (requiring-resolve 'dev-reload/websocket-handler)]
(handler request)
{:status 404}))}]
The route entry exists in the route table in every build, but in production the dev-reload namespace is absent, requiring-resolve yields nil, and the endpoint returns 404. There is no dev WebSocket server in production because there is no handler to run.
The same guard governs whether the browser even loads the dev script. The base layout emits the dev-reload.js <script> only when the dev namespace resolves:
;; In base-layout, at the end of <body>:
(when (try
(requiring-resolve 'dev-reload/websocket-handler)
(catch Exception _ nil))
(dev-reload-script))
In production the guard yields nil and no dev <script> appears in the rendered HTML. The entire client side of this system is structurally absent from a production page.
The Client Side
The browser-side script (src/myapp/web/dev-reload.js) is small: open the WebSocket, and on a reload message reload the page. The one nicety is that it stashes your scroll position before reloading and restores it after, so a full reload keeps your place rather than snapping you to the top.
const ws = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/dev/ws');
ws.onmessage = function (event) {
const data = JSON.parse(event.data);
if (data.type === 'reload') {
// Stash scroll so the reload doesn't lose your place.
try { sessionStorage.setItem('myapp-dev-scroll', String(window.scrollY)); } catch (e) {}
window.location.reload();
}
};
// Restore scroll after a dev reload (stashed just before reloading).
try {
var savedScroll = sessionStorage.getItem('myapp-dev-scroll');
if (savedScroll !== null) {
sessionStorage.removeItem('myapp-dev-scroll');
window.addEventListener('load', function () { window.scrollTo(0, parseInt(savedScroll, 10) || 0); });
}
} catch (e) {}
ws.onopen = function () { console.log('Dev reload WebSocket connected'); };
ws.onerror = function (error) { console.log('WebSocket error:', error); };
The scroll stash goes into sessionStorage -- it has to survive the page navigation that reload() causes, so an in-memory variable would not do. On the next page load we read it back, scroll to it, and clear it. The full reload is correct and total: the whole page comes back fresh, just where you left it vertically.
The REPL Entry Point
The last piece ties it together into a single command. user.clj loads when you start a REPL and exposes a start!:
(ns user
"Development REPL helpers"
(:require
[myapp.core :as core]
[hot-reload]))
(defn start! [] (hot-reload/start))
(defn reload! [] (hot-reload/reload!))
And hot-reload/start brings up the server and the watcher:
(defn start
"Run this from the REPL to start developing."
[]
(core/start-dev-server)
(log/info "Development server started" {:url "http://localhost:3000"})
(start-file-watcher)
(log/info "Development environment ready"
{:websocket-reload true
:file-watcher true
:watch-path "/src"}))
Open a REPL, type (start!), and the whole loop is live: the server is up, the watcher is watching src/, and any connected browser tab will reload when you save. A later chapter adds two more steps to this same start -- a long-lived Tailwind watcher and a one-time view preload -- once those concerns exist.
Design Decisions
load-file instead of tools.namespace. We load exactly the one file that changed rather than running clojure.tools.namespace.repl/refresh. The tools.namespace approach scans the whole source tree and reloads in dependency order, which is powerful but slow and occasionally surprising -- it can wipe defonce state. For a server-rendered app where you edit one handler or view at a time, loading just that file is faster and more predictable, and it never resets the state we deliberately hold in defonce.
requiring-resolve for dev/prod separation. (requiring-resolve 'dev-reload/websocket-handler) is a runtime classpath check that returns nil when the namespace is absent. This is more reliable than an environment variable or a config flag because it is structurally impossible to accidentally enable dev reload in production -- the code simply is not on the classpath. The route uses it to decide whether to serve the socket; the layout uses it to decide whether to emit the script. One mechanism, applied on both sides.
Where This Goes Next
What we have is correct and useful: save a file, the browser reloads, you keep your scroll position. But a full reload is a blunt instrument. It throws away all page state -- focus, in-progress form input, open <details> -- and rebuilds the page from scratch even when the edit was a one-line markup tweak. That is fine for now, when every edit is a .clj file and the only response we have is "reload the page."
Once we have server-rendered Hiccup views with a client-side morphing layer, a source inspector, and a real asset pipeline with Tailwind, this single full-reload becomes the wrong default for the common case. A later chapter upgrades this one branch into a per-edit delivery matrix: a view edit morphs the live DOM in place (keeping scroll, focus, and open <details>), a CSS rebuild hot-swaps the stylesheet without reloading at all, and only the edits that genuinely require it fall back to a full reload. The watcher and socket you built here are the foundation; the matrix is the refinement.
Datomic for Your SaaS: Schema, Queries, and the java.time Bridge
Most SaaS applications reach for PostgreSQL or MySQL without a second thought. They are battle-tested, well-documented, and familiar. But for an application where the history of data matters -- think accounting, compliance, audit trails -- you quickly find yourself bolting on soft deletes, history tables, and temporal queries. You end up reimplementing, badly, what Datomic gives you out of the box.
Datomic treats data as immutable facts over time. Every transaction is recorded. Every past state of the database is queryable. Nothing is ever truly deleted -- it is retracted, and the retraction itself is a fact. For a SaaS that handles financial data, this is not a nice-to-have. It is the correct data model.
In this post, we will set up Datomic in a Clojure SaaS application: the Peer library, schema design, a wrapper layer that bridges java.time and Datomic's java.util.Date, and a test fixture that gives you a fresh database per test.
The Datomic Peer Library
Datomic offers different deployment models. We use the Peer library, where the application process itself contains the query engine. There is no separate query server to manage -- your app connects directly to the storage backend and runs queries in-process.
In deps.edn:
{:deps {com.datomic/peer {:mvn/version "1.0.7491"}
org.postgresql/postgresql {:mvn/version "42.7.10"}}}
The PostgreSQL driver is there because in production, Datomic Peer stores its data in a SQL database. But during development and testing, we use something much lighter.
Two Storage Backends: Memory and SQL
Datomic's connection URI determines the storage backend. This is one of its most practical features: the same code runs against a throwaway in-memory database during development and a durable SQL-backed database in production. No conditional logic, no test doubles for the database layer.
Here is how we configure it:
;; config.edn
{:database-uri #profile {:dev "datomic:mem://myapp-dev"
:prod #env "DATABASE_URI"}}
In development, datomic:mem://myapp-dev creates an in-memory database that disappears when the process stops. Fast startup, zero infrastructure. In production, the DATABASE_URI environment variable points to something like datomic:sql://myapp?jdbc:postgresql://db-host:5432/datomic, backed by PostgreSQL.
The code that connects to the database does not know or care which backend it is talking to:
(ns myapp.db.core
(:require
[datomic.api :as d]
[myapp.config :as config]
[myapp.db.schema :as schema])
(:import
[java.time Instant]
[java.util Date]))
(defn db-uri []
(config/get-config :database-uri))
(defn create-database! []
(let [uri (db-uri)]
(d/create-database uri)
(let [conn (d/connect uri)]
@(d/transact conn schema/schema)
conn)))
(defn get-connection []
(d/connect (db-uri)))
(defn get-db []
(d/db (get-connection)))
create-database! is idempotent -- calling it when the database already exists is a no-op. It creates the database, connects, and transacts the schema. get-db returns an immutable database value: a snapshot of the database at a point in time. This is a key Datomic concept. The database value you get from (d/db conn) never changes. You can pass it around, query it later, and the results will always be consistent with that moment.
Schema Design
Datomic schema is data. You define attributes as maps and transact them into the database like any other data. There are no DDL statements, no migration files, no schema versioning tools. You add attributes by transacting new attribute definitions.
Here is the user schema:
(ns myapp.db.schema
"Datomic schema definitions.
All entity types and their attributes are defined here as transaction data,
installed on database creation.")
(def user-schema
[{:db/ident :user/id
:db/valueType :db.type/uuid
:db/unique :db.unique/identity
:db/cardinality :db.cardinality/one
:db/doc "Unique user ID"}
{:db/ident :user/email
:db/valueType :db.type/string
:db/unique :db.unique/identity
:db/cardinality :db.cardinality/one
:db/doc "User email address (unique)"}
{:db/ident :user/created-at
:db/valueType :db.type/instant
:db/cardinality :db.cardinality/one
:db/doc "When the user account was created"}
{:db/ident :user/active?
:db/valueType :db.type/boolean
:db/cardinality :db.cardinality/one
:db/doc "Whether the user account is active"}
{:db/ident :user/terms-accepted-at
:db/valueType :db.type/instant
:db/cardinality :db.cardinality/one
:db/doc "When the user accepted terms of service (gates access)"}])
(def schema
"Complete database schema."
user-schema)
A few things to note about this design:
Namespaced attributes. Every attribute is namespaced (:user/email, :user/created-at). In Datomic, attributes are global -- they exist at the database level, not the table level. Namespacing is how you organize them. An entity can have attributes from any namespace. This is more flexible than relational tables but demands discipline in naming.
Identity attributes. Both :user/id and :user/email are marked :db.unique/identity. This means they serve as lookup refs -- you can find an entity by either its UUID or its email. It also means that transacting a new entity with an existing email will upsert (merge into the existing entity) rather than throw a constraint violation. This is intentional behavior worth understanding early.
Cardinality. Every attribute declares :db.cardinality/one or :db.cardinality/many. There is no implicit default. :db.cardinality/many gives you a set-valued attribute -- useful for tags, roles, or any multi-valued relationship.
Value types. Datomic has a fixed set of value types: :db.type/string, :db.type/long, :db.type/boolean, :db.type/instant, :db.type/uuid, :db.type/ref, and others. The :db.type/instant type stores points in time, which brings us to a practical problem.
The java.time Bridge
Datomic's :db.type/instant stores java.util.Date internally. This is a holdover from Datomic's origins -- java.util.Date was the standard JVM date type when Datomic was designed. Modern Java code uses java.time.Instant, which is immutable, thread-safe, and generally superior.
You do not want java.util.Date leaking into your application code. The solution is a thin conversion layer that translates automatically at the boundary. These functions live in the same myapp.db.core namespace shown above (whose :import already pulls in java.time.Instant and java.util.Date):
(defn convert-instants
"Recursively convert Instant to Date (for writing to Datomic)."
[x]
(cond (instance? Instant x) (Date/from x)
(map? x) (into {} (map (fn [[k v]] [k (convert-instants v)])) x)
(vector? x) (mapv convert-instants x)
(set? x) (set (map convert-instants x))
:else x))
(defn convert-dates
"Recursively convert Date to Instant (for reading from Datomic)."
[x]
(cond (instance? Date x) (.toInstant ^Date x)
(map? x) (into {} (map (fn [[k v]] [k (convert-dates v)])) x)
(vector? x) (mapv convert-dates x)
(set? x) (set (map convert-dates x))
:else x))
Both functions walk data structures recursively. convert-instants is used on the way in (before transacting), converting every java.time.Instant to java.util.Date. convert-dates is used on the way out (after pulling or querying), converting every java.util.Date back to java.time.Instant.
The type hints (^Date, ^Instant) are there because we have *warn-on-reflection* enabled -- without them, the .toInstant and .getTime calls would use reflection, which is both slow and triggers compiler warnings.
Wrapped API Functions
With the conversion functions in place, we wrap Datomic's core API to apply them transparently:
(defn transact*
"Like d/transact but converts Instant values to Date in tx-data."
[conn tx-data]
(d/transact conn (mapv convert-instants tx-data)))
(defn pull*
"Like d/pull but converts Date values to Instant in the result."
[db pattern eid]
(convert-dates (d/pull db pattern eid)))
(defn pull-many*
"Like d/pull-many but converts Date values to Instant in results."
[db pattern eids]
(mapv convert-dates (d/pull-many db pattern eids)))
(defn q*
"Like d/q but converts Date values to Instant in result tuples."
[query & args]
(let [results (apply d/q query args)]
(into
#{}
(map (fn [tuple]
(mapv (fn [v]
(if (instance? Date v)
(.toInstant ^Date v)
v))
tuple)))
results)))
The naming convention -- transact*, pull*, q* -- signals that these are enhanced versions of the originals. The rest of the application uses these wrappers exclusively and never touches java.util.Date.
Notice that transact* returns a future (just like d/transact). You deref it with @ when you need to wait for the transaction to complete:
@(db/transact* conn
[{:user/id (java.util.UUID/randomUUID)
:user/email "alice@example.com"
:user/created-at (Instant/now)
:user/active? true}])
The Instant/now value gets transparently converted to a Date before Datomic sees it. When you later pull this entity, the Date comes back as an Instant.
Query Patterns
Datomic queries use Datalog, a declarative query language. If you have used SQL, the mental model is different but not difficult. Instead of thinking in tables and joins, you think in entity-attribute-value triples.
Find an entity by attribute:
;; Find the entity ID for a user by email
(d/q '[:find ?e .
:in $ ?email
:where [?e :user/email ?email]]
db
"alice@example.com")
The . after ?e in the :find clause is a scalar binding -- it returns the single value directly instead of wrapping it in a set of tuples. Without it, you would get #{[12345]} instead of 12345.
Pull entity data:
;; Pull specific attributes
(db/pull* db [:user/email :user/created-at :user/active?] eid)
;; => {:user/email "alice@example.com"
;; :user/created-at #object[java.time.Instant "2025-06-15T12:00:00Z"]
;; :user/active? true}
;; Pull all attributes
(db/pull* db '[*] eid)
Query with results containing dates:
;; Find all users and their creation dates
(db/q* '[:find ?email ?created
:where
[?e :user/email ?email]
[?e :user/created-at ?created]]
db)
;; => #{["alice@example.com" #object[java.time.Instant ...]]}
Because we use q* instead of d/q, every Date in the result tuples is automatically converted to an Instant.
Entity API for navigation:
;; Get a lazy, map-like view of an entity
(let [user (d/entity db [:user/email "alice@example.com"])]
(:user/active? user))
;; => true
The entity API uses lookup refs -- [:user/email "alice@example.com"] -- to find entities by identity attributes. This is often more readable than a separate query when you already know the identity value.
One caveat: d/entity is raw Datomic, so it sits outside our conversion wrappers. Reading a non-temporal attribute like :user/active? is fine, but reading a :db.type/instant attribute through it gives you back a raw java.util.Date -- the very thing the wrapper layer exists to keep out of application code. So reach for d/entity only for the lazy, navigational reads where you are not pulling timestamps; when you need instant-typed attributes, go through pull*/q*, which convert. (If you wanted entity-style navigation with conversion, you would wrap it the same way -- (convert-dates (into {} (d/entity db ref))) -- but realizing the whole entity defeats the laziness, so we keep the two tools separate.)
Isolated Test Databases
Testing database code well requires isolation. Each test should start with a clean database and leave no trace when it finishes. Datomic's in-memory backend makes this trivially fast:
(ns myapp.test-helpers
(:require
[datomic.api :as d]
[myapp.db.core :as db]
[myapp.db.schema :as schema]))
(def ^:dynamic *conn*
"Bound to a fresh Datomic connection per test by the with-test-db fixture."
nil)
(defn with-test-db
"Fixture: creates a fresh in-memory Datomic DB per test.
Binds *conn* and stubs db/get-connection."
[f]
(let [uri (str "datomic:mem://myapp-test-" (System/nanoTime))]
(d/create-database uri)
(let [conn (d/connect uri)]
@(d/transact conn schema/schema)
(binding [*conn* conn]
(with-redefs [db/get-connection (fn [] *conn*)]
(f)))
(d/delete-database uri))))
This fixture does four things:
- Creates a uniquely-named in-memory database using
System/nanoTimeto avoid collisions. - Installs the full schema.
- Binds the connection to a dynamic var and redefines
db/get-connectionto return it, so all application code that callsget-connectiongets the test database. - Deletes the database after the test completes.
Using it in a test namespace is one line:
(ns myapp.db.core-test
(:require
[clojure.test :refer [deftest is use-fixtures]]
[datomic.api :as d]
[myapp.db.core :as db]
[myapp.test-helpers :as h])
(:import
[java.time Instant]))
(use-fixtures :each h/with-test-db)
(deftest transact-with-instant-values
(let [now (Instant/now)
user-id (java.util.UUID/randomUUID)]
@(db/transact* h/*conn*
[{:user/id user-id
:user/email "test@example.com"
:user/created-at now
:user/active? true}])
(let [db (d/db h/*conn*)
eid (d/q '[:find ?e .
:in $ ?email
:where [?e :user/email ?email]]
db "test@example.com")]
(is (some? eid)))))
(deftest pull-returns-instants
(let [now (Instant/parse "2025-06-15T12:00:00Z")
user-id (java.util.UUID/randomUUID)]
@(db/transact* h/*conn*
[{:user/id user-id
:user/email "pull@example.com"
:user/created-at now
:user/active? true}])
(let [db (d/db h/*conn*)
eid (d/q '[:find ?e .
:in $ ?email
:where [?e :user/email ?email]]
db "pull@example.com")
user (db/pull* db [:user/created-at] eid)]
(is (instance? Instant (:user/created-at user)))
(is (= (.toEpochMilli now)
(.toEpochMilli ^Instant (:user/created-at user)))))))
(deftest q-converts-dates-in-results
(let [now (Instant/parse "2025-06-15T12:00:00Z")
user-id (java.util.UUID/randomUUID)]
@(db/transact* h/*conn*
[{:user/id user-id
:user/email "q@example.com"
:user/created-at now
:user/active? true}])
(let [db (d/db h/*conn*)
results (db/q*
'[:find ?email ?created
:where
[?e :user/email ?email]
[?e :user/created-at ?created]]
db)]
(is (= 1 (count results)))
(let [[email created] (first results)]
(is (= "q@example.com" email))
(is (instance? Instant created))))))
These tests verify the full roundtrip: write with java.time.Instant, read back with java.time.Instant, with Datomic storing java.util.Date internally. The test suite never touches java.util.Date -- the bridge is invisible.
Each test gets its own database, runs in milliseconds, and cleans up after itself. No Docker containers, no test database provisioning, no cleanup scripts.
Schema Tests
It is also worth testing that your schema installs correctly and that uniqueness constraints behave as expected:
(deftest schema-idents-exist
(let [db (d/db h/*conn*)
expected-idents #{:user/id :user/email :user/created-at
:user/active? :user/terms-accepted-at}]
(doseq [ident expected-idents]
(is (some? (d/entity db ident))
(str "Schema ident " ident " should exist")))))
(deftest email-uniqueness-is-identity
(let [now (Instant/now)]
@(db/transact* h/*conn*
[{:user/id (java.util.UUID/randomUUID)
:user/email "unique@example.com"
:user/created-at now
:user/active? true}])
@(db/transact* h/*conn*
[{:user/id (java.util.UUID/randomUUID)
:user/email "unique@example.com"
:user/created-at now
:user/active? true}])
(let [db (d/db h/*conn*)
n (d/q '[:find (count ?e) .
:in $ ?email
:where [?e :user/email ?email]]
db "unique@example.com")]
(is (= 1 n)
"Same email should resolve to one entity (upsert)"))))
The email uniqueness test demonstrates an important Datomic behavior: when you transact an entity with a :db.unique/identity attribute that already exists, Datomic merges the new data into the existing entity rather than creating a duplicate. This is upsert semantics. The second transaction with the same email does not fail -- it updates the existing entity. Understanding this early prevents subtle bugs.
What You Now Have
At this point, the database layer is complete:
- Schema as data. Attributes are defined as plain maps, transacted like any other data. Adding new attributes is a single transaction -- no migration framework, no schema version table.
- Environment-agnostic connections. The same code runs against
datomic:memin development anddatomic:sqlin production. The URI in config is the only difference. - Transparent date handling. The
transact*,pull*, andq*wrappers mean the rest of your application works exclusively withjava.time.Instant. Thejava.util.Dateconversion is invisible. - Fast, isolated tests. Each test gets a fresh in-memory database that spins up in milliseconds and is deleted afterward. No shared state, no test ordering dependencies, no cleanup scripts.
The wrapper functions are thin -- about 40 lines total. They do not try to be a framework or an ORM. They solve one specific problem (the Date/Instant mismatch) and stay out of the way for everything else. You still write Datalog queries directly, use d/entity for navigation, and call d/transact (via transact*) with plain maps. Datomic's API is good; it just needs this one bridge.
Later chapters build on this foundation to implement domain logic -- creating users, handling authentication state, and querying across entity relationships.
Testing a Clojure App: Fixtures, Helpers, and Coverage
You have a web app. It loads config, connects to Datomic, defines routes, renders pages. It works when you try it in the browser. But "works when I try it" is not a testing strategy. The moment you refactor a handler or change a route, you need something that tells you -- in seconds -- whether you broke anything.
This post covers the testing infrastructure I put in place early: a test helpers module with fixtures for fresh in-memory databases and deterministic config, a set of initial tests for configuration and routing, a coverage tool with a minimum threshold, and a shell script that ties it all together. None of this is exotic. That is the point. Setting up boring, reliable test infrastructure early pays dividends for every feature that follows.
The Testing Philosophy: Fresh State Per Test
The core principle is simple: every test gets a fresh in-memory Datomic database. No shared mutable state between tests. No "run tests in this order." No cleanup logic that can silently fail.
Datomic makes this easy. Its in-memory mode (datomic:mem://) creates a real database with the full query engine, but it lives entirely in the JVM. Create it, transact your schema, run your test, delete it. Each test is isolated by construction, not by discipline.
This matters more than it sounds. When tests share a database, you get two failure modes that waste enormous amounts of time: tests that pass individually but fail together (ordering dependency), and tests that fail individually but pass together (one test's side effects are another test's setup). Both are awful to debug. Fresh state eliminates both.
The Test Helpers Module
All shared test infrastructure lives in a single file: test/myapp/test_helpers.clj. It provides three things -- database fixtures, deterministic config, and a request builder.
(ns myapp.test-helpers
"Shared test fixtures and utilities.
Provides a fresh in-memory Datomic DB per test, deterministic config values,
and a Ring request builder."
(:require
[datomic.api :as d]
[myapp.config :as config]
[myapp.db.core :as db]
[myapp.db.schema :as schema]
;; only needed for the optional analytics fixture below;
;; this namespace arrives in the admin dashboard chapter. Drop this line if your app has no analytics DB.
[myapp.analytics.db :as analytics]))
The Database Fixture
The centerpiece is with-test-db, a Clojure test fixture that creates a throwaway Datomic database for each test:
(def ^:dynamic *conn*
"Bound to a fresh Datomic connection per test by the with-test-db fixture."
nil)
(defn with-test-db
"Fixture: creates a fresh in-memory Datomic DB per test.
Binds *conn* and stubs db/get-connection."
[f]
(let [uri (str "datomic:mem://myapp-test-" (System/nanoTime))]
(d/create-database uri)
(let [conn (d/connect uri)]
@(d/transact conn schema/schema)
(binding [*conn* conn]
(with-redefs [db/get-connection (fn [] *conn*)]
(f)))
(d/delete-database uri))))
Here is what happens step by step:
- Unique URI per test.
System/nanoTimeensures no two tests collide, even when running in parallel. - Create and connect. A fresh in-memory database, indistinguishable from a real one for query purposes.
- Transact the schema. The test database has the same schema as production. You are testing against the real data model.
- Bind and stub.
*conn*holds the connection for tests that need direct access.with-redefsmakesdb/get-connectionreturn this test connection, so application code that calls(db/get-connection)transparently gets the test database. - Clean up. After the test function
fruns, the database is deleted. No residue.
The same pattern applies to the analytics database, if your app has one. It uses the analytics alias from the :require above (myapp.analytics.db, which we build in the admin dashboard chapter -- omit both the require and this fixture until then):
(def ^:dynamic *analytics-conn*
"Bound to a fresh analytics Datomic connection per test."
nil)
(defn with-test-analytics-db
"Fixture: creates a fresh in-memory analytics DB per test.
Binds *analytics-conn* and stubs analytics/get-connection and analytics/get-db."
[f]
(let [uri (str "datomic:mem://myapp-analytics-test-" (System/nanoTime))]
(d/create-database uri)
(let [conn (d/connect uri)]
@(d/transact conn analytics/schema)
(binding [*analytics-conn* conn]
(with-redefs [analytics/get-connection (fn [] *analytics-conn*)
analytics/get-db (fn [] (d/db *analytics-conn*))]
(f)))
(d/delete-database uri))))
Deterministic Config
Tests should not depend on your local config.edn. A test that passes on your machine but fails in CI because of a config difference is worse than useless -- it builds false confidence. The test helpers define a fixed config map:
(def test-signing-key
"Deterministic signing key for tests."
(.getBytes "test-signing-key-32-bytes-long!!" "UTF-8"))
(def test-session-key
"Deterministic 16-byte session key for tests."
(.getBytes "0123456789abcdef" "UTF-8"))
(def test-config
"Deterministic config for tests."
{:server {:port 3000
:host "0.0.0.0"}
:base-url "https://test.myapp.lan"
:session-key test-session-key
:signing-key test-signing-key
:admin-email "admin@test.myapp.lan"
:smtp {:host "localhost"
:port 1025
:tls false
:user nil
:pass nil
:from "test@myapp.lan"}})
And a fixture that installs it:
(defn with-test-config
"Fixture: stubs myapp.config/config with deterministic test values."
[f]
(with-redefs [config/config (delay test-config)]
(f)))
This uses with-redefs to replace the config/config delay with one that resolves to the test map. Any code that calls (config/get-config :server :port) during the test will get 3000, deterministically.
The Request Builder
For testing Ring handlers, you need request maps. Writing them by hand every time is tedious and error-prone. A small helper takes care of the boilerplate:
(defn request
"Build a minimal Ring request map. Defaults locale to :nl."
[method uri &
{:keys [session params locale]
:or {locale :nl}}]
(cond-> {:request-method method
:uri uri
:locale locale}
session (assoc :session session)
params (assoc :params params)))
Usage in tests:
;; Simple GET
(h/request :get "/dashboard")
;; POST with params
(h/request :post "/auth/request" :params {:email "user@example.com"})
;; Authenticated request
(h/request :get "/dashboard" :session {:user-email "user@example.com"})
The cond-> threading macro keeps it clean -- optional keys are only added when provided.
Example Test: Configuration
The config tests verify that the configuration system works correctly -- loading profiles, producing the right key types, and supporting nested path access:
(ns myapp.config-test
"Tests for config loading: expected keys, crypto key sizes, nested path access."
(:require
[clojure.test :refer [deftest is]]
[myapp.config :as config]))
(deftest dev-profile-loads
(let [cfg (config/load-config :dev)]
(is (map? cfg))
(is (contains? cfg :server))
(is (contains? cfg :session-key))
(is (contains? cfg :signing-key))
(is (contains? cfg :smtp))
(is (contains? cfg :base-url))))
(deftest session-key-is-16-bytes
(let [cfg (config/load-config :dev)]
(is (bytes? (:session-key cfg)))
(is (= 16 (alength ^bytes (:session-key cfg))))))
(deftest signing-key-is-byte-array
(let [cfg (config/load-config :dev)]
(is (bytes? (:signing-key cfg)))))
(deftest get-config-nested-path
(with-redefs [config/config (delay {:server {:port 3000}})]
(is (= 3000 (config/get-config :server :port)))))
(deftest get-config-missing-key
(with-redefs [config/config (delay {:server {:port 3000}})]
(is (nil? (config/get-config :nonexistent)))))
These tests are straightforward, but they catch real problems:
dev-profile-loadsensures all required config keys exist. If someone removes:smtpfromconfig.edn, this fails immediately.session-key-is-16-bytesverifies the crypto key size constraint. Ring session encryption requires exactly 16 bytes. A 15-byte key would cause a cryptic runtime error.get-config-nested-pathandget-config-missing-keyverify theget-configaccessor works for both present and absent keys. Note how these tests usewith-redefsdirectly to set up minimal config -- they do not need the full test fixtures.
Example Test: Routes
The routes tests verify that the routing table is correct and that the app handles edge cases properly:
(ns myapp.web.routes-test
"Tests for route resolution: all paths resolve, unknown paths 404, wrong methods 405."
(:require
[clojure.test :refer [deftest is use-fixtures]]
[myapp.test-helpers :as h]
[myapp.web.routes :as routes]
[reitit.core :as r]
[reitit.ring :as ring]))
(use-fixtures :once h/with-test-config)
The :once fixture applies with-test-config once for the entire namespace, rather than per test. Config is read-only during tests, so sharing it is safe and faster.
Route Resolution
Rather than testing each route individually, a data-driven approach lists all expected routes and verifies them in a loop:
(def ^:private router
"The application router, built once for path-resolution tests."
(ring/router routes/routes))
(defn- match
"Resolve a path against the app router (method is checked by the test).
Returns the reitit Match or nil."
[_method path]
(r/match-by-path router path))
(def ^:private expected-routes
"All application routes that should resolve."
[["/" :get]
["/auth/request" :post]
["/auth/sent" :get]
["/auth/verify" :get]
["/auth/logout" :post]
["/terms/welcome" :get]
["/terms/accept" :post]
["/legal/algemene-voorwaarden" :get]
["/legal/privacyverklaring" :get]
["/dashboard" :get]
["/admin" :get]])
(deftest all-routes-resolve
(doseq [[path method] expected-routes]
(let [m (match method path)]
(is (some? m) (str "Route " method " " path " should resolve"))
(is
(some? (get-in m [:data method]))
(str "Route " method " " path " should have a handler")))))
The match helper resolves a path against the same router the app uses (reitit.core/match-by-path), and the test then checks that the resolved match has a handler for the expected method.
This is one of those tests that seems almost too simple to be useful. It is not. Here is what it catches:
- A route accidentally removed during refactoring.
- A route defined with the wrong HTTP method (
:getinstead of:post). - A route that resolves to a path but has no handler attached for the expected method.
Adding a new route to the app means adding one line to expected-routes. The test grows with the application, and it takes near-zero effort to maintain.
Edge Cases: 404 and 405
Two more tests verify that the app handles non-happy paths correctly:
(deftest unknown-route-returns-404
(let [resp (routes/app
{:request-method :get
:uri "/nonexistent"})]
(is (= 404 (:status resp)))))
(deftest wrong-method-returns-405
(let [resp (routes/app
{:request-method :get
:uri "/auth/request"})]
(is (= 405 (:status resp)))))
The 404 test confirms the default handler returns a proper status code for unknown paths. The 405 test is subtler: /auth/request exists, but only for POST. A GET to that path should return 405 (Method Not Allowed), not 404 (Not Found). This distinction matters for API correctness and for clients that use status codes to make decisions.
Cache Control for Authenticated Responses
One more test verifies security-relevant behavior -- that authenticated responses include Cache-Control: no-store:
(deftest authenticated-responses-have-no-store-header
(let [handler (routes/wrap-no-cache-authenticated
(constantly
{:status 200
:headers {"Content-Type" "text/html"}
:body "ok"}))]
(is
(=
"no-store"
(get-in (handler {:session {:user-email "user@test.com"}})
[:headers "Cache-Control"]))
"Authenticated responses should have Cache-Control: no-store")
(is
(nil? (get-in (handler {:session {}}) [:headers "Cache-Control"]))
"Unauthenticated responses should not have Cache-Control")))
This tests a middleware function in isolation by wrapping a dummy handler. It verifies both the positive case (authenticated requests get the header) and the negative case (unauthenticated requests do not). Testing middleware this way -- by composing it with a trivial handler -- is clean and fast.
Coverage with Cloverage
Tests without coverage measurement leave you guessing about what you have not tested. Cloverage instruments your Clojure code and reports line and form coverage.
The coverage configuration lives in the :coverage alias in deps.edn:
:coverage {:extra-paths ["test"]
:extra-deps {cloverage/cloverage {:mvn/version "1.2.4"}}
:main-opts ["-m" "cloverage.coverage"
"--src-ns-path" "src"
"--test-ns-path" "test"
"--text" "--summary"
"--fail-threshold" "50"]}
The key flags:
--src-ns-path "src"and--test-ns-path "test"tell Cloverage where to find source and test code.--text --summaryoutputs a human-readable summary to the terminal.--fail-threshold 50makes the process exit with a non-zero status if overall coverage drops below 50%. This is the enforcement mechanism.
Why 50% and not 80% or 100%? At this stage of the project, the app has config, routes, handlers, database code, and email sending. Some of that (email, database transactions) is harder to unit test and will be covered by integration and end-to-end tests. Setting the bar at 50% ensures meaningful coverage without creating pressure to write bad tests just to hit a number. The threshold should go up as the test suite matures.
The unittest Script
Every verification step gets a script. Here is the unit test script:
#!/usr/bin/env bash
cd "$(dirname "$0")"
clojure -M:coverage
Two lines. It changes to the project directory (so it works regardless of where you invoke it from) and runs Clojure with the coverage alias. That is it.
You run it from anywhere:
./myapp/unittest
If tests pass and coverage is above the threshold, exit code 0. If anything fails, non-zero. CI uses this same script, so local and CI behavior are identical.
Having these scripts matters more than it might seem. Without them, you end up with "run this command, but make sure you're in the right directory, and use these flags" instructions that rot. A script is executable documentation that cannot go stale.
The deps.edn Test Alias
For running tests without coverage (faster feedback during development), there is also the :test alias:
:test {:extra-paths ["test"]
:extra-deps {io.github.cognitect-labs/test-runner
{:git/tag "v0.5.1" :git/sha "dfb30dd"}}
:main-opts ["-m" "cognitect.test-runner"]
:exec-fn cognitect.test-runner.api/test}
This uses Cognitect's test runner, which discovers and runs all _test.clj files under test/. You can run it with:
clojure -M:test
The difference: :test runs fast without instrumentation. :coverage instruments every form for coverage tracking, which is slower. Use :test during development, :coverage (via the unittest script) before committing.
In-File Tests: Co-Locating Quick Tests with Source
Everything above lives in test/. That is the right home for most tests -- the database fixtures, the route table, the middleware checks. But there is a second place a test can live: directly underneath the function it tests, in the source file itself. The two approaches are complements, not substitutes. This project uses both, and the only real question is which job goes where.
The division of labor is worth stating plainly:
- In-file (co-located) tests suit the light cases: example or doc-style tests, short assertions, and -- the one that earns its keep -- assertions on private functions. Inside the function's own namespace you call a
defn-directly. No exposing it, no(var myapp.ns/private-fn)indirection. The test sits next to the code and reads as living documentation. - Separate
test/files -- the approach built earlier in this post -- carry the heavy lifting: larger standalone tests, anything with substantial setup, and integration tests like the DB-fixture style above. Those do not belong in-file.
The signal is the dependency. The only test-only dependency a light in-file test needs is clojure.test itself (deftest/is/testing), plus maybe a small helper or data generator. The moment a test reaches for something heavy -- a JDBC driver, testcontainers, anything not on the production classpath -- that is the cue to move it to a test/ file. The macro below would technically strip it, but a test that needs that machinery is not an example anymore.
The Problem: Test Dependencies in Production Code
The catch is the require. A co-located test needs clojure.test (and maybe a small helper), but the obvious ways to pull it in all fail:
- In the
nsform. The require loads into the production namespace too. Forclojure.testthat is merely unwanted; for a heavier test-only dep that is not even on the prod classpath, the build breaks outright. - Inside the
deftestbody. That is a runtime call inside the test fn -- it does not makedeftest/isresolvable for the rest of the file at compile time. - Guarded with
(when clojure.test/*load-tests* (require ...)). This is a runtime branch -- the form is still compiled into the artifact, so it does not reliably keep the dependency out.
The Macro: Compile-Time Exclusion
The fix leans on a built-in: clojure.test/*load-tests* is a dynamic var, default true, and when it is false, deftest expands to nothing. We extend that to arbitrary forms -- including the test-only require -- with one macro:
(defmacro tests
"Include body only when clojure.test/*load-tests* is true."
[& body]
(if clojure.test/*load-tests*
`(do ~@body)
`(comment ~@body)))
The if runs at macroexpansion (compile) time. With *load-tests* true, (tests (require '[clojure.test ...]) (deftest ...)) expands to (do (require ...) (deftest ...)) -- it loads and runs exactly as written. With it false, the same form expands to (clojure.core/comment (require ...) (deftest ...)), and comment evaluates to nil and never evaluates its body. Even a (require '[does.not.exist]) inside it is inert. So when *load-tests* is false at compile time, the entire block -- test-only requires and all -- is gone from the compiled output.
Define (or require) tests before you use it. Here it is around a private helper:
(ns myapp.config
(:require [clojure.string :as str]))
(defn- parse-port
"Parse a port string into an int, clamped to the valid TCP range."
[s]
(-> (Long/parseLong (str/trim s))
(max 1)
(min 65535)))
(tests
(require '[clojure.test :refer [deftest is]])
(deftest parse-port-clamps
(is (= 8080 (parse-port " 8080 ")))
(is (= 1 (parse-port "0"))) ; clamped up
(is (= 65535 (parse-port "70000"))))) ; clamped down
parse-port is private, and the test calls it directly -- no var gymnastics -- documenting the clamping behavior right where a reader will look for it.
Stripping for Production
In normal dev and test runs, *load-tests* stays true, so these blocks load and run as written. You only flip it false for the AOT build. The strict-compilation chapter's compile-strict already passes a :bindings map to compile-clj -- add one entry (leaving its :err :capture and warning scan exactly as they were):
;; the :bindings map inside compile-strict's b/compile-clj call
;; (see the strict-compilation chapter):
:bindings {#'*warn-on-reflection* true
#'*unchecked-math* :warn-on-boxed
#'clojure.test/*load-tests* false}
(compile-clj has supported :bindings since tools.build 0.8.1.) With this binding the AOT compiler expands every tests block to (comment ...), so the uberjar carries no test forms and none of the in-file test requires. (clojure.test itself ships with Clojure, so its presence in the namespace that defines the tests macro is harmless -- the win is keeping the test bodies and any heavier test-only deps out of the artifact.)
The Real Cost: Discovery
There is an ergonomic price, and it is the honest tradeoff. The :test alias runs the cognitect test-runner, which by default scans test/ for namespaces matching .*-test$. In-file tests live inside source namespaces like myapp.config, which do not end in -test -- so the default run never sees them. To include them, point the runner at src and widen selection:
clojure -X:test :dirs '["src" "test"]' :patterns '[".*"]'
But :patterns '[".*"]' now loads every namespace under src as a test namespace, not just the ones with tests -- fine for a small tree, slower (and a side-effect risk) for a large one. Once you have more than a couple, enumerate the in-file test namespaces explicitly instead. The exec-fn key is :nses (not :namespaces):
clojure -X:test :nses '[myapp.config myapp.web.routes]'
The same caveat applies to the commit gate. The unittest script runs clojure -M:coverage, and cloverage finds tests via its own --test-ns-path "test" -- so in-file tests are skipped there too until you broaden that path (or its --test-ns-regex) as well. Either way, discovery stops being automatic and becomes something you maintain.
Co-location is convenient -- the test sits where you edit, and reads as documentation for the next person. That convenience is not free: it puts a test concern into a source file, and while the tests macro keeps the dependency out of the artifact, it cannot make discovery automatic. The trade is worth it for short, doc-style, and private-function checks. For anything heavier -- standalone or integration -- the separate test/ file stays simpler, and discovery stays free. Keep it there.
What You Have Now
At this point in the series, the test suite covers three areas:
- Configuration -- the config system loads correctly, produces keys of the right type and size, and supports nested access.
- Routes -- every defined route resolves, unknown routes return 404, wrong methods return 405.
- Middleware -- security-relevant behavior (cache headers for authenticated users) is verified.
The infrastructure supports more:
- Fresh in-memory databases per test, ready for when you start testing data access.
- Deterministic config, so tests never depend on local environment.
- A request builder, for when you start testing handlers end-to-end.
- Coverage enforcement, so coverage can only go up as you add features.
The investment is small -- one helpers file, two test files, a three-line shell script, and a couple of aliases in deps.edn. But it establishes patterns that scale. Every new feature you add gets tested against this infrastructure, and you find out in seconds whether it works.
Next time you sit down to add a feature, you write the test first (or at least alongside), and you have everything you need to run it.
i18n in Clojure: Multi-Language Support with Accept-Language Detection
If your SaaS targets a specific market, you will need translations from day one. Not "someday when we go international" -- from the first commit. Adding i18n to an existing codebase means hunting down hundreds of hardcoded strings scattered across dozens of view functions. Adding it from the start means every string goes through the same path, and supporting a new language is just adding a file.
My primary market is the Netherlands, so Dutch is the default language. But the app also needs to work in English -- for international users, for demos, and because English is the lingua franca of SaaS. The approach: Dutch-first with English fallback, detected automatically from the browser's Accept-Language header.
This post walks through the entire i18n implementation: translation maps as plain data, a lookup function with a fallback chain, RFC 4647 language detection using Java's built-in support, and Ring middleware that wires it all together.
Translation Maps as Plain Clojure Data
There is no framework here. Translations are plain Clojure maps in plain Clojure files, one per language. Here is a trimmed version of the Dutch translations:
(ns myapp.i18n.nl
"Dutch (Nederlands) translations. Primary locale -- all keys must be defined here.
English falls back to Dutch for any missing keys.")
(def translations
"Dutch UI strings, keyed by namespaced keyword."
{;; Landing page
:home/title "myapp — Serieuze boekhoudsoftware die je gewoon begrijpt"
:home/tagline "Boekhouden zonder boekhoudkennis."
:home/get-started "Aan de slag"
:home/email-label "E-mailadres"
:home/email-placeholder "jij@voorbeeld.nl"
:home/sign-in "Inloggen"
:home/magic-link-explanation
"We sturen je een inloglink per e-mail. Geen wachtwoord nodig."
;; Auth flow
:auth/check-email "Controleer je e-mail"
:auth/email-sent-to "We hebben een inloglink gestuurd naar "
:auth/link-expires
"Klik op de link in de e-mail om in te loggen. De link verloopt over 15 minuten."
:auth/sign-out "Uitloggen"
;; Dashboard
:dashboard/welcome "Welkom bij myapp"
:dashboard/placeholder "Je boekhouddashboard verschijnt hier."
;; Errors
:error/title "Fout"
:error/invalid-magic-link "Ongeldige of verlopen inloglink. Vraag een nieuwe aan."
;; Navigation
:nav/daily "Dagelijks"
:nav/billing "Factureren"
:nav/insights "Inzicht"
:nav/settings "Beheer"
;; Meta
:meta/description
"Serieuze boekhoudsoftware die je gewoon begrijpt."})
And the English equivalent:
(ns myapp.i18n.en
"English translations. Secondary locale -- should define the same keys as nl.clj.")
(def translations
"English UI strings, keyed by namespaced keyword."
{;; Landing page
:home/title "myapp — Serious accounting software you can use without understanding accounting"
:home/tagline "Accounting without the accounting knowledge."
:home/get-started "Get Started"
:home/email-label "Email address"
:home/email-placeholder "you@example.com"
:home/sign-in "Sign in"
:home/magic-link-explanation
"We'll send you a magic link to sign in. No password needed."
;; Auth flow
:auth/check-email "Check your email"
:auth/email-sent-to "We sent a magic link to "
:auth/link-expires "Click the link in the email to sign in. The link expires in 15 minutes."
:auth/sign-out "Sign out"
;; Dashboard
:dashboard/welcome "Welcome to myapp"
:dashboard/placeholder "Your accounting dashboard will appear here."
;; Errors
:error/title "Error"
:error/invalid-magic-link "Invalid or expired magic link. Please request a new one."
;; Navigation
:nav/daily "Daily"
:nav/billing "Billing"
:nav/insights "Insights"
:nav/settings "Settings"
;; Meta
:meta/description
"Serious accounting software you can use without understanding accounting."})
A few things to notice.
Namespaced keywords. Every translation key uses a namespace: :home/sign-in, :auth/check-email, :nav/settings. This prevents collisions as the app grows and makes it easy to find where a translation is used -- grep for :auth/check-email and you find both the translation definition and every view that uses it.
One file per language. Not one file per page, not one giant EDN resource file. One namespace per language. The Dutch file is the canonical set of keys. The English file should define exactly the same keys. This is easy to enforce with a test (more on that later).
Plain def, not defonce. The translations are defined with a regular def, which means a file watcher (like Beholder) can hot-reload them during development. Change a translation, save the file, see it in the browser. No restart needed.
The t Function
The core of the i18n system is a single function:
(ns myapp.i18n
"Internationalization.
Provides the t function for translating namespaced keys by locale."
(:require
[myapp.i18n.en :as en]
[myapp.i18n.nl :as nl])
(:import
[java.util Locale Locale$LanguageRange]))
(set! *warn-on-reflection* true)
(def default-locale
"Fallback locale when a key is missing in the requested locale."
:nl)
(def ^:private locale-vars
"Map of locale keyword to the var holding its translation map.
Var derefs ensure hot-reloaded translations take effect immediately."
{:nl #'nl/translations
:en #'en/translations})
(defn t
"Translate key for locale. Falls back to default locale, then to key name."
[locale k]
(or
(when-let [v (locale-vars locale)]
(get @v k))
(get @(locale-vars default-locale) k)
(name k)))
The t function takes a locale keyword and a translation key, and returns a string. It has a three-step fallback chain:
- Look up the key in the requested locale. If the user's locale is
:enand the key is:home/sign-in, return"Sign in". - Fall back to the default locale (Dutch). If the key is missing in the requested locale, try Dutch. This means you can add a new key to the Dutch file and it will show up in Dutch for English users until the English translation is added. Better than a blank string or an error.
- Fall back to the key name. If the key is missing everywhere, return the name part of the keyword (e.g.,
:home/sign-inbecomes"sign-in"). This is a development safety net -- you see untranslated keys in the UI immediately, and they are ugly enough that you fix them.
Why Var References?
The locale-vars map stores var references (#'nl/translations) rather than direct references to the maps. This is a subtle but important detail for development workflow.
When the file watcher detects a change to myapp/i18n/nl.clj, it reloads the namespace, which re-evaluates the def and updates the var's value. If locale-vars held a direct reference to the old map value, you would need to restart the REPL to pick up changes. With var references, @v dereferences the var at call time, always getting the current value. Hot reload just works.
In production this makes no difference -- the translations are loaded once and never change. The var deref adds negligible overhead (it just reads the var's current root binding -- a volatile field read, not a map rebuild). But during development it means you can iterate on translations without restarting anything.
Accept-Language Detection
When a Dutch user visits the app, their browser sends a header like:
Accept-Language: nl-NL,nl;q=0.9,en;q=0.8
This tells the server: "I prefer Dutch (Netherlands), then Dutch in general, then English." The quality values (q=0.9, q=0.8) express relative preference.
Parsing this header correctly is specified by RFC 4647, and Java has built-in support for it. No need for a library:
(defn detect-locale
"Detect best matching locale from an Accept-Language header value.
Uses Java's built-in RFC 4647 language matching. Returns nil if no match."
[accept-language]
(try
(when accept-language
(let [ranges (Locale$LanguageRange/parse accept-language)
supported (mapv #(Locale. (name %)) (keys locale-vars))
match (Locale/lookup ranges supported)]
(when match (keyword (.getLanguage ^Locale match)))))
(catch Exception _ nil)))
Here is what happens step by step:
-
Parse the header.
Locale$LanguageRange/parsetakes the raw header string and returns a list ofLanguageRangeobjects, sorted by quality value. Java handles the parsing, the quality values, the subtag matching -- all of it. -
Build the supported locale list. We take the keys from
locale-vars(:nl,:en) and convert them toLocaleobjects. This is our "we support these" list. -
Match.
Locale/lookupperforms RFC 4647 "lookup" matching: it walks through the user's preferences in order and returns the first one that matches a supported locale. If someone sendsnl-NL, it matches:nl. If they sendde-DE, it matches nothing and returnsnil. -
Convert back. The matched
Localeobject is converted back to a keyword with.getLanguage.
The try/catch around the whole thing is a safety net. Browsers can send malformed headers, and we would rather return nil (falling through to the default locale) than crash the request.
This function does not cover every edge case in the RFC. It does not handle region-specific variants or script subtags. But for a two-language app, it is exactly right. When you add a third language, you add one entry to locale-vars and this function handles the matching automatically.
The wrap-locale Middleware
The middleware ties detection to the request lifecycle:
(defn wrap-locale
"Determines locale from session, Accept-Language header, or default.
Assocs :locale onto request."
[handler]
(fn [request]
(let [locale (or
(get-in request [:session :locale])
(i18n/detect-locale (get-in request [:headers "accept-language"]))
i18n/default-locale)]
(handler
(assoc request
:locale locale)))))
Three-step resolution, in priority order:
- Session. If the user has explicitly chosen a language (via a language picker, for example), that choice is stored in the session and takes priority. This is not implemented yet in the UI, but the middleware is ready for it.
- Accept-Language header. Auto-detect from the browser. This is what fires for every first-time visitor.
- Default locale. Dutch. If the browser sends no Accept-Language header (rare but possible) or sends only unsupported languages, the user gets Dutch.
The result is a :locale keyword (:nl or :en) assoc'd onto the request map, available to every handler downstream.
Middleware Stack Position
Where wrap-locale sits in the middleware stack matters:
(ring/ring-handler
(ring/router routes)
(ring/create-default-handler)
{:middleware [[params/wrap-params]
[keyword-params/wrap-keyword-params]
[session/wrap-session
{:store (cookie/cookie-store {:key (config/get-config :session-key)})
:cookie-name "session"
:cookie-attrs {:http-only true
:secure true
:same-site :lax
:max-age (* 30 24 60 60)}}]
[wrap-locale]
[wrap-no-cache-authenticated]]})
It comes after wrap-session (because it reads :session) and after wrap-params/wrap-keyword-params (no dependency, but those need to be early). It comes before the application-specific middleware like wrap-no-cache-authenticated. Every handler that runs after wrap-locale can access (:locale request) without thinking about detection logic.
Using Translations in Views
With the middleware in place, every handler receives a request with a :locale key. Handlers pass it to view functions, and views use t to look up strings. Here is a simplified version of the home-page we build in the Hiccup views chapter -- the layout chrome (public-layout, logo, Tailwind classes) is stripped out so the t calls stand out:
(ns myapp.web.views
(:require [myapp.i18n :refer [t]]))
(defn home-page
"Landing page (simplified -- see the Hiccup views chapter for the full version)."
[locale]
[:div.space-y-8
[:div.text-center
[:p.mt-2.text-lg (t locale :home/tagline)]]
[:div.bg-surface.py-8.px-6
[:h2.text-2xl.font-semibold (t locale :home/get-started)]
[:form {:method "POST" :action "/auth/request"}
[:div
[:label {:for "email"} (t locale :home/email-label)]
[:input {:type "email"
:id "email"
:name "email"
:placeholder (t locale :home/email-placeholder)}]]
[:div.mt-6
[:button {:type "submit"} (t locale :home/sign-in)]]]
[:p.mt-4.text-xs (t locale :home/magic-link-explanation)]]])
The pattern is consistent everywhere: (t locale :namespace/key). No special syntax, no template directives, no interpolation engine. Just a function call that returns a string. Hiccup treats it like any other string value.
The handler bridges the request and the view:
(defn home
"Landing page handler. Redirects authenticated users to dashboard."
[request]
(if (get-in request [:session :user-email])
(response/redirect "/dashboard")
{:status 200
:headers {"Content-Type" "text/html"}
:body (str (views/home-page (:locale request)))}))
The locale also flows into the HTML lang attribute via the layout function:
(defn- base-layout
"Base HTML5 wrapper."
[locale & body]
(page/html5
{:lang (name locale)}
[:head
[:meta {:name "description"
:content (t locale :meta/description)}]
;; ...
]
[:body body]))
This means <html lang="nl"> or <html lang="en"> is set correctly for every page, which matters for screen readers, search engines, and browser features like auto-translation prompts.
Keeping Translations in Sync
One risk with separate translation files is drift -- adding a key to Dutch and forgetting to add it to English. A simple test catches this:
(deftest both-locales-have-same-keys
(let [nl-keys (set (keys myapp.i18n.nl/translations))
en-keys (set (keys myapp.i18n.en/translations))]
(is (= nl-keys en-keys)
"Dutch and English should define the same set of keys")))
When you add a key to nl.clj and forget en.clj, this test fails and tells you exactly which keys are missing. It does not check the quality of translations -- that is a human job -- but it ensures coverage.
The test suite also verifies the fallback chain and header detection:
(deftest t-returns-dutch-for-nl-locale
(is (= "Inloggen" (i18n/t :nl :home/sign-in))))
(deftest t-returns-english-for-en-locale
(is (= "Sign in" (i18n/t :en :home/sign-in))))
(deftest t-falls-back-to-dutch-for-unknown-locale
(is (= "Inloggen" (i18n/t :fr :home/sign-in))))
(deftest t-falls-back-to-key-name-for-missing-key
(is (= "nonexistent" (i18n/t :nl :home/nonexistent))))
(deftest detect-locale-dutch-browser
(is (= :nl (i18n/detect-locale "nl-NL,nl;q=0.9,en;q=0.8"))))
(deftest detect-locale-respects-quality
(is (= :en (i18n/detect-locale "nl;q=0.8,en;q=0.9"))))
(deftest detect-locale-unsupported-language-returns-nil
(is (nil? (i18n/detect-locale "de-DE,de;q=0.9"))))
(deftest detect-locale-malformed-header-returns-nil
(is (nil? (i18n/detect-locale ";;;garbage!!!"))))
The quality-value test is worth noting: when a browser sends nl;q=0.8,en;q=0.9, English wins despite Dutch appearing first in the header. The RFC 4647 matching handles this correctly because Locale$LanguageRange/parse sorts by quality value.
Why Not a Library?
There are Clojure i18n libraries: tongue, tempura, and others. They handle pluralization, interpolation, number formatting, date formatting, and more. Why not use one?
Because I do not need those features yet. The entire i18n system is about 30 lines of code. It handles two languages, falls back gracefully, auto-detects from the browser, and hot-reloads during development. When I need pluralization or interpolation, I will either add a few more lines to t or pull in a library at that point.
Starting with a library means learning its API, understanding its configuration, and working around its opinions. Starting with 30 lines of code means I understand every line, can change any behavior, and have zero dependencies to track. The Algorithm says: question every requirement, delete what you can, simplify before optimizing. A 30-line solution that covers the actual requirements beats a full-featured library that covers hypothetical ones.
What You Have Now
After implementing this:
- Translation maps as data. Two Clojure files, one per language, with namespaced keyword keys. Adding a new string is adding a map entry.
- A
tfunction with graceful fallback. Requested locale, then default locale, then key name. No crashes, no blank strings. - Automatic language detection. The browser's Accept-Language header is parsed using Java's RFC 4647 implementation. Dutch users get Dutch. English users get English. Everyone else gets Dutch.
- Ring middleware. One middleware function resolves the locale and puts it on the request. Every handler downstream gets it for free.
- View integration. Hiccup views call
(t locale :key)wherever they need text. The HTMLlangattribute is set correctly. - Test coverage. Key parity between languages is enforced. The fallback chain is verified. Header parsing edge cases are covered.
The whole system is about 30 lines of implementation code, two translation files, and a middleware function. No dependencies beyond what Java provides. It handles the actual requirements (two languages, auto-detection, fallback) without ceremony.
When the app grows to need a third language, the work is: create myapp/i18n/de.clj, add :de #'de/translations to locale-vars, and translate the keys. The detection, fallback, middleware, and view layer all work without changes. That is the payoff of getting the architecture right from the start.
Styling with Tailwind CSS
You are about to build the view layer in Hiccup -- vectors of tags and attributes that render to HTML. Those views need to look like something. Before we get into markup, we need a styling system: a way to give every page a consistent look without inventing a parallel vocabulary of class names and a sprawl of CSS files that drift out of sync with the markup they style.
This chapter sets that up. It is deliberately small and self-contained. All it needs is a running web server and the project's static/ directory. The production story for styles -- content-hashing the stylesheet, immutable caching, the Content-Security-Policy -- belongs to the asset pipeline chapter much later in the book; here we just get Tailwind producing a stylesheet your views can use as you write them.
Why Tailwind in a server-rendered Clojure app
The problem with styling a server-rendered app is the same one every styled app has: where does the CSS live, and how do you keep it from rotting? The options:
- Hand-written CSS, organized by a convention like BEM. You write
.recipe-card__title--featuredin a.cssfile and the matching class in your markup. This works, but it asks you to invent and remember a naming system, and the CSS lives in a separate file from the markup it styles. The two drift: you delete a component, the styles linger; you rename a block, half the selectors go stale. There is no compiler telling you a class is dead. - CSS-in-JS. Not applicable. There is no JavaScript runtime rendering these pages -- the HTML comes out of Clojure -- so a styling approach that lives inside a React component tree has nothing to attach to.
- A component CSS framework (Bootstrap and friends). You get pre-built components fast, but you also adopt the framework's look, its class taxonomy, and its opinions. Customizing past the defaults means fighting the framework, and you ship a lot of CSS you never use.
Tailwind wins here because it inverts the problem. Instead of a separate stylesheet full of names you invent, you compose styling from a fixed vocabulary of utility classes written directly in the markup: [:div.mt-4.flex.items-center ...]. The styling is co-located with the Hiccup that it styles, so the two cannot drift -- delete the element and its styling goes with it. There is no class name to invent, and no separate CSS file to maintain.
Crucially for this stack, Tailwind does not care where the class names come from. It generates CSS by scanning your source files for class names. It does not need to understand JSX, ERB, or Hiccup -- it scans a directory, finds the utility classes you used, and emits exactly the CSS those classes need and nothing more. That makes it a perfect fit for server-rendered HTML: there is no JavaScript build pipeline for your styles, just a binary that reads source and writes one CSS file.
And Tailwind v4 ships as a standalone CLI -- a single binary, no Node project, no PostCSS plugin chain, no tailwind.config.js. You run it, it produces a CSS file, and the app serves that file. It is a build tool, not a runtime dependency: nothing about Tailwind ends up in the served page except plain CSS.
The input file: Tailwind v4 with design tokens
Tailwind v4 changed how configuration works. Instead of a tailwind.config.js file, everything lives in CSS using @theme, @import, and @source directives. One less JavaScript config file to maintain.
Here is the top of input.css:
@import "tailwindcss" source("./src");
/* The dev-only source inspector uses its own injected `.fy-insp-*` styles, never
Tailwind utilities. Excluding it keeps a false-positive `resize` token (from its
`addEventListener('resize', ...)`) out of the production bundle -- zero prod footprint. */
@source not "./src/myapp/web/inspector.js";
@view-transition {
navigation: auto;
}
@font-face {
font-family: "Geist";
src: url("/fonts/GeistVF.woff2") format("woff2");
font-weight: 100 900;
font-style: normal;
font-display: swap;
}
@theme {
--font-sans: "Geist", ui-sans-serif, system-ui, -apple-system, sans-serif;
--color-primary: #4338ca;
--color-primary-vivid: #4f46e5;
--color-chrome: #292524;
--color-accent: #d97706;
--color-surface: #ffffff;
--color-surface-subtle: #fafaf9;
--color-border: #e7e5e4;
--color-text-primary: #0f172a;
--color-text-secondary: #78716c;
--color-positive: #047857;
--color-negative: #e11d48;
--color-warning: #92400e;
}
A few things to note:
@import "tailwindcss" source("./src") -- This tells Tailwind v4 to scan the ./src directory for utility classes. It will find class names in your .clj files, inside Hiccup vectors like [:div.mt-4 ...] or [:div {:class "flex items-center"} ...].
@source not "./src/myapp/web/inspector.js" -- A targeted exclusion. The dev-only source inspector is a JS file under src/, and Tailwind's scanner would otherwise see a resize substring in its addEventListener('resize', ...) and emit a stray resize utility. Excluding the file keeps that false positive out of the production stylesheet at zero cost.
@font-face for Geist -- Geist is a variable font, so a single .woff2 file covers all weights from 100 to 900. font-display: swap shows a fallback immediately, then swaps to Geist once it downloads.
@theme block -- Design tokens as CSS custom properties. Once declared, you use them directly in utilities: bg-primary, text-text-secondary, border-border. The naming is deliberately semantic -- surface, chrome, accent -- rather than color scales like indigo-600. A focused app has a small, fixed palette; you do not need eleven shades of every color.
Below @theme the file carries plain CSS that does not map cleanly to utilities: a .legal-content block for rendering markdown documents (privacy policy, terms) with proper typography, interaction transitions (press feedback, card hover lift, a details[open] chevron rotation), focus-visible outlines for keyboard navigation, and the .diff-add / .diff-del styles used by the recipe version diff.
The dev stylesheet
Tailwind reads input.css, scans ./src, and writes a single stylesheet: static/styles.css. In development that file is served at a stable, unhashed URL -- /styles.css -- straight out of the static/ directory. The layout links it with one tag in the document head:
[:link {:rel "stylesheet" :href (assets/asset "styles.css")}]
In development (assets/asset "styles.css") resolves to the identity URL /styles.css, so the browser fetches the file Tailwind just wrote. That is the entire dev styling loop: edit a view, Tailwind regenerates static/styles.css, the browser picks up the new CSS.
static/styles.css is a generated file, so it is git-ignored -- the sources are input.css and the static/ tree, and the stylesheet is rebuilt from them. You do not commit it.
What keeps the regeneration loop fast and automatic in development -- a long-lived tailwindcss --watch process and the file watcher that swaps the stylesheet in the browser -- is part of the hot-reload story, and it is covered in the hot-reload chapters, not here. What turns this same static/styles.css into a content-hashed, immutably cached production asset is the asset pipeline chapter, near the end of the book. For now the picture is simple: Tailwind writes one stylesheet, the layout links it, and your views can use utility classes immediately.
What you have now
The app has a styling system with no JavaScript runtime dependency: the standalone Tailwind v4 CLI as a build tool, semantic design tokens declared in CSS, a variable font with font-display: swap, and a single stylesheet served at a stable URL in development. Every view you write from here on can reach for utility classes -- mt-4, flex, bg-primary, text-text-secondary -- co-located with the Hiccup markup they style, with no separate CSS file to keep in sync. That is enough to start building views.
Server-Rendered HTML with Hiccup: Views, Layouts, and Progressive Enhancement
In the previous chapters we set up our Ring server and routing with Reitit, a live-reload workflow, a Datomic schema, internationalization, and Tailwind styling. But we glossed over how pages actually get rendered. In this chapter we build the entire view layer: HTML generation with Hiccup, a layout system that shares structure across pages, navigation components, and a progressive enhancement strategy that keeps things fast without requiring a JavaScript framework. (The login and session machinery the layouts hint at -- magic-link authentication -- comes later, in the authentication chapters; here we just render the views it will plug into.)
Why Server-Rendered HTML
The default assumption in 2026 is that you need React (or something like it) to build a web app. For a lot of products, that is the wrong starting point. Here is why we went with server-rendered HTML:
- One language, one process. Our Clojure server already has all the data. Rendering HTML is just a function call -- no API layer, no serialization boundary, no client-side state management.
- Instant page loads. The browser gets complete HTML. No loading spinners, no hydration, no layout shift.
- Simpler deployment. No build step for a JavaScript bundle. No CDN configuration for client assets. The server sends HTML; the browser renders it.
- Progressive enhancement where it matters. A tiny script upgrades navigation and form submissions into in-place updates without requiring JavaScript for the page to work at all.
This is not a philosophical stance against SPAs. It is a pragmatic choice: for a solo-operated SaaS, every moving part you add is a part you maintain. Server-rendered HTML eliminates an entire category of complexity.
Hiccup 2: HTML as Data
Hiccup represents HTML as Clojure data structures. We render with version 2 (hiccup/hiccup "2.0.0"), whose hiccup2.core/html macro auto-escapes string content by default. That auto-escaping is the foundation of our XSS defense -- more on this in the output-encoding section below.
The core idea is simple. HTML elements become Clojure vectors:
;; Hiccup
[:h1 "Hello"]
;; Becomes
;; <h1>Hello</h1>
Attributes are maps in the second position:
[:a {:href "/dashboard" :class "text-primary"} "Go to dashboard"]
;; <a href="/dashboard" class="text-primary">Go to dashboard</a>
CSS classes can use dot syntax for brevity:
[:div.mt-4.text-center "Centered text"]
;; <div class="mt-4 text-center">Centered text</div>
And since it is just data, you can use all of Clojure: if, for, when, let, function composition. No template language to learn. No special syntax for conditionals or loops.
We use one namespace from the library for rendering:
(require '[hiccup2.core :as h]) ;; escaping html rendering, raw HTML insertion
hiccup2.core/html renders hiccup to an escaped string. hiccup2.core/raw wraps a string we want emitted verbatim (no escaping) -- used for the doctype, for markdown-rendered HTML, and for the inline scripts and styles we control. We will see both at work in the base layout.
The Layout System
Every web app has shared structure: the <head> tag, stylesheets, scripts, navigation. We handle this with three layout functions that compose together.
Base Layout
The base layout is the HTML5 shell that every page shares. It is private -- page functions never call it directly:
(ns myapp.web.views
(:require
[hiccup2.core :as h]
[myapp.i18n :refer [t]]
[myapp.web.assets :as assets :refer [defn-asset]]
[myapp.web.inspector :refer [tag-root]]
[myapp.web.markdown :as markdown]))
(defn-asset toast-script "myapp/web/toast.js")
(defn- script-tag
"A <script> for a served asset, with SRI integrity when the manifest provides it
(prod). `attrs` adds e.g. {:type \"module\"} or {:defer true}."
[logical attrs]
(let [url (assets/asset logical)]
[:script (cond-> (assoc attrs :src url)
(assets/asset-sri url) (assoc :integrity (assets/asset-sri url)))]))
(defn- base-layout
"Base HTML5 wrapper. All pages use this — never called directly by page fns."
[locale & body]
(h/html
{:mode :html}
(h/raw "<!DOCTYPE html>")
[:html {:lang (name locale)}
[:head [:meta {:charset "UTF-8"}]
[:meta {:name "viewport" :content "width=device-width, initial-scale=1.0"}]
[:meta {:name "description" :content (t locale :meta/description)}]
[:title "MyApp"]
[:link {:rel "icon" :type "image/svg+xml" :href "/icon.svg"}]
[:link {:rel "stylesheet" :href (assets/asset "styles.css")}]
;; Import map (must precede any module script) remaps each module's absolute
;; import specifier to its hashed URL in prod; identity no-op in dev.
[:script {:type "importmap"} (h/raw (assets/importmap-json))]
;; Idiomorph (classic script) must load before the dispatcher module
;; so window.Idiomorph is available when dispatcher.js runs.
(script-tag "idiomorph" {:defer true})
(script-tag "js/dispatcher.js" {:type "module"})
(script-tag "js/live-form.js" {:type "module"})
(script-tag "js/defer-details.js" {:type "module"})
(script-tag "js/server-preview.js" {:type "module"})
(script-tag "js/admin-stats.js" {:type "module"})]
[:body (tag-root body)
[:div#toast-container.fixed.bottom-4.right-4.z-50
{:aria-live "polite" :aria-atomic "true"}]
(toast-script)]]))
A few things to note:
localeis threaded everywhere. Every layout takes it as the first argument, and all user-facing text goes through(t locale :key)for i18n. We will cover i18n in a future post, but the view layer is ready for it from day one.- The whole document is built by
h/html, the escaping renderer. This matters enough that it gets its own section below. The doctype is the one structural literal we want emitted as-is, so it goes through(h/raw "<!DOCTYPE html>"). - Assets resolve through
(assets/asset "..."). The stylesheet, the module scripts, and the import map all come from the asset system: in production each URL is content-hashed and carries an integrity (SRI) attribute; in development the same calls return stable, unhashed URLs. The asset pipeline is its own topic -- here, the view layer just asks for a logical name and gets back a URL. (script-tag "js/dispatcher.js" {:type "module"})loads the dispatcher, the script that powers progressive enhancement (covered in detail below). It is a normal ES module, served from the classpath through the asset pipeline -- not inlined.(toast-script)inlines a small toast helper. Thedefn-assetmacro and inline scripts are covered later in this post.(tag-root body)wraps the page body for the development source inspector. In production it is the identity function; the body is unchanged.bodyuses& body(rest args) so callers can pass multiple elements naturally without wrapping them in a container.
Public Layout
For unauthenticated pages (login, error pages, terms acceptance), we want a centered card on a subtle background:
(defn public-layout
"Centered card layout for unauthenticated pages (landing, auth, terms)."
[locale & body]
(base-layout
locale
[:main.min-h-screen.bg-surface-subtle.flex.items-center.justify-center.px-4
{:data-layout "public"}
[:div.max-w-md.w-full body]]))
This wraps base-layout, adding a centered container. The Tailwind classes handle the visual design: full viewport height, centered flexbox, constrained width. Public pages have no navigation -- just the content.
Note the <main data-layout="public">. Every page's content lives inside a single <main> element, and that element carries a data-layout marker. The dispatcher uses both facts -- <main> as the morph target, data-layout to detect when navigating would change the whole chrome. We will come back to this.
App Layout
Authenticated pages -- and the public recipe-browsing pages -- get the navigation chrome:
(defn app-layout
"Layout with the top navigation. Used for recipe browsing (public OR signed
in), the dashboard, and admin. `opts` may include `:admin?`. `user-email`
may be nil for anonymous visitors browsing recipes."
[locale user-email active-tab opts & body]
(let [admin? (:admin? opts)]
(base-layout
locale
[:div.min-h-screen.flex.flex-col.bg-surface-subtle
(top-nav locale user-email active-tab admin?)
[:main.flex-1 {:data-layout "app"}
[:div.mx-auto.max-w-5xl.px-4.py-8.sm:px-6
body]]])))
The active-tab parameter (a keyword like :browse, :new, :dashboard, :admin) tells the navigation which tab to highlight. The opts map carries flags like :admin? to conditionally show admin-only tabs. user-email may be nil: the recipe browse pages render with the app chrome whether or not someone is signed in, and the nav adapts.
Again, the content sits in <main data-layout="app"> -- same morph target as the public layout, different data-layout value.
How They Compose
The hierarchy is:
base-layout (HTML5 shell, head, scripts)
|
+-- public-layout (centered card, no nav, <main data-layout="public">)
|
+-- app-layout (top nav + content area, <main data-layout="app">)
Page functions call either public-layout or app-layout, never base-layout directly. This keeps the shared structure in one place while giving each page type its own wrapper.
Navigation Components
The app layout includes a single, responsive top navigation bar. The same bar shows whether or not someone is signed in -- it just shows different tabs.
The Top Bar
The top navigation bar is a horizontal strip with tabs for each workflow area. Each tab is an icon plus a label (the label hides on the narrowest screens):
(defn- nav-tab
"Single tab in the top navigation bar."
[locale label-key href icon-key active?]
[:a
{:href href
:class
(if active?
"flex items-center gap-1.5 text-white border-b-2 border-white px-3 py-3 text-sm font-semibold"
"flex items-center gap-1.5 text-white/70 hover:text-white border-b-2 border-transparent hover:border-white/30 px-3 py-3 text-sm font-medium")}
(hero-icon (get nav-icons icon-key))
[:span.hidden.sm:inline (t locale label-key)]])
The active tab gets full white text with a solid bottom border. Inactive tabs are translucent with a hover effect. This is pure CSS -- no JavaScript needed for tab state.
The bar itself adapts to the signed-in state. Browse is always shown; "New recipe" and "Dashboard" appear only when there is a user-email; the admin tab appears only for admins. On the right, a signed-in user sees their email and a logout form; an anonymous visitor sees a sign-in link:
(defn- top-nav
"Top navigation bar. Adapts to whether a user is signed in."
[locale user-email active-tab admin?]
[:nav.bg-chrome.sticky.top-0.z-50
[:div.mx-auto.max-w-5xl.px-4
[:div.flex.h-14.items-center.justify-between
[:div.flex.items-center.gap-x-2
[:a.mr-2 {:href "/recipes"}
[:img.h-7 {:src "/scriptlogo-white.svg" :alt "MyApp" :width 132 :height 32}]]
(nav-tab locale :nav/browse "/recipes" :browse (= active-tab :browse))
(when user-email
(nav-tab locale :nav/new "/recipes/new" :new (= active-tab :new)))
(when user-email
(nav-tab locale :nav/dashboard "/dashboard" :dashboard (= active-tab :dashboard)))
(when admin?
(nav-tab locale :nav/admin "/admin" :admin (= active-tab :admin)))]
[:div.flex.items-center.gap-x-2
(if user-email
(list
[:span {:key "email" :class "text-sm text-white/70 hidden sm:block"} user-email]
[:form {:key "out" :method "POST" :action "/auth/logout"}
[:button {:type "submit" :class "text-sm text-white/70 hover:text-white"}
(t locale :auth/sign-out)]])
[:a {:href "/" :class "text-sm text-white/70 hover:text-white"}
(t locale :home/sign-in)])]]]])
Inline SVG Icons
Tabs use inline SVG icons. Using inline SVGs means no icon font to load, and each icon is just a vector of path strings:
(defn- hero-icon
"Inline 24x24 Heroicon-style outline SVG. `paths` is a seq of d-strings."
[paths]
[:svg.h-5.w-5
{:fill "none" :viewBox "0 0 24 24" :stroke-width "1.5" :stroke "currentColor"}
(for [d paths]
[:path {:stroke-linecap "round" :stroke-linejoin "round" :d d}])])
(def ^:private nav-icons
{:browse ["M12 6.042A8.967 8.967 0 0 0 6 3.75c-1.052..."]
:new ["M12 4.5v15m7.5-7.5h-15"]
:dashboard ["M3.75 6A2.25 2.25 0 0 1 6 3.75h2.25..."]
:admin ["M9.594 3.94c.09-.542..." "M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"]})
The icon paths are stored in a map keyed by tab name, so the markup that draws each tab stays small.
Building Pages
With layouts and navigation in place, building a page is straightforward. Here is the landing page:
(defn home-page
"Landing page with the magic-link sign-in form."
[locale]
(public-layout
locale
[:div.space-y-8
[:div.text-center
[:img.h-12.mx-auto {:src "/logo.svg" :alt "MyApp" :width 220 :height 48}]
[:p.mt-3.text-lg.font-medium.text-text-primary (t locale (rand-nth tagline-keys))]
[:p.mt-2.text-sm.text-text-secondary (t locale :home/lead)]]
[:div.bg-surface.py-8.px-6.border.border-border.rounded-lg
[:h2.text-2xl.font-semibold.text-text-primary.mb-4 (t locale :home/get-started)]
[:form {:method "POST" :action "/auth/request"}
[:div
[:label.block.text-sm.font-medium.text-text-primary {:for "email"}
(t locale :home/email-label)]
[:input.mt-1.block.w-full.px-3.py-2.border.border-border.rounded-md
{:type "email" :id "email" :name "email" :required true
:placeholder (t locale :home/email-placeholder)}]]
[:div.mt-6
[:button.w-full.py-3.px-4.rounded-md.text-sm.font-semibold.text-white.bg-primary.hover:bg-primary-vivid
{:type "submit"} (t locale :home/sign-in)]]]
[:p.mt-4.text-xs.text-text-secondary.text-center (t locale :home/magic-link-explanation)]]]))
There is nothing special on the form. A plain <form method="POST" action="/auth/request"> with a plain <button type="submit">. No data attributes, no per-element wiring. The dispatcher enhances it automatically (next section), and if the dispatcher never loads, the form posts the old-fashioned way and everything still works.
And here is the authenticated dashboard, using app-layout:
(defn dashboard
"Signed-in user's home: their own recipes."
[locale user-email admin? recipes]
(app-layout
locale user-email :dashboard {:admin? admin?}
[:div.flex.items-center.justify-between.mb-6
[:h1.text-2xl.font-bold.text-text-primary (t locale :dashboard/your-recipes)]
[:a.inline-flex.items-center.gap-1.text-sm.font-semibold.text-white.bg-primary.hover:bg-primary-vivid.px-3.py-2.rounded-md
{:href "/recipes/new"} "+ " (t locale :recipe/new)]]
(if (seq recipes)
[:div.grid.gap-4.sm:grid-cols-2
(for [r recipes] ^{:key (:recipe/id r)} (recipe-card locale r))]
[:div.text-center.py-12
[:p.text-text-secondary (t locale :dashboard/no-recipes)]
[:a.mt-4.inline-block.text-sm.font-semibold.text-white.bg-primary.hover:bg-primary-vivid.px-4.py-2.rounded-md
{:href "/recipes/new"} (t locale :dashboard/create-cta)]])))
The :dashboard keyword tells the navigation to highlight the Dashboard tab. The admin? flag passes through to the nav.
Handlers: Connecting Routes to Views
Handlers sit between routes and views. They extract data from the request, call domain logic, and return a Ring response with rendered HTML. A small private helper does the wrapping:
(defn- html
"Ring HTML response (200) from a Hiccup-rendered string."
[body]
{:status 200
:headers {"Content-Type" "text/html; charset=UTF-8"}
:body (str body)})
Note the explicit charset=UTF-8. With the escaping renderer (below), the response is exactly the bytes Hiccup produced, so we declare the encoding the browser should read them in.
Handlers then read what they need off the request and render:
(defn home
"Landing page handler. Redirects authenticated users to the dashboard."
[request]
(let [email (get-in request [:session :user-email])
user-exists? (and email (auth/find-user-by-email (d/db (db/get-connection)) email))]
(cond
user-exists? (response/redirect "/dashboard")
email (-> (html (views/home-page (:locale request))) (assoc :session nil))
:else (html (views/home-page (:locale request))))))
(defn dashboard
"Signed-in home: the user's own recipes."
[request]
(let [recipes (recipe/recipes-for-user ...)]
(html (views/dashboard (:locale request) (:user-email request) (admin? request) recipes))))
The pattern is consistent: gather data, render the view, wrap with html. No framework magic. The str inside html realizes the Hiccup output as an HTML string for the response body.
Importantly, handlers do not branch on the request type. There is no "is this a fetch?" check and no separate partial-vs-full code path. A handler renders one thing -- the full page -- and the dispatcher on the client extracts the part it needs. We will see why that works in a moment.
Progressive Enhancement: the Dispatcher + Idiomorph
The base layout loads dispatcher.js as an ES module. It is the one script responsible for turning ordinary links and forms into smooth, in-place updates -- without any per-element configuration, and without changing how the server responds.
The whole strategy rests on one idea: the server always renders complete pages; the client morphs the part that changed. Specifically, the dispatcher intercepts same-origin navigation, fetches the destination, parses the returned HTML, pulls out its <main>, and morphs the live <main> to match using idiomorph. Morphing diffs the existing DOM against the new DOM and applies the minimal set of mutations -- so focus, scroll position, form state, and ongoing CSS transitions inside unchanged subtrees survive the update.
What it intercepts
The dispatcher attaches exactly three listeners at the document level:
document.addEventListener('click', onClick, true);
document.addEventListener('submit', onSubmit);
window.addEventListener('popstate', onPopState);
For clicks, it looks for the nearest <a href> and decides whether to take over. It deliberately bows out -- letting the browser do its native thing -- for anything that is not a plain primary-button, same-origin navigation:
function shouldEnhanceLink(a, e) {
if (a.hasAttribute('data-no-enhance')) return false;
if (a.getAttribute('target') === '_blank') return false;
if (a.hasAttribute('download')) return false;
if (e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return false;
if (e.button !== undefined && e.button !== 0) return false;
const href = a.getAttribute('href');
if (!href) return false;
if (!sameOrigin(href)) return false;
if (isFragmentOnly(a)) return false;
return true;
}
So cmd-click to open in a new tab, downloads, external links, and in-page #anchor jumps all behave exactly as the browser intends. The only opt-out attribute is data-no-enhance; everything else is inferred from the link itself.
Forms are similar -- the dispatcher reads action and method straight off the <form> (honoring an <input formaction>/formmethod submitter when present), and skips anything cross-origin or marked data-no-enhance. There is no DSL: a normal <form method="POST" action="/auth/request"> is enhanced as-is.
The core primitive: fetchAndMorph
Both the click and submit handlers funnel into one exported function, fetchAndMorph. It fetches, picks the fragment to apply, morphs, and updates history:
export async function fetchAndMorph(url, opts = {}) {
// ... fetch the URL (credentials: same-origin, redirect: follow) ...
const finalUrl = res.url || url; // reflects any 302 the server issued
const html = await res.text();
// Pull <main> (or an explicit data-target) out of the response HTML.
const { fragmentEl, parsedDoc } = pickResponseFragment(html, target);
const targetEl = document.querySelector(target);
if (!targetEl) return; // page already morphed away; bail quietly
// Cross-layout guard (see below).
// ...
morph(targetEl, fragmentEl, { morphStyle: 'innerHTML', ignoreActiveValue });
updateTitle(parsedDoc);
// ... history bookkeeping ...
}
A few details worth pulling out, because they are what make this robust rather than a toy:
-
The server is the source of truth for errors and redirects. A 4xx or 5xx with an HTML body is morphed in place, so server-rendered error pages just appear. A POST that ends in a redirect (the Post-Redirect-Get pattern) is followed by
fetch, andfinalUrlcarries the redirect target, which the dispatcher then writes into the address bar -- so a refresh does not re-POST. -
Cross-layout navigation falls back to a full load. If the response's
<main data-layout>differs from the live one (e.g. going frompublic-layouttoapp-layout), an in-place morph would leave the wrong chrome around it. The dispatcher detects the mismatch and does an honest full navigation instead:if (target === 'main') { const liveLayout = document.querySelector('main[data-layout]'); const newLayout = parsedDoc.querySelector('main[data-layout]'); if (liveLayout && newLayout && liveLayout.dataset.layout !== newLayout.dataset.layout) { window.location.assign(finalUrl); return; } }This is the payoff for putting
data-layouton every<main>. -
In-flight requests are de-duplicated. Each destination keys an
AbortController; a newer click to the same target aborts the older fetch, so rapid navigation cannot land an out-of-order morph. -
Failure degrades gracefully. A network error on a GET falls back to
window.location.assign(url)-- a real navigation -- so the user is never stranded on a dead click.
A note on the Accept header
You will see the fetch send headers: { 'Accept': 'text/html' }. The server does not branch on it. There is no content negotiation, no "partial vs full" response, no special header that flips the handler into a different mode. Every handler renders the complete page every time; the dispatcher is the only thing that decides to extract <main>. The Accept header is vestigial -- harmless, but do not build server logic around it. Keeping the server oblivious to how it is being called is exactly what lets the no-JavaScript path stay correct for free.
History and accessibility
For link clicks and GET form submits, the dispatcher pushes a history entry to the fetched URL, so Back and Forward work. popstate re-fetches whatever URL the browser is now showing and morphs <main> again. After a navigation morph it moves focus to <main> (giving it tabindex="-1" if needed) so screen readers re-announce the new content, and it copies the new document's <title> over.
Scripts inside a morph
One sharp edge of morphing (or any innerHTML swap): browsers do not execute <script> tags introduced that way. If a page embeds a per-page script inside <main>, a morph would insert it inert. The dispatcher handles this by re-materializing such scripts after the morph -- cloning each into a fresh <script> element (which the browser does run) and marking it data-executed so later morphs leave it alone:
function executeScripts(root) {
const scripts = root.querySelectorAll('script:not([data-executed])');
scripts.forEach((old) => {
const fresh = document.createElement('script');
for (const attr of old.attributes) fresh.setAttribute(attr.name, attr.value);
fresh.setAttribute('data-executed', 'true');
fresh.text = old.textContent;
old.replaceWith(fresh);
});
}
In practice our pages avoid inline <script> inside <main> entirely (it would also fight our strict Content-Security-Policy -- a later post). Page-specific behavior is delivered as ES modules loaded once in <head> that enhance whatever DOM is present and re-run their setup on the dispatcher:morphed event the function fires at the end:
document.dispatchEvent(new CustomEvent('dispatcher:morphed', {
detail: { url: finalUrl, target, method },
}));
That event is the extension point. The live-form, defer-details, server-preview, and admin-stats modules each listen for it (or for native events) and idempotently wire up the elements they care about after every morph.
Why this shape
This is progressive enhancement in the literal sense. The HTML is complete and functional on its own; the dispatcher is a strict speed-up layered on top. There is no client-side router to keep in sync with the server's routes, no template duplicated between server and client, no hydration step, and no handler that has to know whether it is talking to a browser navigation or a fetch. Delete dispatcher.js and the app still works -- every link navigates, every form posts. Keep it and navigation becomes a partial DOM morph that preserves UI state.
Output Encoding: Escaping is the Primary XSS Defense
Recipes carry user-supplied text -- titles, descriptions, ingredient lines -- that we render straight into pages. That is precisely where stored XSS lives: if a recipe title containing <script>...</script> is written into the page unescaped, every visitor runs the attacker's script. The fix is not to sanitize on the way in; it is to encode on the way out, by default, everywhere.
This is why the base layout renders with hiccup2.core/html -- the escaping renderer -- and not with the page-helper html5 wrapper:
(h/html
{:mode :html}
(h/raw "<!DOCTYPE html>")
[:html {:lang (name locale)}
...])
h/html HTML-escapes every string it renders. So a recipe whose title is <img src=x onerror=alert(1)> comes out as text -- <img src=x onerror=alert(1)> -- not as live markup. We get this for free at every interpolation point. The recipe card, the detail heading, the author name, the nav email: all of them put user or session strings into the tree as plain Clojure strings, and all of them are escaped:
[:h3.text-lg.font-semibold.text-text-primary (:recipe/title recipe)] ;; escaped
[:span {:class "..."} user-email] ;; escaped
There is nothing to remember and nothing to opt into. Safe is the default; you have to go out of your way to render raw.
Opting into raw, on purpose
Some content we do want emitted verbatim, and only those places use h/raw:
-
The doctype.
(h/raw "<!DOCTYPE html>")-- a fixed literal we control. -
The import map and inline scripts/styles. These are our own bytes, and (as the security post explains) they must be emitted exactly so the Content-Security-Policy hash matches what the browser sees:
[:script {:type "importmap"} (h/raw (assets/importmap-json))]. -
Markdown-rendered HTML. Recipe descriptions and legal documents are authored in Markdown and rendered to HTML by CommonMark; that HTML is inserted with
h/raw:(when-not (str/blank? (:recipe/description recipe)) [:div.legal-content.mt-4 (h/raw (markdown/render (:recipe/description recipe)))])
Each h/raw is a deliberate, auditable decision. The rule of thumb: grep for h/raw, and every hit should be either a literal you wrote or output from a renderer you trust. Everything else flows through h/html and is escaped.
A correctly-escaped output layer is the load-bearing defense here. (The app also ships a strict, hash-based Content-Security-Policy with no 'unsafe-inline' for scripts, which would additionally block an injected inline script -- but that is defense-in-depth sitting behind escaping, and it is the subject of its own post. The escaping is what makes the XSS not happen in the first place.)
CommonMark for Markdown Content
Recipe descriptions and legal documents (terms of service, privacy policy) are written in Markdown and rendered to HTML. We use CommonMark via the commonmark-java library:
(ns myapp.web.markdown
"CommonMark markdown-to-HTML rendering."
(:import
[org.commonmark.parser Parser]
[org.commonmark.renderer.html HtmlRenderer]
[org.commonmark.ext.gfm.tables TablesExtension]))
(def ^:private extensions
[(TablesExtension/create)])
(def ^:private ^Parser parser
(-> (Parser/builder) (.extensions extensions) (.build)))
(def ^:private ^HtmlRenderer renderer
(-> (HtmlRenderer/builder) (.extensions extensions) (.build)))
(defn render
"Render markdown string to HTML string."
[markdown-str]
(->> (.parse parser markdown-str)
(.render renderer)))
The parser and renderer are created once and reused (they are thread-safe). We enable the GFM tables extension because legal documents sometimes need tables (e.g., data processing categories, retention periods).
The rendered HTML is inserted into a styled container with h/raw (the deliberate raw-output decision from the previous section):
(defn legal-page
"Render a legal document as a styled HTML page."
[locale title html-content]
(base-layout
locale
[:div.min-h-screen.bg-surface-subtle
[:div.max-w-3xl.mx-auto.py-8.px-4
[:div.bg-surface.border.border-border.rounded-lg.p-8
[:h1.text-3xl.font-bold.text-text-primary.mb-8 title]
[:div.legal-content (h/raw html-content)]]]]))
The .legal-content class applies typography styles to the rendered HTML -- heading sizes, paragraph spacing, list styles, table formatting -- in CSS. This is a clean separation: the Markdown is pure content, the renderer converts to semantic HTML, and the CSS handles presentation.
Inline Scripts via defn-asset
A few small scripts -- like the toast helper -- are best inlined directly into the document rather than fetched as separate modules. The defn-asset macro turns a classpath resource into a private function that returns the corresponding inline Hiccup element:
(defmacro defn-asset
"Defines a private zero-arity fn returning a hiccup element for a classpath
resource (an INLINE <script>/<style>). Content is emitted RAW (unescaped) so the
emitted bytes equal what the CSP hashes. In prod content is read once at load; in
dev it is re-read every call so inline scripts hot-reload. Script assets register
their path so their hash enters the CSP."
[sym path]
(let [tag (tag-for-ext path)
wrap (if tag (fn [expr] [tag `(h2/raw ~expr)]) identity)
reg (when (= tag :script) `(register-inline-script! ~path))]
(if dev?
`(do ~reg (defn- ~sym [] ~(wrap `(slurp (io/resource ~path)))))
(let [content (wrap (slurp (io/resource path)))]
`(do ~reg (let [v# ~content] (defn- ~sym [] v#)))))))
Two things connect this back to earlier sections:
- The content is wrapped in
h2/raw. This macro lives in themyapp.web.assetsnamespace, which aliaseshiccup2.coreash2(the views namespace happens to alias it ash-- same library, different local nickname). Because the body is syntax-quoted,`(h2/raw ~expr)expands to the fully-qualifiedhiccup2.core/raw, so the generated function works no matter how the calling namespace aliases Hiccup. An inline script must be emitted byte-for-byte; escaping it would corrupt the JavaScript. Because we control the file, raw output is the correct call here -- and because the bytes are emitted exactly, the strict CSP can authorize the script by hashing those same bytes. - Script assets register their path (
register-inline-script!) so the CSP can compute and allow their hash. The CSP machinery itself is covered in the security post; the takeaway here is that inlining a script is a one-liner and it stays compatible with a no-'unsafe-inline'policy.
In development the file is re-read on every render so you can edit the script and refresh; in production it is read once at load and baked into the function. Usage is a single form at the top of the views namespace:
(defn-asset toast-script "myapp/web/toast.js")
Then (toast-script) in the layout returns [:script (h/raw "...the JS...")].
The Middleware Stack
The view layer relies on middleware to prepare the request and to set response headers. Here is the relevant portion of the stack:
(def ^:private app*
(delay
(ring/ring-handler
(ring/router routes {:conflicts nil})
(ring/routes
(cond-> (ring/create-file-handler {:path "/" :root assets/static-root})
assets/dev? wrap-dev-no-store)
(ring/create-default-handler))
{:middleware [[params/wrap-params]
[keyword-params/wrap-keyword-params]
[session/wrap-session
{:store (cookie/cookie-store {:key (config/get-config :session-key)})
:cookie-name "session"
:cookie-attrs {:http-only true :secure true
:same-site :lax :max-age (* 30 24 60 60)}}]
[wrap-locale]
[wrap-no-cache-authenticated]
[wrap-csp]]})))
The pieces that touch the view layer:
wrap-localedetects the user's locale from the session or theAccept-Languageheader and assocs:localeonto the request. Every view function receives this. (This is the only request header the server negotiates on -- there is no enhanced/partial negotiation.)wrap-no-cache-authenticatedsetsCache-Control: no-storeon authenticated responses, preventing the browser's back-forward cache from showing stale pages after logout.wrap-cspattaches the strict Content-Security-Policy header to everytext/htmlresponse (the defense-in-depth backstop behind output escaping). Its construction lives inmyapp.web.assetsand gets its own post.
Static assets are served from assets/static-root -- the source static/ tree in development, the built and content-hashed tree in production. In development a small wrap-dev-no-store keeps the browser from caching stable, unhashed dev URLs.
What We Have Now
After this post, the view layer is complete:
- Hiccup 2 with the escaping renderer for HTML generation -- composable, just Clojure data, and auto-escaped so user content is safe by default. Raw output is an explicit, auditable opt-in.
- A layout system:
base-layoutfor the HTML5 shell,public-layoutfor unauthenticated pages,app-layoutfor the chrome-bearing experience -- every page's content inside a single<main data-layout>. - Responsive, state-aware navigation: one top bar that adapts to signed-in vs anonymous, with active-tab highlighting in pure CSS.
- Progressive enhancement via the dispatcher + idiomorph: links and forms are upgraded into in-place
<main>morphs that preserve UI state, with zero per-element configuration and zero server-side branching. The server always renders full pages; the client decides what to swap. Works without JavaScript. Better with it. - CommonMark rendering for Markdown content, keeping content in Markdown and presentation in CSS.
There is no virtual DOM, no client-side state store, no hydration, and no separate API. The server renders complete HTML; the browser shows it; one small dispatcher makes navigation feel instant.
The next chapters sharpen the development experience further -- a source inspector and morph-based hot reload -- and a later chapter puts this view layer under test end to end, driving a real browser with Playwright to verify the full request, render, and morph loop, including the no-JavaScript fallback path.
A Bidirectional Source Inspector for Server-Rendered Hiccup: From Element to Code and Back
In the live-reload chapter we closed the gap between saving a file and seeing it in the browser. This chapter closes two more, in both directions:
- Element → code. You spot a misaligned badge in the admin dashboard; hold a key, hover it, and your editor opens the exact
.cljline that produced it. - Code → element. You put your cursor on a view function (or a call to one) in the editor, and the matching element lights up in the browser — even telling apart the component's definition from this particular call site.
Front-end frameworks have had the first half for years (React/Vue/Svelte inspectors). The second half — editor-cursor-drives-the-browser — is rarer even there. We render HTML on the server from Hiccup, plain Clojure data with no source information attached, so we have to manufacture all of it. This post is the full build: the why, the how, and the trade-offs.
It reuses two things from the live-reload chapter — the file watcher and the dev-reload WebSocket — and hooks into the base-layout from the Hiccup views chapter. Everything here is dev-only and structurally absent from production builds, the same as the rest of our dev infrastructure. If you read strictly in order, the Hiccup views chapter's layout sections are the relevant background.
Part I — Element → code
Why this is hard for Hiccup specifically
Svelte and JSX get element-level source locations for free because a compiler owns the template. It parses the template into an AST where every node knows its character offset, and in dev mode it emits that position on the element. The framework hands itself the answer.
Hiccup has no compiler step that sees your source text. A view is an ordinary function returning vectors:
(defn user-row [u]
[:tr
[:td (:user/email u)]
[:td (:user/created-at u)]])
By the time [:tr …] exists it is a runtime value with no idea what line it came from. Worse, the default Clojure reader attaches no line metadata to vector literals:
(meta (read-string "[:div [:span \"hi\"]]"))
;;=> nil
So the obvious approaches are dead ends. We cannot ask a Hiccup value where it lives, and the reader will not tell us. The whole feature hinges on getting around exactly this.
Three coordinates of "where did this come from?"
A rendered element actually has three distinct source locations, and we will produce all three because the reverse direction needs them:
| Coordinate | Attribute | Means |
|---|---|---|
| Element | data-myapp-src | the exact [:td …] literal's file:line:col |
| Component | data-myapp-name | the view fn that produced this subtree (ns/fn) |
| Call site | data-myapp-callsite | where this instance was invoked from |
The element coordinate is the hard, interesting one — it did not exist for Hiccup. The other two fall out of the same plumbing. The distinction between component and call site matters more than it looks; we will return to it when a component is rendered from several places.
The insight: tools.reader keeps your line numbers, and so does the compiler
Two facts, stacked, make element-level mapping possible.
Fact one: clojure.tools.reader — a pure-Clojure reader maintained by core — does attach :line/:column/:end-line/:end-column to every nested form, vectors included, at every depth:
(require '[clojure.tools.reader :as tr]
'[clojure.tools.reader.reader-types :as rt])
(let [form (tr/read (rt/indexing-push-back-reader "[:div [:span \"hi\"]]"))]
{:outer (meta form) :inner (meta (nth form 1))})
;;=> {:outer {:line 1 :column 1 :end-line 1 :end-column 21}
;; :inner {:line 1 :column 7 :end-line 1 :end-column 19}}
The end positions matter later (the reverse direction does span-containment), and they are present too. Where the default reader gave nil, tools.reader gives every […] its own coordinates.
Fact two — the one that makes it all work: the Clojure compiler preserves a vector literal's metadata onto the runtime value, and does so even for vectors built in a loop:
(def render (eval '(fn [xs] (mapv (fn [x] ^{:line 9} [:li x]) xs))))
(map meta (render [1 2 3]))
;;=> ({:line 9} {:line 9} {:line 9})
Three <li>s, each tagged with the one source line of their template. That is exactly the semantics we want: clicking a for-generated row should land on the for's [:tr …] line, not on three different places.
Put the facts together:
Read our view namespaces with
tools.readerinstead of the default reader, thenevalthe forms. Now every Hiccup element our views produce carries its own:line/:columnin its metadata — welded onto the value, riding along through everyfor,if, and helper call, all the way to the rendered page.
There is no separate index to keep in sync and no fragile matching of DOM nodes back to source. The position is part of the value.
Recognising an element, and stamping the file onto it
tools.reader gives :line/:column but not which file a form came from, and a page mixes elements from many namespaces. So as we load each file we stamp the file path onto every element literal.
First, recognise an element literal — not every vector is Hiccup (Datomic pull patterns [:db/id …], let bindings, tagged tuples). We tag only vectors whose head is an unnamespaced HTML/SVG tag keyword:
(ns myapp.web.inspector
(:require [clojure.string :as str]
[clojure.java.io :as io]))
(def ^:private dev?
;; Detect dev by a classpath resource, NOT requiring-resolve — see the design
;; note at the end; requiring the hot-reload ns here can deadlock on a
;; circular load and silently turn the whole feature off.
(some? (io/resource "hot_reload.clj")))
(def ^:private html-tags
#{"a" "div" "span" "p" "ul" "ol" "li" "table" "tbody" "tr" "td" "th" "thead"
"form" "input" "button" "label" "section" "nav" "header" "footer" "h1" "h2"
"h3" "img" "svg" "g" "path" "circle" "rect" "text" ,,, }) ; full set in the repo
(defn element?
"True when x is a Hiccup element vector with an unnamespaced HTML/SVG tag head."
[x]
(and (vector? x)
(keyword? (first x))
(nil? (namespace (first x)))
(contains? html-tags (first (str/split (name (first x)) #"[.#]")))))
The str/split on #"[.#]" strips Hiccup's .class/#id shorthand, so :div.card#main matches "div". This element? gate is what makes everything that follows safe to apply blindly across every view: a non-Hiccup vector can never be touched.
Now the walk that adds the file, preserving all existing metadata:
(defn add-file-meta
"Stamp :myapp/file onto every Hiccup element literal in `form`. Preserves
structure and all existing metadata; only element vectors are touched."
[file form]
(cond
(vector? form)
(let [walked (mapv #(add-file-meta file %) form) m (meta form)]
(with-meta walked
(if (and m (:line m) (element? form)) (assoc m :myapp/file file) m)))
(map? form)
(with-meta (into (empty form)
(map (fn [[k v]] [(add-file-meta file k) (add-file-meta file v)])) form)
(meta form))
(set? form) (with-meta (into (empty form) (map #(add-file-meta file %)) form) (meta form))
(seq? form) (with-meta (apply list (map #(add-file-meta file %) form)) (meta form))
:else form))
The care to re-attach (meta form) on lists/maps/sets matters: clojure.walk/postwalk would drop metadata as it rebuilds collections, throwing away the very line numbers we are keeping. We rebuild by hand so nothing is lost.
The loader (and the component layer)
The component coordinate — which view function produced a given root element — can't come from a literal's source position; it needs the enclosing defn. The loader supplies it invisibly by instrumenting every function a view namespace defines, so views stay plain defn with no annotation.
The loader, under dev/ (never on the prod classpath), reads each file with tools.reader, and then does three things to it: stamps file metadata (element layer), auto-instruments every function the namespace defined (component layer), and indexes it (for the reverse direction, Part II).
(ns inspector-load
(:require [clojure.string :as str]
[clojure.tools.reader :as tr]
[clojure.tools.reader.reader-types :as rt]
[myapp.web.inspector :as inspector]))
(defn tr-load!
"Read a .clj view file with tools.reader; eval each form with element + call-
site tags; then instrument its fns (component layer) and index it (reverse)."
[path]
(let [file (str/replace path #"^.*/src/" "") ;; classpath-relative
rdr (rt/indexing-push-back-reader (slurp path))
eof (Object.)]
(binding [*ns* *ns*, *file* file]
(let [read1 #(tr/read {:eof eof :read-cond :allow} rdr)
ns-form (read1)]
;; Eval the (ns …) form FIRST so the namespace and its aliases exist
;; before we read the rest — see the note below.
(eval ns-form)
(let [body (loop [acc (transient [])]
(let [form (read1)]
(if (identical? form eof) (persistent! acc) (recur (conj! acc form)))))
forms (into [ns-form] body)
names (inspector/view-defn-names forms)] ;; fn names → call heads to wrap
(doseq [form body]
(eval (inspector/add-file-meta file (inspector/wrap-callsites names file form true))))
(inspector/instrument-ns! (ns-name *ns*)) ;; component layer
(inspector/index-ns! file (ns-name *ns*) forms)))))) ;; reverse-direction index
Two ordering subtleties hide in that little loop. We need the file's function names before evaluating any body form (so call-site wrapping knows which calls are components), which pushes us toward reading everything up front. But we must evaluate the ns form before reading the rest: tools.reader resolves auto-namespaced keywords like ::alias/kw against the current namespace's aliases at read time, so the namespace has to exist first — read the whole file before the ns form runs and such a keyword throws, the file falls back to a plain load, and you silently lose its tags. So: read the ns form, eval it, then read the body. Binding *file* to the classpath-relative path also means the vars get a correct :file — exactly what the component layer reads:
(defn instrument-var!
"DEV: wrap a view fn so its returned root element is tagged with the var's
location (data-myapp-src = the defn site) and name (data-myapp-name = ns/fn).
Idempotent — unwraps to the original before re-wrapping on a reload."
[v]
(let [cur @v]
(when (fn? cur)
(let [orig (or (::orig (meta cur)) cur), m (meta v)
src (str (:file m) ":" (:line m) ":" (or (:column m) 1))
nm (str (ns-name (:ns m)) "/" (:name m))
wrapped (with-meta (fn [& args] (tag-hiccup (apply orig args) src nm)) {::orig orig})]
(alter-var-root v (constantly wrapped))))))
(defn instrument-ns!
"Tag every fn the namespace defined. Wrapping a fn that returns non-Hiccup is
safe (tag-hiccup is a no-op on non-elements), so we don't pick functions."
[ns-sym]
(doseq [[_ v] (ns-interns ns-sym) :when (and (var? v) (fn? @v))]
(instrument-var! v)))
tag-hiccup is the one-element version of the tree walk below: if its argument is an element vector, it adds data-myapp-src/data-myapp-name; otherwise it returns it unchanged. Because instrument-var! reads the var's metadata (always present after a defn), the component tag works even for a function whose root is built dynamically — and clicking that root opens the function definition.
How the loader hooks the live-reload watcher. We tr-load! view namespaces instead of load-file, both at startup and on change — but only views, to avoid the tools.reader cost on files with no Hiccup. Views carry no inspector-specific marker, so we detect them by a naming convention:
(defn- view-ns-file? [path]
(str/ends-with? (str path) "views.clj")) ;; web/views.clj, admin/views.clj, …
Wrap tr-load! in a try that falls back to load-file — a reader edge case must never break your reload loop.
Trade-off — convention over opt-in. A naming convention means a view placed in a non-
views.cljfile silently won't be instrumented. We accept that: it is a dev affordance with a harmless failure mode, and the convention is one the project already follows. The alternative (a per-namespace marker like^{:myapp/views true}) is more explicit but adds ceremony; pick whichever your team prefers.
Keeping the tags alive
Here is a subtlety worth calling out, because it produces a baffling symptom. The source tags exist only because the loader applied them — they live on the runtime functions (the instrument-var! wrappers) and on the element metadata that tr-load!'s tools.reader pass attached. So any re-definition of a view namespace that goes around the loader — a plain load-file, or evaluating the namespace from your editor/REPL (a "Load File", an eval of a single defn, or an editor's eval-on-save) — re-defs those functions with the default reader and no instrument-ns!, silently stripping every tag in that file until the next loader pass. The visible effect: "I edited a view, saved, and after the reload a bunch of elements lost their inspection border." It's easy to misattribute to the edit; the real cause is a second, untagged load behind the watcher.
The fix is a principle, not a mechanism: tr-load! is the single source of truth for tagged view code. Make it the only path that re-loads views and the problem can't occur. Concretely, let the file-watcher own reloads and turn off any editor "evaluate/load on save" for view namespaces — tr-load! already evals into the running REPL, so an editor re-load of the same file is pure redundancy that happens to strip the tags.
We did briefly build the obvious "fix" — a var watch on each view fn that re-instruments whenever it's re-defined out of band — and then deleted it. It's the wrong direction: reactive instead of preventive, and not even airtight, because the re-tag is asynchronous, so a page fetch can land in the window between the strip and the heal (you see a partial page, and a manual refresh "fixes" it). Healing the symptom is strictly worse than removing the cause. If you genuinely need out-of-band re-defs to stay tagged — a REPL-eval-heavy flow where you hover the page before saving — the airtight shape is a dev request middleware that re-tags any dirtied file before serving (correct at fetch time, whatever the timing), but that's only worth its machinery for that narrow case.
What the loader should do is degrade gracefully on its own:
Degrade per form, not per file. tr-load! evals each tagged form inside a try. If a transformed form won't compile — an edit hit a construct the call-site/element rewrite mishandles — it loads that one form plain and logs which one, rather than letting the whole file fall back to an untagged load. The function is still defined and still root-tagged by instrument-ns!; only its element-level tags are missing, and the gap is logged, not silent. (reload-changed! keeps the outer try/load-file from the previous section as a last resort for a read error that kills the entire file.)
Lesson. When a feature works by decorating runtime vars — instrumentation, tracing,
clojure.specinstrumentation — a re-defbehind your back silently undoes the decoration. The temptation is to re-apply it reactively with a var watch; resist it. Keep one authoritative path that applies the decoration, route all reloads through it, and there's no symptom to chase.
Call-site tagging: telling instances apart
Here is the subtlety the three-coordinate table hinted at. Consider a dashboard that calls one component eight times:
[:dl
(stat-card "Total Users" total-users)
(stat-card "Links Sent" links-sent)
,,, ] ; six more
All eight rendered cards carry the same data-myapp-name (…/stat-card) and the same data-myapp-src (the defn site). Nothing records which call produced which card. So if your cursor is on the "Total Users" call and you want only that card to light up, there is no way to know — the instance identity isn't in the DOM.
The fix is to tag each rendered instance with its invocation site. During the loader's read pass we wrap calls to view fns:
(stat-card "Total Users" n)
;; becomes, at load time:
(myapp.web.inspector/tag-callsite "myapp/admin/views.clj:123:9" (stat-card "Total Users" n))
tag-callsite adds data-myapp-callsite to the result if it is an element, and is otherwise a no-op. The rewrite is done by wrap-callsites, which walks the form preserving reader metadata and only wraps calls whose head is an unqualified symbol naming a fn the file defined:
(def ^:private no-wrap-heads
;; threading/doto rewrite their arg forms, and a quoted list is data — never
;; wrap a call inside these, or you change its meaning.
#{"->" "->>" "some->" "some->>" "cond->" "cond->>" "as->" "doto" ".." "quote"})
(defn wrap-callsites [names file form wrap?]
(cond
(seq? form)
(let [head (when (symbol? (first form)) (name (first form)))
child-wrap? (if (contains? no-wrap-heads head) false wrap?)
walked (with-meta (apply list (map #(wrap-callsites names file % child-wrap?) form))
(meta form))]
(if (and wrap? (call-head names form) (form-span form))
(let [[l c] (form-span form)]
(with-meta (list `tag-callsite (src-key file l c) walked) (meta form)))
walked))
(vector? form) (with-meta (mapv #(wrap-callsites names file % wrap?) form) (meta form))
,,, )) ; map/set/else preserve metadata the same way
Now the two cases resolve correctly, and — this is the nice part — they are the same mechanism:
- Distinct call sites (the eight
stat-cards): each gets a differentdata-myapp-callsite, so a cursor on one call lights up exactly one card. - A single call in a loop (
(for [r recipes] (recipe-card r))): one source site, so all its instances share onedata-myapp-callsite— and lighting up all of them is correct. One place in the code, many renders.
Trade-offs — call-site tagging.
- Per-instance precision has a floor. A looped call can't distinguish iteration 3 from iteration 5 — they share a call site. That is the honest limit of "one source location → N renders," and the behaviour (highlight the whole family) is the right answer, not a bug.
- Rewriting source is delicate. We only wrap unqualified calls to the file's own fns, and we refuse to descend into threading/
quoteforms (where wrapping would change semantics). Reserved names (recur,let, …) are excluded so we can never move a call out of tail position. The conservative guard loses call-site precision for components composed through a threading macro — which view code essentially never does — in exchange for never corrupting code.- It is metadata-preserving by construction. Every rebuilt collection re-attaches
(meta form); the wrapper inherits the call's reader span. If you get this wrong you silently break the element layer, so test thatdata-myapp-srcstill appears after wrapping.
From metadata to attributes, at the render boundary
The metadata rides on the runtime Hiccup, but a browser can't read Clojure metadata. We translate it to attributes by walking the assembled tree just before stringification:
(defn tag-tree
"Add data-myapp-src to every element carrying :line + :myapp/file metadata."
[node]
(cond
(vector? node)
(let [m (meta node) children (mapv tag-tree node)]
(if (and (:line m) (:myapp/file m) (element? node))
(let [has-attrs? (map? (second children))
attrs (if has-attrs? (second children) {})
body (subvec children (if has-attrs? 2 1))]
(into [(first children)
(assoc attrs :data-myapp-src
(str (:myapp/file m) ":" (:line m) ":" (or (:column m) 1)))]
body))
children))
(seq? node) (doall (map tag-tree node))
:else node))
We never call this directly — that would defeat Hiccup's compile-time precompilation in prod. We gate it behind a macro that vanishes in prod, and call it once, in the layout every page passes through:
(defmacro tag-root [tree] (if dev? `(tag-tree ~tree) tree))
(defn base-layout [& body]
(page/html5
[:head ,,, ]
[:body
(tag-root body) ;; <- dev: tagged; prod: the bare tree
,,, ]))
The two layers compose cleanly. tag-tree only tags elements that still carry reader metadata; a component's root was rebuilt by tag-hiccup/tag-callsite (so its reader metadata is gone) and keeps its component/call-site tags, while every inner literal keeps its own :line. Roots resolve to their function; inner literals to their exact line.
The browser overlay
The front end is a self-contained script, inlined with the defn-asset macro from the Hiccup views chapter so it is only ever served in dev. It opens its own connection to the /dev/ws endpoint, draws a highlight box on hover, shows a breadcrumb of tagged ancestors, and opens source on click.
Two pieces beyond the basics are worth showing.
The breadcrumb folds component + call site. As you hover, we walk the data-myapp-src ancestors into breadcrumb steps. A component instance carries two source locations on one node — its definition and its call site — so instead of two crumbs with the same name we fold them into one: the name once, then two tiny selectable glyphs, λ (the definition) and () (the call site):
… ▸ dl ▸ stat-card () λ ▸ dd
// each tagged ancestor becomes one or two STEPS:
function chain(node) {
var out = [], cur = node;
while (cur) {
if (cur.getAttribute && cur.getAttribute('data-myapp-src')) {
var name = shortName(cur), call = cur.getAttribute('data-myapp-callsite');
out.push({ node: cur, src: cur.getAttribute('data-myapp-src'),
name: name, kind: call ? 'defn' : 'element' });
if (call) out.push({ node: cur, src: call, name: name, kind: 'callsite' });
}
cur = cur.parentElement;
}
return out;
}
Alt+wheel walks the chain without moving the mouse. Hover selects the most-nested element (e.target.closest('[data-myapp-src]')); holding Alt and scrolling then walks outward (λ → () → parent element → …) and back in, so you can select an outer component or its call site without nudging the pointer. While Alt is held we freeze the selection (a mouse jitter during scrolling must not reset it), and a click opens whatever step is currently selected — the λ, the (), or an element — not whatever is physically under the cursor.
The editor bridge
A browser can't open your editor; the server can. In the live-reload chapter the dev-reload WebSocket handler only did open/close/error. We make it a small relay hub between two kinds of peer — browsers and the editor — and teach it to push an "open" command to the editor:
(defn- handle-open!
"Browser → open file. Pushes the open to a connected editor (vscode API via
Joyride). With no editor connected the open fails — no shell-out fallback."
[channel src line column]
(let [reply (fn [m] (send! channel (assoc m :type "open-result")))]
(if-let [f (resolve-source-file src)]
(if (pos? (push-open! (.getPath f) line column)) ;; editor connected?
(reply {:ok true :src src :line line :column column})
(reply {:ok false :src src :error "no editor connected"})) ;; Joyride not running
(reply {:ok false :error (str "unresolved source: " (pr-str src))}))))
resolve-source-file is the trust boundary — a browser can send any string, so we canonicalize, reject .., require a .clj/.cljc extension, and confirm the result is inside src/ using NIO Path.startsWith (a plain string-prefix check would let a sibling src-other/ slip through):
(defn- resolve-source-file ^File [src]
(when (and (string? src) (not (str/includes? src "..")))
(let [^File root @src-root
cf (.getCanonicalFile (if (.isAbsolute (File. src)) (File. src) (File. root ^String src)))]
(when (and (.exists cf) (re-find #"\.cljc?$" (.getName cf))
(.startsWith (.toPath cf) (.toPath root)))
cf))))
How the editor actually opens the file is Part II. push-open! writes an {type "open" ...} message to every connected editor over the same /dev/ws socket and returns the delivery count; handle-open! uses that count to report success or "no editor connected" back to the browser. There is no shell-out — opening always goes through a live editor agent.
Part II — Code → element
The forward direction tags the DOM with source coordinates. The reverse direction is the dual: take an editor cursor and find the DOM. It needs three pieces — an index (so the server can map a cursor to coordinates), an editor agent (so the editor can report the cursor), and a highlighter in the browser.
The index: reuse the same read pass
We already read every view with tools.reader. While we're there, we build a per-file span index — top-level defn spans (→ component), every element literal's span (→ element), and every call-to-a-view-fn span (→ call site):
(def view-index (atom {})) ;; file -> {:defns [...] :elements [...] :calls [...]}
(defn index-ns! [file ns-sym forms]
(let [defn-forms (filter defn-form? forms)
names (view-defn-names forms)]
(swap! view-index assoc file
{:defns (keep (fn [f] (when-let [s (form-span f)]
{:name (str ns-sym "/" (second f)) :span s})) defn-forms)
:elements (vec (mapcat #(collect-elements file %) defn-forms))
:calls (vec (mapcat #(collect-calls names file %) defn-forms))})))
The keys it produces (file:line:col for elements/calls, ns/fn for components) are byte-identical to what the forward direction stamps onto the DOM — so resolving a cursor yields strings the browser can match with a plain attribute selector. No fuzzy matching.
resolve-cursor then maps a cursor to all three coordinates by span containment (inclusive start, exclusive end — tools.reader's :end-column is one past the last char), picking the innermost containing span for the element and call:
(defn resolve-cursor [file line col]
(when-let [{:keys [defns elements calls]} (get @view-index file)]
(when-let [d (innermost-containing defns line col)]
{:component (:name d)
:file file
:defn-lines [(first (:span d)) (nth (:span d) 2)]
:element (:key (innermost-containing elements line col))
:callsite (:key (innermost-containing calls line col))})))
Because the index lives in an atom the dev loader refreshes on every reload, it always reflects the currently loaded source — which is what the browser was rendered from. It is empty in production (the loader never runs), so resolve-cursor returns nil and the whole reverse path is inert.
The editor agent: piggyback on Joyride
Capturing cursor movement requires running code in the editor's extension host — there is no config-only or LSP route (the language-server protocol has no cursor-moved notification, and Calva exposes no such hook). So we need an extension. But we don't have to write and package one: Joyride (Calva's sibling) runs ClojureScript in VS Code's extension host with full access to the vscode API, so the editor agent is a script that lives in the repo under .joyride/scripts/, installed by adding betterthantomorrow.joyride to the devcontainer's extension list. No TypeScript, no build, no .vsix.
Why Joyride and not a custom extension? A TypeScript extension is fully decoupled and works for non-Joyride users, but you must build and auto-install a
.vsixfrom a lifecycle hook (the declarativecustomizations.vscode.extensionsonly takes marketplace IDs). For a Clojure team in a devcontainer, a Joyride script is genuinely project code — a.cljsin the repo — with none of that overhead. The cost is coupling navigation to Joyride being installed. We did once keep acode -gshell-out as a fallback, but landing it in the right window required sniffing the newestVSCODE_IPC_HOOK_CLIsocket — fragile enough that we dropped it. With Joyride already supplying the reverse direction, requiring it for the forward direction too is a fair trade for deleting that workaround.
The script holds one WebSocket to the dev server. It sends the cursor (debounced) and receives open commands (opening the file via the vscode API — exact window, exact range, no shell-out):
(ns workspace-activate
(:require ["vscode" :as vscode]))
(defn- on-selection [event] ;; report the cursor (debounced)
(let [doc (.-document (.-textEditor event))
sel (aget (.-selections event) 0)
file (rel-path (.. doc -uri -fsPath))] ;; …/src/foo/views.clj -> foo/views.clj
(when file
(debounce 80
#(ws-send! {:type "cursor" :file file
:line (inc (.. sel -active -line)) :col (inc (.. sel -active -character))})))))
(defn- open-file! [file line col] ;; act on an open command from the browser
(let [uri (.file vscode/Uri file)
pos (vscode/Position. (max 0 (dec line)) (max 0 (dec col)))]
(-> (.openTextDocument vscode/workspace uri)
(.then #(.showTextDocument vscode/window % #js {:selection (vscode/Selection. pos pos)}))
(.then #(.revealRange % (vscode/Range. pos pos) (.. vscode -TextEditorRevealType -InCenter))))))
A node-22 extension host has a global WebSocket (undici), so the script opens ws://localhost:3000/dev/ws directly. (On older hosts that lack it, an HTTP fetch POST works the same — we benchmarked both on loopback at well under a millisecond; transport latency is never the bottleneck for a debounced cursor.) Everything is event-driven: on the socket's open event the script sends {:type "hello" :role "editor"} to register up front — so a browser click can be routed to the editor before you've even moved the cursor — and on close/error it reconnects. The socket lives in a defonce atom so a re-eval disposes and reconnects cleanly. Getting that reconnect right across a REPL restart turned out to be its own saga — the next section.
The relay, and a lesson about ordering
On the server, the same /dev/ws handler now tags each client's role (browser by default; editor on its hello/cursor) and routes:
(defn- handle-cursor! [file line column]
(when (and (number? line) (number? column))
(when-let [f (resolve-source-file file)] ;; same trust boundary
(when-let [resolved (inspector/resolve-cursor (classpath-relative f) line column)]
(when (:component resolved)
(notify-highlight! resolved)))))) ;; broadcast to browsers
We learned one thing here the hard way, worth passing on. An early version stamped each highlight with a sequence number and had the browser drop any with seq <= lastSeq, to guard against out-of-order delivery. But highlights travel over a single ordered WebSocket — they can't arrive out of order — so the guard bought nothing and introduced a footgun: when the server's counter reset on a hot-reload (a plain def (atom 0) re-evaluates to 0), the browser's lastSeq was still high and silently dropped every subsequent highlight. We removed the guard entirely.
Trade-off / lesson. Don't add ordering machinery for a channel that already guarantees order. We dropped the sequence number from both ends entirely — an ordered socket needs no de-duplication, and the only thing a counter added was a way to stall after a reload reset it.
Reconnecting through the extension host
Making the editor survive a REPL/server restart on its own took longer than the rest of the inspector combined, and every wrong turn traced back to the same root: the VS Code extension host is a Node runtime with a few non-obvious WebSocket behaviours. Three lessons, because they'll bite anyone running a duplex socket from Joyride.
1. Async logs go to the DevTools console, not the Joyride output channel. Joyride binds *out* to its output channel only during a script's synchronous top-level evaluation. Anything that runs later — a setTimeout, a vscode event, a WebSocket handler — runs outside that binding, so its println lands in the Extension Host DevTools console (Help → Toggle Developer Tools), which you won't see unless it's open. We spent a long time concluding "the events never fire" purely because we were watching the wrong console — they fired the whole time. Debug the socket in DevTools, or have handlers report back over the socket itself (which the server can log).
2. undici fires close for a dropped link but only error for a failed connect. Node's WebSocket distinguishes a connection that was established and then dropped (the server died — close, code 1006) from one that never connected (the server is still booting after a restart — error, no close). Reconnect logic that listens to only one of them stalls on the other: listen to close alone and you never retry while the server is restarting. So both events schedule a reconnect, deduped so the rare both-fire case still yields a single retry:
(defn- connect! []
;; Tear down a prior socket; only close one that's OPEN/CONNECTING (see lesson 3).
(when-let [ws (:ws @!state)] (when (< (.-readyState ws) 2) (try (.close ws) (catch :default _ nil))))
(let [ws (js/WebSocket. ws-url)]
(swap! !state assoc :ws ws)
(.addEventListener ws "open" (fn [_] (ws-send! {:type "hello" :role "editor"})
(swap! !state assoc :attempt 0)))
(.addEventListener ws "message" on-message)
(.addEventListener ws "close" (fn [_] (schedule-reconnect! ws))) ;; dropped link (1006)
(.addEventListener ws "error" (fn [_] (schedule-reconnect! ws))))) ;; failed connect
(defn- schedule-reconnect! [ws] ;; only the *current* socket retries, exactly once
(when (= ws (:ws @!state))
(let [n (inc (:attempt @!state 0))]
(swap! !state assoc :ws nil :attempt n
:reconnect (js/setTimeout (fn [] (connect!)) (backoff n))))))
3. Never call .close() from an error/close handler. In a browser this is harmless; in undici, .close() on a failing socket runs the close path synchronously, re-firing the same event into the same handler — unbounded recursion, Maximum call stack size exceeded, and worst of all it silently wedges the whole Joyride extension host. An early error handler did exactly this — (.close ws) "to force a close so the reconnect fires" — which was both unnecessary (undici fires close right after error anyway) and the actual cause of every "editor won't reconnect" report: the reconnect attempt was crashing on the recursion, and lesson 1 hid the crash. The fix is to never close from a handler — schedule-reconnect! only ever schedules — and to skip closing an already-closing socket in the teardown.
The browser side needs none of this. Chromium fires
close/errorreliably and.close()is async there, so the overlay reconnects onclose/errorwith no heartbeat and tracks the connection in its badge. We did briefly add a ping/pong liveness heartbeat to the browser while debugging blind — but once the events proved reliable it was pure asymmetry (the editor has none) for a case a page reload already covers, so we removed it along with the server'spong. Both ends now use the same event-driven strategy, each matched to its runtime's reality.
The browser highlighter
The browser handles {type: "highlight", component, file, defn-lines, element, callsite} with DOM-as-truth precedence: the server proposes coordinates, but the browser highlights based on what actually rendered.
function handleHighlight(m) {
clearReverse();
if (!m.component) return;
var compNodes = bySel('data-myapp-name', m.component);
// element target (strong): callsite → element literal → component root
var elNodes = bySel('data-myapp-callsite', m.callsite);
if (!elNodes.length) elNodes = bySel('data-myapp-src', m.element);
if (!elNodes.length) elNodes = compNodes;
// component frame (soft): per-instance roots if present, else a bounding box
// over the defn's source-span members (for components with no DOM root).
var frame = compNodes.length ? compNodes : spanNodes(m.file, m['defn-lines']);
drawFrame(frame); drawElement(elNodes); // soft frame + strong box + a pulse
}
The precedence resolves the cursor's intent automatically:
- Cursor on a call
(stat-card …)→callsitematches → that one card lights up. - Cursor inside a component's body → no callsite;
elementmatches → that literal's nodes (or the component root if the cursor is on the root literal, which carries the defn-site key). - Cursor on a call to a non-component helper →
callsite/elementfind nothing on the page → it harmlessly falls back. The DOM, not the resolver, decides what exists.
The component frame has one wrinkle worth its own note. A component that returns a single element vector has a data-myapp-name root we can frame per instance. But a layout like app-layout returns a string (it calls html5), so it has no root node. For those we fall back to a bounding box over every on-screen node whose data-myapp-src line falls within the defn's span — so even a root-less layout gets a meaningful boundary.
Trade-off — root-less components. Keying the component frame on a
data-myapp-nameroot is precise per instance but misses string-returning layouts and fragments. Span-membership covers them at the cost of a single bounding box instead of crisp per-instance frames. We use the root when present and the span otherwise; the two together cover every component shape.
Surfacing a failed reload
One more dev affordance, reusing the same relay. When you save a file with a syntax error, the hot-reload hook can't load it: the edit doesn't take, and — crucially — the browser is not told to reload, so it keeps showing the old page. That's a quiet trap. The page looks fine, so you assume your change applied when it didn't; the only sign is a stack trace in the server terminal.
So on a reload exception the hook pushes one message, and the dev-reload script turns it into a dismissible banner:
;; hot-reload hook, in the catch around the file load:
(dev-reload/notify-reload-error! file-path (some-> e .getMessage))
;; → broadcasts {:type "reload-error" :file … :error …} to browser clients
// dev-reload.js
else if (data.type === 'reload-error') showStaleWarning(data.file, data.error);
⚠
myapp/web/views.cljfailed to reload — this page may be stale. Fix the error and save.
The wording is deliberately soft. We know a reload failed; we don't know the current page actually renders the broken file — it could be an unrelated source reload — so "may be" is the honest claim. There's no explicit clear path: the next successful reload navigates the page (a full location.reload()), which removes the banner along with everything else, and the × dismisses it in the meantime. Dev-only, like the rest — the message originates only from the dev file-watcher, and the script ships only in the dev-gated asset block.
Keeping production clean
Trace every piece and confirm it disappears in prod:
tag-rootexpands to the bare literal, so Hiccup precompiles your markup exactly as before — notag-tree, nodata-*attributes in the output.inspector.jsis emitted bydefn-assetonly inside the dev-gated block (next to the dev-reload script), so it never reaches a production page.inspector-loadand the editor/relay code live underdev/, on the classpath only via the:devalias.tr-load!— and therefore all instrumentation, call-site wrapping, and indexing — is dev-only. Production loads views the normal way: no tools.reader, no metadata, no wrapping, an emptyview-index.- The editor agent is a Joyride script in
.joyride/; it does nothing without the Joyride extension and a running dev server.
The feature is structurally absent, not merely disabled.
Trade-offs & limitations, in one place
- Per-instance precision has a floor. Distinct call sites are distinguishable; instances of a single looped call are not (they share a call site) — and highlighting all of them is the correct semantics, not a defect.
- Definition vs. call site is an intent, resolved by position. A cursor in a component's body means "show me my output" (component/element); a cursor on a call means "show me what this produces" (call site). The breadcrumb's λ/() makes the same choice clickable in the forward direction. Navigating to the specific call site that produced one clicked instance is the one thing that needs the call-site tag we added — and it now works.
- Source rewriting is conservative. Call-site wrapping refuses threading/
quotecontexts and reserved names; it loses precision in component-via-threading composition (rare in views) to guarantee it never corrupts code. - Editor coupling. Navigation pushes to a connected Joyride agent (exact window, exact range, works in remote containers); with no agent connected, the browser reports "no editor connected" rather than guessing. Both directions need the agent — an earlier
code -gfallback for the forward direction was dropped because landing it in the right window required a fragile newest-IPC-socket sniff. - The dev WebSocket is unauthenticated. Anything that can reach
localhost:3000/dev/wscan ask to open a (src-confined) file or push a cursor. That is acceptable for a dev-only, loopback service; don't expose the dev server. - A small dev startup cost. Reading views through tools.reader, wrapping calls, instrumenting vars, and indexing is more work than a plain
load— paid once per view file at startup/reload, and never in production.
Design decisions worth noting
- The metadata rides on the value — there is no index to keep in sync (forward). The location travels welded to the Hiccup vector from read time, through
eval, through everyforand helper, onto the page. Afor-row maps to its template line; anif-branch maps to the branch taken. - The loader instruments; the views stay plain. Auto-instrumenting every fn in a
views.clj(safe becausetag-hiccupno-ops on non-elements) gets the component layer with zero source ceremony: views need no per-function annotation. - Only the markup is touched.
element?gates every mutation, so a blanket walk can never corrupt a non-Hiccup value. That is what makes it safe to apply across every view without auditing each one. - DOM-as-truth precedence (reverse). The server proposes coordinates; the browser highlights whatever actually rendered, so conditionals, loops, and not-taken branches all behave correctly without the server predicting anything.
- A resource check for
dev?. Detecting dev withrequiring-resolveof the hot-reload namespace can close a circular load during the app's own startup, throw, get swallowed, and silently freezedev?to false.(io/resource "hot_reload.clj")answers the same question without loading anything.
What you now have
Building on the live-reload chapter's watcher and dev WebSocket, with one namespace (myapp.web.inspector), one dev loader (inspector-load), one inlined script (inspector.js), one Joyride script (workspace_activate.cljs), and a relay in dev-reload, you get a bidirectional inspector:
- Hold
Alt+Shift+I, hover any element, walk its ancestor chain with Alt+wheel (component λ / call site ()), and click to open the exact source. - Move your cursor in the editor and the matching element lights up — the right card among eight, or every row of a
for, framed within its component. for-rows resolve to their template; conditionals to the branch taken; clicks to the selection, not the pointer.- Zero production footprint — no attributes, no script, no server code, all structurally excluded.
The whole thing rests on one trick that the front-end world never needed: Hiccup is plain data with no source information, so you manufacture it — and clojure.tools.reader plus the Clojure compiler will, between them, carry a line number from your file to a running vector if you simply stop throwing it away. Everything else — call sites, the reverse direction, the editor bridge — is built on that single welded coordinate.
Tightening the Reload Loop: DOM Morphing and CSS Hot-Swap
The live-reload chapter gave us a working feedback loop: a file watcher loads the changed .clj file, a WebSocket tells the browser, and the browser does a full reload with the scroll position restored. That is correct. It is also a blunt instrument.
A full reload throws away all page state. It loses your scroll position (we patched that with the scroll stash), but it also blurs the field you were typing in, collapses every open <details>, and discards any in-progress form input. For the most common edit during day-to-day work -- a one-line tweak to a view function -- that is wildly disproportionate. You changed some markup; the browser tore down and rebuilt the entire page.
And reload is not even the right answer for every kind of save. A CSS rebuild should not reload the page at all -- a stylesheet is declarative, so swapping it in place restyles the page with no flash and no lost state. Meanwhile a .js module edit genuinely must reload, for reasons rooted in how ES modules work. Different edits have genuinely different correctness constraints.
The insight that drives this chapter is that a save is not one thing. A view edit, a non-view code edit, a JavaScript edit, and a CSS rebuild each have a narrowest correct response, and matching the response to the edit is what makes the loop feel instant instead of janky. We call the mapping the per-edit delivery matrix:
| You edit... | Response |
|---|---|
a view namespace (*views.clj) | morph <main> in place -- keeps scroll, focus, open <details> |
any other .clj | full reload, scroll restored |
a served .js module | full reload, scroll restored |
(Tailwind rebuilds styles.css) | hot-swap the stylesheet <link> -- no reload |
This chapter rebuilds the watcher's dispatch around that matrix. It assumes the basic file watcher and WebSocket from the live-reload chapter, the server-rendered Hiccup views and their client dispatcher's fetchAndMorph (idiomorph) from the Hiccup views chapter, the source inspector and its inspector-load loader from the source inspector chapter, and the Tailwind setup from the asset pipeline chapter.
One artifact, two deliveries. Dev and production ship byte-identical asset files from the same source; they differ only in the HTTP envelope (URL shape, cache headers) and the build cadence (watch vs. one-shot). The dev story below is the "watch" cadence of that single pipeline; the asset pipeline chapter covers the production cadence -- content hashing, an import map, and immutable caching. Nothing here changes the bytes between dev and prod; it only changes when and how they are delivered.
Revisiting load-changed-file
In the live-reload chapter, load-changed-file had a single branch: a .clj file changed, so load it and trigger a full reload. The matrix needs three branches, checked in order. Here is the real thing:
(defn- load-changed-file
"Loads a changed file."
[{:keys [event-type path]}]
(when (= event-type :modify)
(cond
;; Tailwind output: debounced, CSS-ready refresh (fires after the write).
(.endsWith (str path) "styles.css")
(debounced-css-reload!)
(clj-file? path)
(let [file-path (str path)
start-time (System/nanoTime)]
(log/info "File changed" {:file-path file-path})
(try
(before-refresh)
;; View namespaces go through the inspector's tools.reader load (for
;; element-level source metadata); everything else is a normal load. A
;; view-ns edit is morphable; other .clj edits force a full reload.
(let [view? (inspector-load/reload-changed! file-path)]
(when-not view? (load-file file-path))
(after-refresh (boolean view?)))
(let [duration-seconds (/ (- (System/nanoTime) start-time) 1e9)]
(log/info "Successfully reloaded file"
{:file-path file-path
:duration-seconds duration-seconds}))
(catch Exception e
;; Reload failed (syntax error, etc.) -- the edit didn't take and we
;; did NOT reload the browser, so its page is now potentially stale.
(dev-reload/notify-reload-error! file-path (some-> e .getMessage))
(let [duration-seconds (/ (- (System/nanoTime) start-time) 1e9)]
(log/error e "Error reloading file"
{:file-path file-path
:duration-seconds duration-seconds})))))
(asset-file? path)
(do
(log/info "Asset file changed, reloading browser" {:file-path (str path)})
(dev-reload/reload!)))))
Read top to bottom, this is the matrix:
styles.csschanged. This is Tailwind's output, not a file you edited by hand, so it gets a debounced CSS swap (debounced-css-reload!, covered below). No code loads; no page reloads.- A
.cljfile changed. This is the interesting branch. Instead of an unconditionalload-file, we first askinspector-load/reload-changed!, which returns truthy if the file was a view namespace. That truthiness becomes themorphable?flag passed toafter-refresh. A view edit is morphable (the browser can morph<main>); any other.cljedit is not (full reload). - A
.js(or other.css) asset changed. A plain full reload viadev-reload/reload!.
The cheap predicates are unchanged from before, with one addition for assets:
(defn- clj-file?
"Returns true if the path has a .clj extension."
[^java.nio.file.Path path]
(.endsWith (str path) ".clj"))
(defn- asset-file?
"Returns true if the path has a .js or .css extension."
[^java.nio.file.Path path]
(let [s (str path)]
(or (.endsWith s ".js") (.endsWith s ".css"))))
Two refinements deserve their own attention: the failure path and the view/non-view split.
The failure path is new. A broken file -- a syntax error -- no longer just logs and moves on. It also sends a reload-error message to the browser via notify-reload-error!. The edit didn't take, so the browser was never refreshed, which means the page in front of you no longer matches the code you just saved. The reload-error message raises a soft "this page may be stale" banner so you know why the page and the code disagree. Fix the error, save again, and the next successful reload clears the banner on its own.
Why view namespaces take a different load path
The .clj branch does not call load-file directly. It first asks inspector-load/reload-changed!, and only falls back to load-file when that returns falsey. For files whose name ends in views.clj, reload-changed! loads them through clojure.tools.reader instead of the normal loader, and returns truthy.
The reason belongs to the source inspector chapter: the default Clojure reader attaches no line metadata to nested vector literals, but tools.reader does, and that metadata is what lets the inspector tag every rendered element with the source location that produced it. A view edit must take the tools.reader path so the inspector's source map stays current; everything else takes the ordinary load-file.
For the matrix, the relevant output is just the boolean: a views.clj edit is morphable, a non-view .clj edit is not. That boolean is the entire reason this branch exists in the shape it does.
Watching static/ too
The basic watcher only registered the src/ tree. The matrix needs more: the styles.css branch and the .js asset branch only fire if something is watching where those files live, and they live under static/ -- Tailwind writes its output to static/styles.css, and the served ESM modules live under static/js/. So start-file-watcher now walks both roots:
;; Watch src/ (code) AND static/ (so the Tailwind output styles.css and the
;; source ESM under static/js trigger the right refresh).
_ (doseq [r ["src" "static"]
:when (.exists (java.io.File. ^String r))
^Path dir (->> (Files/find (.toPath (java.io.File. ^String r))
Integer/MAX_VALUE
(reify
BiPredicate
(test [_ _path attrs]
(.isDirectory ^BasicFileAttributes attrs)))
(make-array java.nio.file.FileVisitOption 0))
.iterator
iterator-seq)]
(.register dir ws kinds))
If we only watched src/, JS edits and CSS rebuilds would fire nothing. The second root closes that gap. Everything else about the watch loop -- daemon thread, ENTRY_MODIFY/ENTRY_CREATE, the silent ClosedWatchServiceException on shutdown -- is exactly as the live-reload chapter built it.
CSS: One Long-Lived Tailwind Watcher
The matrix line for CSS is conspicuous: CSS is absent from the "save triggers a rebuild" path entirely. The watcher only reacts to styles.css being rewritten; it never rewrites it. That is deliberate, and it is the biggest structural change from a naive design.
The obvious approach is to rebuild Tailwind on every .clj save -- a .clj file might introduce a new utility class, so regenerate the stylesheet, then refresh. That is wrong in two ways. It couples CSS rebuilds to code reloads, so a Clojure edit that touches no markup still pays for a full Tailwind run. And it puts the rebuild on the critical path of every single save.
The better design decouples them. Tailwind v4 has its own watch mode: point it at your input and output and it watches the @source globs (which include ./src), rebuilding incrementally whenever a class appears or disappears. So we start one long-lived Tailwind process at dev startup and let it own the stylesheet entirely:
(defonce ^{:doc "The long-lived Tailwind --watch Process, or nil."} tailwind-watcher (atom nil))
(defn start-tailwind-watch!
"Start ONE long-lived `tailwindcss --watch=always` writing static/styles.css for
dev (served unhashed, no-store). --watch=always (not plain --watch) is required:
plain --watch exits as soon as stdin closes, which a script/REPL launch triggers.
Tailwind v4 also watches the @source globs (./src), so a .clj edit that adds a
utility class rebuilds the stylesheet on its own -- decoupled from code reload."
[]
(stop-tailwind-watch!) ; fully terminate any prior process first -- no double writers
@tailwind-shutdown-hook ; install the JVM-exit cleanup once
(let [^"[Ljava.lang.String;" cmd (into-array String
["tailwindcss" "-i" "input.css" "-o" "static/styles.css" "--minify" "--watch=always"])
pb (doto (ProcessBuilder. cmd) (.inheritIO))
p (.start pb)]
(reset! tailwind-watcher p)
(log/info "Tailwind --watch started" {:out "static/styles.css"})
p))
Two non-obvious choices here:
Why --watch=always and not plain --watch? Plain --watch exits as soon as its stdin closes. That is fine when you run it by hand in a terminal, but a process you launch from a script or the REPL doesn't keep a TTY attached to its stdin, so plain --watch would exit almost immediately. --watch=always tells Tailwind to keep watching regardless of stdin. This is exactly the kind of thing you only discover by hitting it; the flag is load-bearing.
Why keep --minify in dev? Because of the "one artifact, two deliveries" rule. Production minifies the CSS, so dev minifies it too -- the dev stylesheet is byte-for-byte what production builds from the same input.css. If dev served unminified CSS and prod served minified, you would have two artifacts and a class of "works in dev, breaks in prod" surprises. The minification cost is invisible (Tailwind is fast and incremental), so there is no reason to introduce the drift.
Lifecycle hardening
Because it is an external OS process, we have to manage its lifecycle by hand. Sloppiness here has a specific, nasty failure mode: two Tailwind processes writing the same styles.css at once, racing each other. Three pieces of hardening prevent it:
(defn stop-tailwind-watch!
"Stop the Tailwind --watch process, WAITING for it to actually exit before
returning -- so a restart can never leave two tailwinds writing styles.css at once."
[]
(when-let [^Process p @tailwind-watcher]
(.destroy p)
(when-not (.waitFor p 2 TimeUnit/SECONDS) (.destroyForcibly p))
(reset! tailwind-watcher nil)
(log/info "Tailwind --watch stopped")))
(defonce ^:private tailwind-shutdown-hook
;; Destroy the external Tailwind process on JVM exit so a killed REPL never
;; orphans it (an orphan would keep writing styles.css behind our back).
(delay (.addShutdownHook (Runtime/getRuntime)
(Thread. ^Runnable (fn [] (stop-tailwind-watch!)) "tailwind-shutdown"))))
stop-tailwind-watch!waits for the process to actually die (.waitForwith a timeout, escalating to.destroyForcibly) before returning. So whenstart-tailwind-watch!callsstop-tailwind-watch!first, the old process is gone before the new one starts -- never two writers.- A JVM shutdown hook destroys the Tailwind process when the REPL exits. Kill your REPL ungracefully and you would otherwise orphan a Tailwind process that keeps rewriting
styles.cssbehind the back of your next session. start-tailwind-watch!is idempotent: it stops any prior process before starting a new one, so re-running it (orstart) can never accumulate watchers.
When this process rewrites static/styles.css, the file watcher sees the write -- that is the styles.css branch of load-changed-file. Which brings us to the race.
Debouncing the CSS-ready refresh
A single Tailwind rebuild can emit several filesystem events (it may truncate, then write, then flush). If we fired a browser update on the first event, the browser could re-fetch a stylesheet that is mid-rebuild -- empty or half-written. And because Tailwind runs asynchronously, a code edit that adds a class produces a sequence the browser must not observe out of order: the new markup arrives via the code reload, but the matching CSS is still being written.
The fix is to debounce the CSS notification and fire it only after the writes settle:
(defonce ^:private css-debounce-pool
(Executors/newSingleThreadScheduledExecutor
(reify ThreadFactory
(newThread [_ r] (doto (Thread. ^Runnable r "css-reload-debounce") (.setDaemon true))))))
(defonce ^:private css-debounce-task (atom nil))
(defn- debounced-css-reload!
"Coalesce a burst of styles.css writes into a single browser refresh ~150ms
after the last write, guaranteeing the new CSS is fully on disk first."
[]
(when-let [^ScheduledFuture t @css-debounce-task] (.cancel t false))
(reset! css-debounce-task
(.schedule ^ScheduledExecutorService css-debounce-pool
^Runnable (fn [] (dev-reload/notify-css!)) 150 TimeUnit/MILLISECONDS)))
Each styles.css event cancels the pending task and reschedules it ~150ms out. A burst of writes collapses into a single notify-css!, fired only once the file has been quiet for 150ms -- by which point the rebuilt CSS is fully on disk. One daemon-thread scheduler does all of it. This is the kind of small race that makes a decoupled watcher feel unreliable if you skip it, and rock-solid once it is in place.
Stable dev URLs with no-store
The dev stylesheet is served at a stable, unhashed URL (/styles.css) -- there is no content hash in dev, because the URL never changes. That stability is exactly why the next problem exists: the browser would happily cache the stable URL and serve stale bytes after a rebuild. We solve that on the response side, not by mangling URLs. Dev marks served .css/.js as no-store:
;; myapp.web.routes
(defn wrap-dev-no-store
"Dev only: Cache-Control: no-store on served .css/.js so a stable (unhashed) dev
URL never serves stale bytes after Tailwind --watch / esbuild rewrites the file."
[handler]
(fn [request]
(let [resp (handler request)]
(if (and resp (re-find #"\.(?:css|js)$" (or (:uri request) "")))
(assoc-in resp [:headers "Cache-Control"] "no-store")
resp))))
This middleware wraps the static-file handler, and only in dev:
(ring/routes
(cond-> (ring/create-file-handler {:path "/" :root assets/static-root})
assets/dev? wrap-dev-no-store)
(ring/create-default-handler))
The static-root itself is the same idea -- one mechanism with a dev/prod switch:
;; myapp.web.assets
(def static-root
"Dir the Ring file handler serves from: source static/ in dev, the built
myapp/static/ tree in prod (also what Caddy mounts)."
(if dev? "static" "myapp/static"))
In dev the app serves the source static/ tree directly at stable URLs with no-store. In production, Caddy serves the built myapp/static/ tree with content-hashed filenames and immutable caching (the asset pipeline chapter). Same files, two envelopes.
WebSocket: From One Message to Four
The live-reload chapter had exactly one outbound message: reload. The matrix needs more. The dev-reload namespace now emits three message types, and the reload message carries a morphable flag, so the browser has four behaviors to dispatch on.
(defn notify-reload!
"Tell every browser client to reload. `morphable?` (default false) lets the browser
take the state-preserving morph fast path (a view-ns edit) instead of a full
reload (non-view .clj, .js, or a manual trigger)."
([] (notify-reload! false))
([morphable?]
(send-json! (clients-of :browser) {:type "reload" :morphable (boolean morphable?)})))
(defn notify-css!
"Tell browsers to hot-swap the stylesheet <link> (no reload). The dev CSS URL is
stable, so the browser cache-busts it to refetch the rebuilt file."
[]
(send-json! (clients-of :browser) {:type "css"}))
(defn notify-reload-error!
"Tell browsers a source file failed to (re)load (a syntax error or similar), so
the page can warn it MAY be stale."
[file error]
(send-json! (clients-of :browser) {:type "reload-error" :file file :error error}))
So the server emits exactly three browser messages: reload (with a morphable flag), css, and reload-error. The before-refresh/after-refresh hooks in hot-reload are the thin shims that turn the load result into the first of those:
(defn before-refresh
"This hook runs before refreshing a changed file."
[]
(log/info "Code refresh starting..."))
(defn after-refresh
"Runs after a changed .clj file reloads: notify the browser. A view-ns edit is
morphable (state-preserving <main> morph); other .clj edits force a full reload.
CSS is rebuilt out-of-band by the Tailwind --watch process, not here."
[morphable?]
(log/info "Code refresh completed, notifying browser..." {:morphable morphable?})
(dev-reload/notify-reload! morphable?))
Note what is not in after-refresh anymore: no Tailwind shell-out, no CSS hashing. That responsibility moved entirely to the long-lived watcher. after-refresh does one thing -- notify the browser, with the right morphable? flag.
A manual (reload!) from the REPL is intentionally not morphable -- it defaults to a full reload, the safe choice when you trigger it by hand. The send-json! pruning of dead channels and the requiring-resolve-guarded /dev/ws route are exactly as the live-reload chapter built them.
The Client Side: One Socket, Four Responses
On the browser side, the dev script dispatches on message type. It is the client end of the delivery matrix:
const ws = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/dev/ws');
ws.onmessage = function (event) {
const data = JSON.parse(event.data);
if (data.type === 'reload') {
if (data.morphable) { morphReload(); } else { hardReload(); }
} else if (data.type === 'css') {
swapStylesheet(); // CSS rebuilt -- swap the <link>, no reload
} else if (data.type === 'reload-error') {
showStaleWarning(data.file, data.error);
}
};
Three message types, four behaviors (because reload forks on morphable). Let us take them in turn.
A view edit: morph <main> in place
The most common edit during day-to-day work is to a view function -- tweak markup, adjust a class, restructure a fragment. For that case a full reload is overkill and actively annoying: it loses scroll, collapses open <details>, blurs the field you were typing in. So a morphable reload morphs the new <main> into the live DOM instead, reusing the production navigation machinery:
// A view-ns edit: morph <main> in place via the dispatcher (state-preserving -- keeps
// scroll, focus, open <details>). Falls back to a full reload on any failure, and
// clears a prior stale banner since the morph won't navigate it away.
function morphReload() {
var bar = document.getElementById('myapp-stale-warning'); if (bar) bar.remove();
import('/js/dispatcher.js')
.then(function (m) {
return m.fetchAndMorph(location.pathname + location.search,
{ target: 'main', replaceUrl: true, focus: false, ignoreActiveValue: true });
})
.catch(function () { window.location.reload(); });
}
The key point is that this is not a new mechanism. fetchAndMorph is the app's production interaction layer (the Hiccup views chapter): it fetches the current URL, parses the response, and uses idiomorph to morph <main> in place, preserving form values, focus, and scroll. Dev hot-reload is just one more caller of it. A few options matter:
target: 'main'-- morph only the main content region. The dev overlay scripts and the<head>are siblings of<main>, so an inner-HTML morph of<main>leaves them untouched. The overlay survives for free.ignoreActiveValue: true-- this one is mandatory. Without it, idiomorph would clobber the value of the field you are currently typing in. With it, your in-progress input survives the morph.focus: false-- a hot reload should not steal focus.- The
.catch(...)falls back to a fullwindow.location.reload()if anything goes wrong, so a morph failure degrades to the safe behavior rather than leaving a half-updated page.
It also clears any leftover stale-warning banner first, because a morph (unlike a reload) does not navigate, so the banner would otherwise persist.
After the morph, fetchAndMorph fires a dispatcher:morphed event. The source inspector listens for it to re-attach its highlight to the freshly morphed DOM -- the source inspector chapter, which precedes this one, owns that behavior. Here it is enough to know the morph announces itself.
A non-view .clj or .js edit: full reload, scroll restored
When the edit is not a view -- a handler, a route table, a .js module -- the server sends a non-morphable reload, and the client does a full page reload. It stashes scroll first and restores it after, exactly as the live-reload chapter's reload did:
// A non-view .clj or a .js edit: a module is a re-executing singleton, so a full
// reload is required. Stash scroll so the reload doesn't lose your place.
function hardReload() {
try { sessionStorage.setItem('myapp-dev-scroll', String(window.scrollY)); } catch (e) {}
window.location.reload();
}
// Restore scroll after a dev hard reload (stashed by hardReload before reloading).
try {
var savedScroll = sessionStorage.getItem('myapp-dev-scroll');
if (savedScroll !== null) {
sessionStorage.removeItem('myapp-dev-scroll');
window.addEventListener('load', function () { window.scrollTo(0, parseInt(savedScroll, 10) || 0); });
}
} catch (e) {}
This raises the obvious question: why does a .js edit force a full reload at all, when a view edit can morph? The answer is fundamental to ES modules, and it is worth being precise about, because it is the reason the matrix has this shape.
An ES module is a URL-cached singleton. The browser loads a module once per URL and caches the resulting module instance forever. Importing the same URL again returns the same already-evaluated instance -- the module body never re-runs. So when you edit
dispatcher.js, there is no in-place way to make the browser re-evaluate it: the old instance, with its already-registered event listeners and timers, is still live. The only honest ways to pick up the new code are (a)evalthe new source, (b) re-import the module under a different URL (a cache-bust query) and somehow tear down the old instance, or (c) reload the page.We reject (a) and (b).
evalrequires'unsafe-eval'in the Content-Security-Policy, and our CSP forbids it on purpose (the asset pipeline chapter) -- addingunsafe-evalfor a dev convenience would weaken the very policy the book is teaching. Re-importing under a fresh URL is CSP-legal but unsound: the old module's event listeners andsetIntervals keep running with nothing to dispose them, so you accumulate zombie handlers on every edit. A real module-replacement system (HMR with accept/dispose hooks) is bundler-grade machinery, wildly disproportionate for the thin, mostly stateless client JS here. So (c), a full reload, is the correct behavior, not a fallback -- and the scroll stash makes it nearly seamless.
The same singleton argument is why the dev scripts do not try anything clever for non-view .clj edits either: those can change server behavior in ways a <main> morph can't safely reflect (a changed route, a changed handler), so a full reload is the conservative choice.
A CSS rebuild: swap the <link>, no reload
When Tailwind rebuilds the stylesheet, the server sends css (after the debounce settles), and the client swaps the stylesheet's href with a cache-busting query. No reload, no flash -- the page just restyles:
// A .css rebuild (Tailwind --watch): swap the stylesheet href with a cache-bust so
// the browser refetches the rebuilt file -- no reload, no flash.
function swapStylesheet() {
var link = document.querySelector('link[rel="stylesheet"]');
if (!link) return;
var base = (link.getAttribute('href') || '').split('?')[0];
link.setAttribute('href', base + '?v=' + Date.now());
}
CSS is the easy case precisely because it has no execution model and no registry: a stylesheet is declarative, so re-fetching it has no side effects to clean up. (Compare the module problem above.) The dev URL is stable, so a ?v=<timestamp> query is enough to defeat the cache and pull the rebuilt bytes.
A reload that failed: a soft staleness banner
The fourth message, reload-error, is the response to a save that didn't take -- a syntax error on the server side. The browser was never refreshed, so the page you are looking at may not reflect your latest edit. The client raises a dismissible banner saying so:
function showStaleWarning(file, error) {
// ... builds a fixed-position banner: "<file> failed to reload --
// this page may be stale. Fix the error and save." ...
}
The wording is deliberately soft ("may be stale") because the server can't know whether the page actually depends on the broken file -- it might be an unrelated reload. The banner does not need to be cleared by hand: the next successful reload (or morph) clears it automatically, because a morph removes it explicitly and a reload navigates the page away from it.
Updating start
The matrix adds two steps to the hot-reload/start the live-reload chapter introduced: launch the long-lived Tailwind watcher, and preload the view namespaces through tools.reader so the inspector has source metadata from boot rather than only after the first edit.
(defn start
"Run this from the REPL to start developing."
[]
(core/start-dev-server)
(log/info "Development server started" {:url "http://localhost:3000"})
;; One long-lived Tailwind --watch writes static/styles.css (served unhashed in
;; dev); CSS is no longer rebuilt per .clj save.
(start-tailwind-watch!)
;; Re-load view namespaces through tools.reader so Hiccup carries element-level
;; source metadata from boot (the inspector overlay reads it). Dev-only.
(inspector-load/load-all-views!)
(start-file-watcher)
(log/info "Development environment ready"
{:websocket-reload true
:file-watcher true
:watch-path "/src"
:database "Datomic"}))
(start!) from the REPL still brings up the whole system in one call -- now with the Tailwind process and the view preload folded in.
The Complete Flow, Per Edit Type
To see how the pieces fit, here is the end-to-end story for each kind of edit:
You edit a view (*views.clj):
- The WatchService wakes on
ENTRY_MODIFYundersrc/. load-changed-filesees a.cljfile;inspector-load/reload-changed!recognizes it as a view, loads it viatools.reader, and returns truthy.after-refreshis called withmorphable? = true, sonotify-reload!sends{:type "reload" :morphable true}.- The browser's
morphReloadimportsdispatcher.jsand morphs the new<main>into place -- scroll, focus, and open<details>preserved. (If a class changed, Tailwind's watcher rebuilds CSS in parallel and acssmessage swaps the stylesheet a beat later.)
You edit a handler or other non-view .clj:
- Same wake-up;
reload-changed!returns nil, soload-fileruns. after-refreshis called withmorphable? = false;notify-reload!sends{:type "reload" :morphable false}.- The browser stashes scroll and does a full
window.location.reload(), then restores scroll on load.
You edit a served .js module under static/js/:
- The WatchService wakes on the
static/root. load-changed-filehits theasset-file?branch ->dev-reload/reload!-> a non-morphablereload.- Full reload with scroll restore -- required, because an ES module is a URL-cached singleton (see above).
Tailwind rebuilds static/styles.css:
- The long-lived Tailwind process rewrites the file (because a class appeared in your markup).
- The WatchService sees the write;
load-changed-filehits thestyles.cssbranch ->debounced-css-reload!. - ~150ms after the writes settle,
notify-css!sends{:type "css"}. - The browser swaps the stylesheet
<link>with a cache-bust -- no reload, no flash.
The whole cycle -- from saving a file to seeing the updated page -- typically completes in a fraction of a second, and for the common view-edit case it does so without losing any page state at all.
Design Decisions
Why decouple CSS from code reloads? Rebuilding Tailwind on every .clj save couples two unrelated concerns and puts a CSS rebuild on the critical path of every code edit -- even edits that touch no markup. A long-lived tailwindcss --watch=always rebuilds incrementally and only when classes actually change, and it owns the stylesheet end to end. The file watcher merely reacts to its output with a debounced, no-reload <link> swap. The result is faster code reloads and a CSS path that never flickers the page.
Why match the response to the edit? The central choice of this chapter is that a save is not one thing. A view edit, a handler edit, a JS edit, and a CSS rebuild have genuinely different correctness constraints -- a module can't be hot-swapped without eval or an HMR runtime; a stylesheet can be swapped trivially; a view can be morphed because the production layer already knows how. Picking the narrowest correct response for each (morph, reload, or <link> swap) is what keeps the loop both fast and sound. A one-size-fits-all window.location.reload() would be correct everywhere but state-destroying everywhere too.
Why reuse fetchAndMorph for the morph path? Idiomorph and fetchAndMorph already ship as the app's production interaction layer. Building a separate dev-only morph would mean a second, divergent code path to maintain and a second set of edge cases. Dev hot-reload is just one more caller of the production dispatcher, so the morph behaves in dev exactly as it does for real navigation.
Why requiring-resolve for dev/prod separation? The pattern (requiring-resolve 'dev-reload/websocket-handler) is a runtime classpath check that returns nil when the namespace is absent. It is structurally impossible to accidentally enable dev reload in production -- the code simply is not there. The base layout uses the same check to decide whether to emit the dev scripts at all, so none of this advanced machinery leaks to a production page.
What You Now Have
Building on the file watcher and WebSocket from the live-reload chapter, you now have a development loop where:
- Saving a view morphs
<main>in place, preserving scroll, focus, and open<details>. - Saving a non-view
.cljor a.jsmodule does a full reload with scroll restored -- the correct response, because an ES module is a URL-cached singleton that can't be hot-swapped withouteval(which our CSP forbids). - A Tailwind rebuild swaps the stylesheet
<link>with no reload at all, fired only after the rebuild settles. - A failed reload raises a soft staleness banner that clears itself on the next success.
- CSS is owned by one long-lived
tailwindcss --watch=alwaysprocess, decoupled from code reloads, with explicit lifecycle hardening (wait-for-exit on stop, a JVM shutdown hook, idempotent start). - The dev stylesheet is served at a stable URL with
no-store; production serves the byte-identical, content-hashed file with immutable caching. - The entire dev infrastructure remains structurally excluded from production by classpath separation.
The result is a feedback loop tight enough that you rarely need to leave your editor -- and for the most common edit, the page updates in place, exactly where you left it.
Passwordless Auth Part 1: HMAC-Signed Magic Link Tokens
Passwords are a liability. They get reused, leaked, phished, and forgotten. For a small SaaS, they also mean building password reset flows, hashing infrastructure, and breach notification procedures. That is a lot of surface area for something your users already hate.
Magic links sidestep all of this. The user enters their email, gets a link, clicks it, and they are in. No password to remember, no credential to steal, no database of hashed secrets waiting to be breached. The email inbox becomes the authenticator -- and your users already have that secured with their own MFA, biometrics, or whatever their provider offers.
This post covers the first half of the implementation: building the cryptographic token that powers those magic links. We will handle signing, verification, expiry, and user management -- all backed by tests. The next post will wire this into HTTP routes, email delivery, and sessions.
Why HMAC tokens instead of JWTs?
JWTs are the default answer for signed tokens, and for good reason -- they are standardized, well-understood, and interoperable. But interoperability is a feature you pay for in complexity, and in a system where you are both the producer and consumer of every token, that tax buys you nothing.
Here is what a JWT library brings: header parsing, algorithm negotiation, claim validation, multiple signature schemes, and a spec that has produced a steady stream of security vulnerabilities over the years (algorithm confusion attacks, "alg": "none", key confusion between HMAC and RSA). That is a lot of moving parts for what amounts to "sign some JSON and check the signature later."
Our needs are simpler:
- One signing algorithm (HMAC-SHA256)
- One producer and one consumer (our server)
- Two claims (email and expiration)
- No need for third-party verification
For this, a hand-rolled HMAC token is fewer than 40 lines of Clojure, uses only javax.crypto from the JDK (no dependencies), and has zero ambiguity about what algorithm is in use. The format is dead simple: base64(payload).base64(signature). You can read it, reason about it, and audit it in minutes.
That said -- if you ever need tokens that cross service boundaries, or that third parties need to verify, reach for a proper JWT library. The right tool depends on the problem.
The token format
Our tokens follow a two-part structure separated by a dot:
base64url({"email":"user@example.com","exp":1709654400000}).base64url(hmac-sha256-signature)
The left side is the payload -- a JSON object with the user's email and an expiration timestamp in milliseconds. The right side is the HMAC-SHA256 signature computed over the base64-encoded payload. Both sides use URL-safe base64 encoding (no +, /, or = characters), which matters because these tokens live in URLs.
To verify a token, you recompute the signature from the payload using your secret key and compare. If the signatures match, the payload has not been tampered with. Then you check the expiration. Two checks, no ambiguity.
Crypto primitives: HMAC-SHA256 and Base64
Let's start with the low-level building blocks. Java's javax.crypto package gives us everything we need.
(ns myapp.auth.core
(:require
[clojure.string :as str]
[datomic.api :as d]
[jsonista.core :as json]
[myapp.db.core :as db])
(:import
[java.time Instant]
[java.util Base64 UUID]
[javax.crypto Mac]
[javax.crypto.spec SecretKeySpec]))
(set! *warn-on-reflection* true)
A quick note on *warn-on-reflection*: this tells the Clojure compiler to warn when it cannot resolve a Java method at compile time and would fall back to reflection. Reflection is slow and, in crypto code that may run on every request, worth avoiding. The type hints you will see (like ^bytes and ^String) are how we help the compiler.
Now the HMAC function:
(defn hmac-sha256
"Compute HMAC-SHA256 of data using secret key."
[^bytes secret-key ^String data]
(let [mac (Mac/getInstance "HmacSHA256")
secret-key-spec (SecretKeySpec. secret-key "HmacSHA256")]
(.init mac secret-key-spec)
(.doFinal mac (.getBytes data "UTF-8"))))
Three lines of Java interop, no external dependencies. Mac/getInstance gives us an HMAC engine. SecretKeySpec wraps our raw key bytes into something the JCA (Java Cryptography Architecture) understands. We initialize the engine with our key, then feed it the data. Out comes a byte array -- the signature.
The ^bytes and ^String type hints are not just documentation. Without them, Clojure would use reflection to find the right .init and .getBytes overloads at runtime, which is both slower and noisier in the logs.
Next, base64 encoding and decoding:
(defn base64-encode
"Base64 URL-safe encode."
[^bytes data]
(-> (Base64/getUrlEncoder)
(.withoutPadding)
(.encodeToString data)))
(defn base64-decode
"Base64 URL-safe decode."
[^String data]
(-> (Base64/getUrlDecoder)
(.decode data)))
We use getUrlEncoder / getUrlDecoder rather than the standard variants. Standard base64 uses + and /, which have special meaning in URLs. The URL-safe variant substitutes - and _. We also strip padding (= characters) since the decoder handles unpadded input just fine, and padding adds noise to URLs.
Signing tokens
With the primitives in place, signing is straightforward:
(defn sign-token
"Create a signed token carrying email, expiration, and a nonce.
Format: base64(payload).base64(signature)
Payload: {\"email\": \"...\", \"exp\": <ms>, \"nonce\": \"<uuid>\"}"
[signing-key email ^Instant expires-at nonce]
(let [payload-map {:email email
:exp (.toEpochMilli expires-at)
:nonce (str nonce)}
payload-json (json/write-value-as-string payload-map)
payload-b64 (base64-encode (.getBytes payload-json "UTF-8"))
signature (hmac-sha256 signing-key payload-b64)
signature-b64 (base64-encode signature)]
(str payload-b64 "." signature-b64)))
The flow:
- Build a map with the email, the expiration as epoch milliseconds, and a one-time nonce (a random UUID). We will return to why the nonce is here in a moment -- it is what turns a forgeable-but-replayable token into a single-use credential.
- Serialize it to JSON.
- Base64-encode the JSON -- this becomes the left side of the token.
- Compute the HMAC-SHA256 of the base64-encoded payload using our signing key.
- Base64-encode the signature -- this becomes the right side.
- Join them with a dot.
An important detail: we sign the base64-encoded payload, not the raw JSON. This means verification only needs to split on the dot and compare signatures -- no base64 decoding needed until after the signature check passes. Fail fast on the cheap operation.
Verifying tokens
Verification is the mirror image of signing, with two additional checks: signature validity and expiration.
(defn verify-token
"Verify signed token and extract claims.
Returns {:email \"...\" :nonce \"<uuid-string>\"} if valid,
nil if invalid or expired."
[signing-key token]
(try
(let [[payload-b64 signature-b64] (str/split token #"\." 2)]
(when (and payload-b64 signature-b64)
;; Verify signature
(let [expected-signature (hmac-sha256 signing-key payload-b64)
expected-signature-b64 (base64-encode expected-signature)]
(when (= signature-b64 expected-signature-b64)
;; Signature valid, check expiration
(let [payload-json (String. ^bytes (base64-decode payload-b64) "UTF-8")
payload (json/read-value payload-json
(json/object-mapper {:decode-key-fn true}))
exp-time (long (:exp payload))
now (.toEpochMilli (Instant/now))]
(when (> exp-time now)
;; Not expired -- hand the caller the email and the nonce.
;; verify-token proves the token is authentic and fresh; the
;; nonce lets the caller enforce single use (next chapter).
{:email (:email payload)
:nonce (:nonce payload)}))))))
(catch Exception _e nil)))
Let's walk through the verification logic:
- Split the token on the first dot. We pass
2as the limit tostr/splitso that any dots in the signature do not cause extra parts. - Check both parts exist. If the token is malformed (no dot, empty parts), bail early with
nil. - Recompute the signature from the payload using our signing key and compare it to the provided signature. If they do not match, someone tampered with either the payload or the signature -- return
nil. - Decode the payload and parse the JSON. Only now do we touch the payload contents, after we have confirmed they are authentic.
- Check expiration. If the current time is past the expiry, the token is dead -- return
nil. - Return the email and the nonce if everything checks out. The signature and expiry are settled here; the nonce travels back to the caller, who is responsible for the one-shot replay check (see the next chapter).
The entire function is wrapped in a try/catch that returns nil on any exception. Malformed base64, invalid JSON, missing keys -- all produce nil. This is a deliberate design choice: from the caller's perspective, a token is either valid (returns a map) or it is not (returns nil). There is no need to distinguish between "expired" and "tampered" and "garbage" -- they all mean "do not authenticate this request."
A note on timing attacks
The sharp-eyed reader might notice we are comparing signatures with =, which is not constant-time. In theory, this leaks information about how many bytes of the signature match. In practice, for magic link tokens with a 15-minute expiry that are used exactly once, this is not a realistic attack vector. The attacker would need to send millions of carefully timed requests to extract one signature, and the token expires before they can finish. If you are building something where timing attacks matter (long-lived API keys, for instance), use MessageDigest/isEqual instead.
Token expiry
Tokens get a 15-minute window. This is a convenience function that wraps sign-token with the expiry logic:
(defn create-magic-link-token
"Create a signed magic-link token plus the nonce embedded in it.
Returns {:token <signed-string> :nonce <UUID>}. The caller records the
nonce server-side before sending the link; verification consumes it once."
[signing-key email]
(let [expires-at (-> (Instant/now)
(.plusSeconds (* 15 60)))
nonce (UUID/randomUUID)]
{:token (sign-token signing-key email expires-at nonce)
:nonce nonce}))
Why 15 minutes? Long enough that the email can survive a slow mail server or a user who gets distracted. Short enough that a token sitting in someone's inbox is not a long-lived credential. This is a judgment call, not a science -- adjust based on your users' behavior.
Notice that the expiry is baked into the signed payload itself. There is no database lookup needed to check if a token has expired. The token carries its own expiration, and the signature guarantees it has not been modified. This is one of the key advantages of signed tokens: verification is a pure function. No database, no cache, no network call. Just bytes and math.
Why a nonce? The replay problem
A signed token has one weakness an attacker can still exploit: it is valid until it expires, and "valid" means "valid every time." If a magic link leaks -- a forwarded email, a proxy log, a shared inbox -- anyone holding it can log in, again and again, for the full 15-minute window. Expiry shortens that window; it does not close it.
We considered three ways to make a link single-use:
- Store the whole token server-side and delete it on use. Works, but it throws away the main advantage of signed tokens -- that they are self-contained. Now every issuance is a database write of an opaque blob, and verification is a lookup.
- Track "consumed" tokens by their signature. Smaller than storing the token, but the signature is long and the semantics are awkward -- we would be indexing on a base64 HMAC.
- Embed a small random nonce and track that. The token stays self-contained and pure-function-verifiable; we only persist a tiny UUID per issuance and flip it from "unused" to "used" exactly once.
We chose the nonce. create-magic-link-token therefore returns two things: the token to email, and the nonce to record. The signing side is now complete -- the next chapter records the nonce at send time and atomically consumes it at verify time, which is where "single use" actually gets enforced.
User management in Datomic
With tokens handled, we need somewhere to put the users. Datomic makes this pleasantly simple.
Finding a user
(defn find-user-by-email
"Find user by email address."
[db email]
(d/q '[:find ?e . :in $ ?email :where [?e :user/email ?email]] db email))
This is a Datalog query. The . after ?e in the find clause is a scalar binding -- it returns the entity ID directly instead of wrapping it in a set of tuples. If no user exists with that email, it returns nil.
The query takes db (an immutable database value) rather than a connection. This is idiomatic Datomic: functions that read take a database value, functions that write take a connection. Database values are immutable snapshots, so queries are inherently thread-safe and reproducible.
Creating a user
(defn create-user!
"Create a new user account."
[conn email]
(let [user-id (UUID/randomUUID)
now (Instant/now)]
@(db/transact* conn
[{:db/id "temp-user"
:user/id user-id
:user/email email
:user/created-at now
:user/active? true}])
user-id))
A few things to note:
transact*is a thin wrapper aroundd/transactthat convertsjava.time.Instantvalues tojava.util.Date, which is what Datomic stores internally. It lets us use the modern Java time API everywhere else."temp-user"is a temporary ID. Datomic assigns the real entity ID during the transaction. We do not need to track it -- we identify users by their:user/emailor:user/idgoing forward.@dereferences the future returned byd/transact, making the call synchronous. The transaction either succeeds or throws.:user/emailis defined as:db.unique/identityin our schema, which means Datomic enforces uniqueness and will upsert (update rather than duplicate) if we transact a new entity with an email that already exists.
Get or create
The login flow needs a function that finds an existing user or creates one. With Datomic's upsert behavior on unique identity attributes, this is clean:
(defn get-or-create-user!
"Get existing user or create new one, return email."
[conn email]
(let [db (d/db conn)]
(when-not (find-user-by-email db email) (create-user! conn email))
email))
If the user exists, we skip the transaction entirely. If they do not, we create them. Either way, we return the email. This function is what the magic link handler will call when a user clicks a valid token -- ensuring they have an account before we create their session.
Testing
Tests are where the design proves itself. Let's walk through the test suite.
Test setup
Tests run against a fresh in-memory Datomic database per test, with a deterministic signing key:
(ns myapp.auth.core-test
(:require
[clojure.string :as str]
[clojure.test :refer [deftest is testing use-fixtures]]
[datomic.api :as d]
[myapp.auth.core :as auth]
[myapp.test-helpers :as h])
(:import
[java.time Instant]
[java.util UUID]))
(use-fixtures :each h/with-test-db)
The with-test-db fixture creates a disposable in-memory Datomic database with the full schema loaded, binds it to a dynamic var, and tears it down after each test. No shared state between tests, no cleanup headaches.
The test signing key is a fixed 32-byte string:
(def test-signing-key
(.getBytes "test-signing-key-32-bytes-long!!" "UTF-8"))
Deterministic keys in tests mean deterministic results. No randomness, no flakiness.
Base64 correctness
First, we verify our base64 implementation round-trips correctly and produces URL-safe output:
(deftest base64-roundtrip
(let [data (.getBytes "hello world" "UTF-8")
encoded (auth/base64-encode data)
decoded (auth/base64-decode encoded)]
(is (= (seq data) (seq decoded)))))
(deftest base64-url-safe
(testing "No +, /, or = in output"
(let [encoded (auth/base64-encode
(byte-array (map unchecked-byte [255 239 191 253 251 247])))]
(is (not (str/includes? encoded "+")))
(is (not (str/includes? encoded "/")))
(is (not (str/includes? encoded "="))))))
The second test is particularly important. The byte sequence [255 239 191 253 251 247] is specifically chosen to produce +, /, and = in standard base64. If URL-safe encoding is working, none of those characters should appear.
Token format and round-trip
(deftest sign-token-format
(let [token (auth/sign-token h/test-signing-key "test@example.com"
(.plusSeconds (Instant/now) 3600) (UUID/randomUUID))]
(is (string? token))
(let [parts (str/split token #"\.")]
(is (= 2 (count parts)) "Token should have exactly one dot separator")
(is (every? #(pos? (count %)) parts) "Both parts should be non-empty"))))
(deftest verify-token-roundtrip
(let [nonce (UUID/randomUUID)
token (auth/sign-token h/test-signing-key "user@example.com"
(.plusSeconds (Instant/now) 3600) nonce)
result (auth/verify-token h/test-signing-key token)]
(is (= {:email "user@example.com" :nonce (str nonce)} result))))
The format test confirms structural correctness: it is a string, it has exactly two parts separated by a dot, and neither part is empty. The round-trip test confirms semantic correctness: sign a token, verify it, get back the original email and the nonce -- the caller needs the nonce to enforce single use.
Failure modes
These are the tests that matter most. A signing system is defined as much by what it rejects as by what it accepts.
Expired tokens:
(deftest verify-token-expired
(let [token (auth/sign-token h/test-signing-key "user@example.com"
(.minusSeconds (Instant/now) 1) (UUID/randomUUID))]
(is (nil? (auth/verify-token h/test-signing-key token)))))
Create a token that expired one second ago. Verification returns nil. Simple.
Tampered payload:
(deftest verify-token-tampered-payload
(let [token (auth/sign-token h/test-signing-key "user@example.com"
(.plusSeconds (Instant/now) 3600) (UUID/randomUUID))
[_payload sig] (str/split token #"\.")
tampered (str
(auth/base64-encode
(.getBytes "{\"email\":\"evil@example.com\",\"exp\":9999999999999}"
"UTF-8"))
"." sig)]
(is (nil? (auth/verify-token h/test-signing-key tampered)))))
This simulates an attacker who intercepts a valid token and replaces the payload with a different email while keeping the original signature. The signature will not match the new payload, so verification fails. This is the core security property of HMAC signatures.
Tampered signature:
(deftest verify-token-tampered-signature
(let [token (auth/sign-token h/test-signing-key "user@example.com"
(.plusSeconds (Instant/now) 3600) (UUID/randomUUID))
[payload _sig] (str/split token #"\.")]
(is (nil? (auth/verify-token h/test-signing-key (str payload ".AAAA"))))))
Valid payload, garbage signature. Rejected.
Wrong signing key:
(deftest verify-token-wrong-key
(let [other-key (.getBytes "other-signing-key-32-bytes-long!" "UTF-8")
token (auth/sign-token h/test-signing-key "user@example.com"
(.plusSeconds (Instant/now) 3600) (UUID/randomUUID))]
(is (nil? (auth/verify-token other-key token)))))
A token signed with one key cannot be verified with a different key. This matters for key rotation: if you change your signing key, all outstanding tokens become invalid immediately.
Garbage input:
(deftest verify-token-garbage-input
(is (nil? (auth/verify-token h/test-signing-key nil)))
(is (nil? (auth/verify-token h/test-signing-key "")))
(is (nil? (auth/verify-token h/test-signing-key "not-a-token")))
(is (nil? (auth/verify-token h/test-signing-key "abc.def.ghi"))))
nil, empty string, no dot separator, too many dots -- all return nil. The try/catch in verify-token earns its keep here. No matter what garbage comes in, the function returns a clean nil rather than throwing.
User management tests
(deftest find-user-not-found
(let [db (d/db h/*conn*)]
(is (nil? (auth/find-user-by-email db "nobody@example.com")))))
(deftest create-user-and-find
(let [user-id (auth/create-user! h/*conn* "new@example.com")
db (d/db h/*conn*)]
(is (instance? UUID user-id))
(is (some? (auth/find-user-by-email db "new@example.com")))))
Create a user, find them by email. create-user! returns a UUID, and find-user-by-email finds the entity.
Duplicate handling is worth calling out:
(deftest create-user-duplicate-upserts
(auth/create-user! h/*conn* "dup@example.com")
(auth/create-user! h/*conn* "dup@example.com")
(let [db (d/db h/*conn*)
n (d/q '[:find (count ?e) . :in $ ?email :where [?e :user/email ?email]]
db "dup@example.com")]
(is (= 1 n) "Should have exactly one entity for the email")))
Because :user/email has :db.unique/identity, Datomic performs an upsert rather than throwing a uniqueness constraint violation. Creating the same user twice results in one entity, not two, and no error. This is a property of Datomic's identity model -- entities are identified by their unique attributes, and transacting the same identity twice merges rather than duplicates.
Idempotent get-or-create:
(deftest get-or-create-user-creates-new
(let [email (auth/get-or-create-user! h/*conn* "fresh@example.com")
db (d/db h/*conn*)]
(is (= "fresh@example.com" email))
(is (some? (auth/find-user-by-email db "fresh@example.com")))))
(deftest get-or-create-user-idempotent
(auth/create-user! h/*conn* "existing@example.com")
(let [email (auth/get-or-create-user! h/*conn* "existing@example.com")]
(is (= "existing@example.com" email))))
Call it with a new email, get a new user. Call it with an existing email, get the same one back. No errors either way.
What we have now
At the end of this post, we have a complete token-based authentication primitive:
sign-token-- creates a signed, time-limited token containing an email address and a one-time nonceverify-token-- validates the signature, checks expiry, and returns the email and nonce, returningnilfor any failurecreate-magic-link-token-- wrapssign-tokenwith a 15-minute expiry and returns both the token (to email) and the nonce (to record)find-user-by-emailandcreate-user!-- user CRUD in Datomicget-or-create-user!-- idempotent user lookup/creation for the login flow- A comprehensive test suite covering round-trips, expiry, tampering, wrong keys, garbage input, and user management
The entire implementation is about 60 lines of Clojure with zero external dependencies beyond jsonista (for JSON) and Datomic. The crypto comes from the JDK. The token format is simple enough to explain in one sentence and audit in five minutes.
What we do not have yet: HTTP routes, email sending, sessions, the server-side recording and one-shot consumption of the nonce, and the actual magic link flow that ties it all together. That is Part 2.
All code in this series is from a real production SaaS. If you are building something similar, the patterns here should translate directly -- just swap in your own namespace and database.
Passwordless Auth Part 2: Magic Link Emails and the Full Login Flow
In the previous chapter, we built the cryptographic foundation for passwordless authentication: HMAC-SHA256 signed tokens with expiration. But a token sitting in memory is useless until it reaches the user's inbox and completes the round trip back to our server. This post wires up the complete flow: sending magic link emails, handling the callback, creating sessions, and gating access behind terms acceptance.
By the end, you will have a fully working passwordless login system with no passwords stored anywhere.
The Full Login Flow
Before diving into code, here is the sequence of events:
- User enters their email and submits the login form
- Server generates a signed token with a nonce, records the nonce, and sends an email containing the magic link
- User clicks the link in their email
- Server verifies the token, consumes the nonce (one-shot), creates the user if needed, and sets a session cookie
- Server checks whether the user has accepted terms of service
- If not, redirect to terms acceptance; otherwise, show the dashboard
Every step uses the Post-Redirect-Get pattern where appropriate, and the session is an encrypted cookie -- no server-side session store needed.
Sending Email with Jakarta Mail (Eclipse Angus)
There are Clojure email libraries out there, but they are all thin wrappers around Jakarta Mail anyway. Using Jakarta Mail directly means one fewer dependency to track, and the API is straightforward enough that a wrapper does not add much value. Eclipse Angus is the reference implementation of Jakarta Mail since it moved out of the javax namespace.
The dependency in deps.edn:
org.eclipse.angus/angus-mail {:mvn/version "2.0.5"}
Here is the email namespace:
(ns myapp.auth.email
"SMTP email delivery for magic link authentication.
Uses Jakarta Mail directly (via Eclipse Angus) -- no wrapper library."
(:require
[clojure.tools.logging :as log]
[myapp.config :as config]
[myapp.i18n :refer [t]])
(:import
[jakarta.mail Message$RecipientType Session Transport]
[jakarta.mail.internet InternetAddress MimeMessage]
[java.util Properties]))
(set! *warn-on-reflection* true)
The imports tell the story. We need Session to configure the SMTP connection, MimeMessage to build the email, and Transport to send it. Nothing else.
Configuring the SMTP Session
(defn- smtp-session
"Create a Jakarta Mail session from SMTP config."
^Session []
(let [{:keys [host port tls user pass]} (config/get-config :smtp)
props (doto (Properties.)
(.put "mail.smtp.host" (str host))
(.put "mail.smtp.port" (str port))
(.put "mail.smtp.starttls.enable" (str (boolean tls))))]
(if user
(do
(.put props "mail.smtp.auth" "true")
(Session/getInstance
props
(proxy [jakarta.mail.Authenticator] []
(getPasswordAuthentication []
(jakarta.mail.PasswordAuthentication. user pass)))))
(Session/getInstance props))))
Two things worth noting. First, we branch on whether user is present. In development, we connect to Mailpit without authentication. In production, we use authenticated SMTP with STARTTLS. The same code handles both -- the config drives the behavior.
Second, the ^Session type hint on the return value. With *warn-on-reflection* set to true, the Clojure compiler will tell us if we miss a hint that causes reflective method lookup. In a namespace full of Java interop, this matters for both performance and correctness.
The send-magic-link! Function
(defn send-magic-link!
"Send magic link email to user via SMTP."
[locale email token base-url]
(let [magic-link (str base-url "/auth/verify?token=" token)
from (config/get-config :smtp :from)
session (smtp-session)
msg (doto (MimeMessage. session)
(.setFrom (InternetAddress. from))
(.setRecipient Message$RecipientType/TO (InternetAddress. email))
(.setSubject (t locale :email/magic-link-subject))
(.setText (format (t locale :email/magic-link-body) magic-link)))]
(try
(Transport/send msg)
(log/info "Magic link email sent" {:to email})
{:error :SUCCESS}
(catch Exception e
(log/error e "Failed to send magic link email" {:to email})
{:error :FAIL
:message (.getMessage e)}))))
The function takes four arguments: the locale (for i18n), the recipient email, the signed token, and the base URL. It constructs the full magic link URL, builds a MimeMessage, and sends it.
A few design choices:
Plain text email. No HTML templates, no inline CSS wrestling. A magic link email should contain exactly one thing: the link. Plain text is universally readable, does not get clipped by email clients, and is trivial to test.
i18n from the start. The subject and body come from translation maps via the t function. The body template uses %s for the magic link URL, filled in with format. This means Dutch users get Dutch emails and English users get English ones. Adding this later would mean touching every email template. Adding it now costs nothing.
Return value, not exception. The function returns {:error :SUCCESS} or {:error :FAIL :message "..."}. The caller can decide what to do. In our case, the handler always shows the "check your email" page regardless -- we do not want to leak information about whether an email address is registered.
The Handler Layer
With token creation (from the previous post) and email sending in place, the handlers orchestrate the full flow.
Requesting a Magic Link (POST /auth/request)
(defn request-magic-link
"Handle a magic-link request -- send the email + record the nonce,
then redirect (PRG pattern)."
[request]
(let [email (get-in request [:params :email])
locale (:locale request)
signing-key (config/get-config :signing-key)
base-url (config/get-config :base-url)
{:keys [token nonce]} (auth/create-magic-link-token signing-key email)]
(email/send-magic-link! locale email token base-url)
(analytics/record!
[{:magic-link/email email
:magic-link/nonce nonce
:magic-link/requested-at (time/now)}])
(response/redirect
(str "/auth/sent?email="
(java.net.URLEncoder/encode ^String email "UTF-8")))))
This handler does four things: create a token (and its nonce), send the email, record the nonce, and redirect. The redirect is the Post-Redirect-Get pattern; the nonce record is what makes the link single-use.
Recall from Part 1 that create-magic-link-token returns {:token ... :nonce ...}. The token goes in the email; the nonce we write to a small server-side store keyed by :magic-link/nonce, alongside the email and request time. (This store is the same lightweight event log the admin dashboard reads for analytics -- its schema is defined there; here we only need the nonce field.) When the user clicks the link, verification will look this record up and atomically flip it to "consumed." A second click finds it already consumed and is rejected.
Why Post-Redirect-Get Matters
Without PRG, refreshing the "check your email" page would resubmit the form and send another email. The browser would show a "resubmit form data?" dialog. With PRG:
- POST
/auth/request-- sends the email, returns a 302 redirect - Browser follows redirect to GET
/auth/sent?email=user@example.com - Refreshing this page is a harmless GET -- no duplicate emails
(The dispatcher from the views chapter enhances this form submission into an in-place fetch when JavaScript is available, but the server is oblivious to that -- it always renders the same full page and redirects. No X-Enhanced header, no content negotiation, no separate code path.)
The Confirmation Page (GET /auth/sent)
(defn magic-link-sent
"Show confirmation page (GET after redirect)."
[request]
(let [email (get-in request [:params :email])]
{:status 200
:headers {"Content-Type" "text/html"}
:body (str (views/magic-link-sent (:locale request) email))}))
Simple: read the email from the query string, render a page telling the user to check their inbox.
Verifying the Token (GET /auth/verify)
When the user clicks the magic link, three things must all hold before we sign them in: the token must be authentic and unexpired, its nonce must not have been used before, and a user account must exist (creating one on first sign-in). Let us build the one-shot check first.
Consuming the nonce exactly once
verify-token proves the token is genuine, but a genuine token can be presented twice. The nonce closes that gap -- but only if "mark this nonce as used" is atomic. If we read "unused," then separately wrote "used," two near-simultaneous clicks could both read "unused" and both succeed. We need a compare-and-swap.
Datomic gives us exactly this with the built-in :db.fn/cas transaction function:
(defn- consume-magic-link-nonce!
"CAS-stamp the magic-link record for `nonce` as verified.
Returns true if this call was the first to consume the nonce; false on
replay, malformed nonce, or unknown nonce. The CAS expects
:magic-link/verified-at to be currently unset; a replay finds it already
set, the CAS fails, the transaction throws, and we return false."
[^UUID nonce]
(try
(let [conn (analytics/get-connection)
eid (d/q '[:find ?e . :in $ ?n :where [?e :magic-link/nonce ?n]]
(d/db conn) nonce)]
(when eid
@(d/transact conn [[:db.fn/cas eid :magic-link/verified-at nil (time/now-date)]])
true))
(catch Exception _e
false)))
:db.fn/cas asserts the new value only if the current value matches the expected one -- here, only if :magic-link/verified-at is still nil. The first click matches nil, stamps the time, and commits. A replay finds the field already stamped, the CAS fails inside Datomic's transactor, the transaction throws, and we fall through to false. The atomicity is the database's job, not ours -- there is no read-then-write window to lose a race in.
The handler
(defn verify-magic-link
"Verify the magic-link token, then create the user-and-session.
Three gates must pass: (1) HMAC + expiration via verify-token;
(2) one-shot consumption of the token's nonce; (3) user-entity
creation if needed. Any gate failing produces the same generic
error, never revealing which gate failed."
[request]
(let [token (get-in request [:params :token])
signing-key (config/get-config :signing-key)
locale (:locale request)
token-data (auth/verify-token signing-key token)
nonce-str (:nonce token-data)
nonce-uuid (when nonce-str
(try (UUID/fromString nonce-str)
(catch IllegalArgumentException _ nil)))]
(if (and token-data nonce-uuid (consume-magic-link-nonce! nonce-uuid))
(let [email (:email token-data)
conn (db/get-connection)]
(auth/get-or-create-user! conn email)
(-> (response/redirect "/dashboard")
(assoc :session {:user-email email})))
{:status 400
:headers {"Content-Type" "text/html"}
:body (str (views/error-page locale
(t locale :error/invalid-magic-link)))})))
The three gates run in order, short-circuiting on the first failure:
verify-tokenchecks the HMAC signature and expiry, returning the email and nonce (ornil).consume-magic-link-nonce!atomically claims the nonce. A replayed link getsfalsehere even though its signature is still valid.get-or-create-user!creates the account on first sign-in and is a no-op thereafter -- so the user record is created here, at verification, the moment we know the person controls the email.
Crucially, every failure path produces the same generic error page. We never tell the caller whether the token was forged, expired, or already used -- that would hand an attacker a probe.
The session is the moment where "stateless token" becomes "stateful session." The token was a one-time bridge to prove the user controls that email address; the nonce guaranteed it was crossed only once. The session persists across requests.
Session Management
Sessions are configured in the middleware stack using Ring's cookie store:
(def ^:private app*
(delay
(ring/ring-handler
(ring/router routes)
(ring/create-default-handler)
{:middleware [[params/wrap-params]
[keyword-params/wrap-keyword-params]
[session/wrap-session
{:store (cookie/cookie-store
{:key (config/get-config :session-key)})
:cookie-name "session"
:cookie-attrs {:http-only true
:secure true
:same-site :lax
:max-age (* 30 24 60 60)}}]
[wrap-locale]
[wrap-no-cache-authenticated]]})))
The session cookie is:
- Encrypted with a 16-byte key (AES via Ring's cookie store)
- http-only so JavaScript cannot read it
- secure so it only travels over HTTPS
- same-site :lax to prevent CSRF while still allowing the magic link GET request to work (the link opens in a new tab, which is a top-level navigation --
:laxpermits this) - 30 days expiry
No server-side session store. The session data is encrypted inside the cookie itself. For our use case -- storing just an email address -- this is ideal. No session table to query, no cleanup jobs, no scaling concerns.
Cache Control for Authenticated Pages
One subtle middleware worth highlighting:
(defn wrap-no-cache-authenticated
"Sets Cache-Control: no-store on authenticated HTML responses.
Prevents browser bfcache from showing stale pages after logout."
[handler]
(fn [request]
(let [response (handler request)]
(if (get-in request [:session :user-email])
(assoc-in response [:headers "Cache-Control"] "no-store")
response))))
Without this, a user who logs out and hits the back button might see their dashboard from the browser's back-forward cache. no-store prevents this. It only applies to authenticated responses, so public pages still benefit from caching.
The Terms Acceptance Gate
Authentication proves who you are. But before a new user can use the application, they need to accept the terms of service. The dashboard handler enforces this:
(defn dashboard
"Dashboard handler (requires authentication + terms acceptance)."
[request]
(if-let [user-email (get-in request [:session :user-email])]
(let [db (d/db (db/get-connection))
user-eid (auth/find-user-by-email db user-email)
user (when user-eid (db/pull* db [:user/terms-accepted-at] user-eid))]
(if (:user/terms-accepted-at user)
{:status 200
:headers {"Content-Type" "text/html"}
:body (str (views/dashboard (:locale request) user-email {}))}
;; Authenticated but terms not accepted -- show welcome page
(response/redirect "/terms/welcome")))
;; Not authenticated
(response/redirect "/")))
Three possible outcomes:
- Not authenticated -- redirect to home page
- Authenticated, terms not accepted -- redirect to
/terms/welcome - Authenticated, terms accepted -- render the dashboard
The user account already exists by this point -- verify-magic-link created it the moment the link was confirmed. So accepting terms is a single stamp, not a creation:
(defn accept-terms
"Stamp :user/terms-accepted-at on the authenticated user, then continue."
[request]
(if-let [user-email (get-in request [:session :user-email])]
(let [conn (db/get-connection)
user-eid (auth/find-user-by-email (d/db conn) user-email)]
@(db/transact* conn
[{:db/id user-eid
:user/terms-accepted-at (time/now)}])
(response/redirect "/dashboard"))
(response/redirect "/")))
A natural question: why create the user at verification rather than waiting until they accept terms? It keeps each handler doing one job. verify-magic-link answers "does this person control this email?" -- and the honest record of that fact is a user row. accept-terms answers a separate question, "have they agreed to the terms?", and records that with a single timestamp. The terms gate then lives entirely in the read path (the dashboard handler above redirects until :user/terms-accepted-at is set), so an account that exists but has not accepted terms is a perfectly valid, well-defined state -- not a half-written record. Folding user creation into accept-terms would mean two code paths (create-with-terms vs stamp-existing) and a user-creation step hidden inside a handler named for something else.
Logout
Logout is refreshingly simple:
(defn logout
"Logout handler."
[_request]
(-> (response/redirect "/")
(assoc :session nil)))
Setting :session to nil tells Ring's session middleware to clear the cookie. The redirect sends the user back to the home page. That is the entire logout implementation.
Routes
All auth-related routes in one place:
["/auth/request" {:post handler/request-magic-link}]
["/auth/sent" {:get handler/magic-link-sent}]
["/auth/verify" {:get handler/verify-magic-link}]
["/auth/logout" {:post handler/logout}]
["/terms/welcome" {:get handler/terms-welcome}]
["/terms/accept" {:post handler/accept-terms}]
State-changing operations (request link, logout, accept terms) are POST. Idempotent reads (sent page, verify link, welcome page) are GET. The verify endpoint is GET because the user clicks a link in an email -- email clients do not POST.
Testing with GreenMail
You cannot test email delivery by checking logs and hoping. GreenMail is an embedded SMTP server written in Java that captures emails in-process. No external mail server needed, no network flakiness, tests run in milliseconds.
The dependency goes in the :test alias:
:test {:extra-paths ["test"]
:extra-deps {com.icegreen/greenmail {:mvn/version "2.1.8"}}
...}
The GreenMail Fixture
(ns myapp.auth.email-test
(:require
[clojure.string :as str]
[clojure.test :refer [deftest is use-fixtures]]
[myapp.auth.email :as email]
[myapp.config :as config]
[myapp.test-helpers :as h])
(:import
[com.icegreen.greenmail.util GreenMail ServerSetup]
[jakarta.mail.internet MimeMessage]))
(set! *warn-on-reflection* true)
(def ^:dynamic *greenmail*
"Bound to a running GreenMail instance per test."
nil)
(defn with-greenmail
"Fixture: starts GreenMail SMTP on a dynamic port, stubs config."
[f]
(let [setup (ServerSetup. 0 "127.0.0.1" "smtp")
gm (GreenMail.
^"[Lcom.icegreen.greenmail.util.ServerSetup;"
(into-array ServerSetup [setup]))]
(.start gm)
(try
(let [port (.getPort (.getSmtp gm))
test-cfg (assoc h/test-config
:smtp {:host "127.0.0.1"
:port port
:tls false
:user nil
:pass nil
:from "noreply@myapp.test"})]
(with-redefs [config/config (delay test-cfg)]
(binding [*greenmail* gm]
(f))))
(finally (.stop gm)))))
(use-fixtures :each with-greenmail)
Key details:
- Port 0 tells GreenMail to pick a random available port. No port conflicts, tests can run in parallel.
with-redefsswaps the app config to point SMTP at the GreenMail instance. The production code does not know it is talking to a test server.bindingmakes the GreenMail instance available to test assertions via the*greenmail*dynamic var.finallyensures the server stops even if a test fails.
The ^"[Lcom.icegreen.greenmail.util.ServerSetup;" type hint is the JVM's notation for an array of ServerSetup objects. It looks ugly, but it eliminates a reflection warning.
Testing Email Delivery
(deftest send-magic-link-delivers-email
(let [result (email/send-magic-link!
:nl "user@example.com" "test-token-abc"
"https://myapp.test")]
(is (= :SUCCESS (:error result)))
(let [messages (.getReceivedMessages ^GreenMail *greenmail*)]
(is (= 1 (alength messages)))
(let [^MimeMessage msg (aget messages 0)]
(is (= "Inloggen bij myapp" (.getSubject msg)))
(is (= "noreply@myapp.test" (str (first (.getFrom msg)))))
(let [body (str (.getContent msg))]
(is (str/includes? body
"https://myapp.test/auth/verify?token=test-token-abc"))
(is (str/includes? body "15 minuten")))))))
This test verifies the complete email: return value, recipient count, subject line, sender address, magic link URL in the body, and the expiration notice. We pass locale :nl so we can assert on the Dutch subject line and body text.
(deftest send-magic-link-recipient-is-correct
(email/send-magic-link!
:nl "other@example.com" "token-123" "https://myapp.test")
(let [messages (.getReceivedMessages ^GreenMail *greenmail*)
^MimeMessage msg (aget messages 0)
recipients (.getAllRecipients msg)]
(is (= "other@example.com" (str (first recipients))))))
A separate test for the recipient address. Seems redundant, but sending an email to the wrong person is the kind of bug you want caught by its own focused test.
Development with Mailpit
Tests use GreenMail. But during development, you want to see the actual emails in a UI. Mailpit is a lightweight SMTP server with a web interface -- think of it as a local email inbox for development.
The SMTP config in config.edn uses Aero profiles to switch between environments:
:smtp {:host #profile {:dev "mailpit"
:prod #env "SMTP_HOST"}
:port #profile {:dev 1025
:prod 587}
:tls #profile {:dev false
:prod true}
:user #profile {:dev nil
:prod #env "SMTP_USER"}
:pass #profile {:dev nil
:prod #env "SMTP_PASS"}
:from #profile {:dev "no-reply@myapp.lan"
:prod #env "SMTP_FROM"}}
In development:
- SMTP goes to
mailpiton port 1025 (the Mailpit SMTP port) - No TLS, no authentication
- Mailpit's web UI (typically on port 8025) shows every email the app sends
In production:
- SMTP goes to a real mail provider with STARTTLS
- Credentials come from environment variables
The production email code is identical to the development email code. Only the config changes. This is the advantage of using a real SMTP server for development instead of mocking -- you exercise the actual email path every time.
The Config Layer
One detail worth calling out: the SMTP configuration is read at runtime, not compile time. The smtp-session function calls (config/get-config :smtp) every time it creates a session. This means:
- Tests can swap the config with
with-redefsand point to GreenMail - The dev config points to Mailpit
- Production reads from environment variables
- No code changes between environments
This is the same pattern used for the signing key, the database URI, and the session key. Aero's profile system handles the branching, and the code just calls get-config.
What We Have Now
Starting from the token infrastructure in the previous post, we have added:
- Email delivery -- Jakarta Mail via Eclipse Angus, no wrapper libraries
- Magic link flow -- request, send, verify, session creation
- Session management -- encrypted cookie, http-only, secure, 30-day expiry
- Post-Redirect-Get -- no accidental double-submissions
- Terms acceptance gate -- users must agree before accessing the app
- GreenMail tests -- real SMTP assertions, no mocking
- Mailpit for development -- visual email inspection during development
The complete flow works like this: a user enters their email on the home page. The server creates a signed token, emails a magic link, and redirects to a "check your email" page. The user clicks the link. The server verifies the token, creates a session, and redirects to the dashboard. If the user has not accepted terms, they see the terms page first. All of this with zero passwords stored anywhere.
The system is also testable at every layer. Unit tests verify token signing and verification. Integration tests verify email delivery end-to-end with GreenMail. And the config system makes it trivial to swap between test, development, and production SMTP.
Next time, we will look at the view layer -- how the HTML for all these pages gets generated with Hiccup, and how progressive enhancement gives us a smoother experience without sacrificing the non-JavaScript baseline.
E2E Testing a Clojure Web App with Playwright
Unit tests prove your functions work. E2E tests prove your application works. There is a gap between the two that no amount of unit coverage can bridge: the gap where middleware ordering matters, where sessions expire, where a redirect chain lands somewhere unexpected, where the browser does not behave the way your mental model predicted. This post covers how to set up proper end-to-end testing for a Clojure web app using Playwright, with a dedicated test server, stubbed services, and test-only endpoints for controlling state.
By the end, you will have a self-contained E2E test suite that spins up a fresh Clojure server with in-memory databases, captures emails instead of sending them, and exercises your auth flow through a real browser.
The Architecture
The E2E setup has four pieces:
e2e_server.clj-- A dedicated server entry point that boots the app with in-memory databases and stubbed external services.- Test-only HTTP endpoints -- Routes that only exist in the E2E server, letting Playwright inspect internal state (like captured emails).
playwright.config.js-- Configuration that tells Playwright to start the Clojure server before running tests and shut it down after.- Spec files -- The actual browser tests, living in an
e2e/directory.
This architecture keeps E2E concerns completely separate from your production code. The test server never ships. The test endpoints never exist outside of the test process. Your production server is not modified or compromised in any way.
The E2E Server
The heart of the setup is a dedicated server entry point. It looks like your production server, but with deliberate differences: in-memory databases, stubbed email sending, and extra routes for test control.
(ns myapp.e2e-server
"E2E test server entry point.
Starts a clean app instance with real auth flow (no auto-auth).
Stubs email sending to capture magic links in an atom, exposed via
test-only HTTP endpoints for Playwright to fetch."
(:require
[jsonista.core :as json]
[myapp.analytics.db :as analytics]
[myapp.auth.email :as email]
[myapp.config :as config]
[myapp.db.core :as db]
[myapp.test-helpers :as h]
[myapp.web.routes :as routes]
[org.httpkit.server :as http-kit]
[reitit.ring :as ring]
[ring.middleware.keyword-params :as keyword-params]
[ring.middleware.params :as params]
[ring.middleware.session :as session]
[ring.middleware.session.cookie :as cookie]))
Capturing Emails Instead of Sending Them
The first problem is email. Your auth flow sends magic links via SMTP. In tests, you do not have an SMTP server, and even if you did, you would not want tests depending on network I/O. The solution is simple: an atom that collects emails, and a stubbed function that writes to it instead of sending.
(def sent-emails
"Atom collecting emails captured from the stubbed send-magic-link! function."
(atom []))
The stub replaces the real send-magic-link! function at server startup (more on that below). Each call appends a map with the recipient and the magic link URL to the atom. From Playwright's perspective, the auth flow works identically -- the user enters their email, the server "sends" a magic link -- except the link ends up in memory instead of an inbox.
Test-Only Endpoints
Now Playwright needs a way to retrieve those captured emails. We add two endpoints that only exist in the E2E server:
(defn- get-emails-handler
"Return captured emails as JSON, optionally filtered by ?to= query param."
[request]
(let [to (get-in request [:params :to])
emails (cond->> @sent-emails
to (filter #(= (:to %) to)))]
{:status 200
:headers {"content-type" "application/json"}
:body (json/write-value-as-string emails)}))
(defn- clear-emails-handler
"Clear all captured emails."
[_request]
(reset! sent-emails [])
{:status 200
:headers {"content-type" "application/json"}
:body "{\"cleared\":true}"})
These get mounted alongside the app's real routes:
(def ^:private e2e-routes
"App routes plus test-only email capture endpoints."
(conj
routes/routes
["/test/emails"
{:get get-emails-handler
:delete clear-emails-handler}]))
The /test/emails endpoint supports a ?to= query parameter for filtering by recipient. This matters when you have tests running concurrently or multiple emails being sent in a single test. The DELETE method clears the atom, which you call in beforeEach to ensure test isolation.
This pattern generalizes. Any external service your app depends on -- payment processing, SMS, webhooks -- can be stubbed the same way: replace the side-effecting function, capture the calls in an atom, expose them via a test endpoint.
Deterministic Configuration
The E2E server uses hardcoded configuration instead of reading from environment variables:
(def ^:private e2e-config
"Deterministic config for e2e tests."
{:server {:port 9876
:host "127.0.0.1"}
:database-uri "datomic:mem://myapp-e2e"
:analytics-database-uri "datomic:mem://myapp-e2e-analytics"
:base-url "http://localhost:9876"
:session-key h/test-session-key
:signing-key h/test-signing-key
:smtp {:host "localhost"
:port 1025
:tls false
:user nil
:pass nil
:from "test@myapp.lan"}})
A few things to note:
- Port 9876 is fixed. The Playwright config needs to know where to find the server.
- In-memory Datomic (
datomic:mem://) means every test run starts fresh. No leftover data from previous runs, no database cleanup scripts. - Deterministic keys for session signing and token verification. These come from your test helpers module and are fixed byte arrays, not random. This means sessions and tokens behave identically across test runs.
- SMTP config points nowhere real. The email function is stubbed, so these values are never used, but they are present to keep the config shape consistent.
Building the Ring Handler
The app handler is assembled the same way as production, but using the extended route table:
(defn- build-app
"Build the Ring handler with real auth (no auto-auth).
Includes test-only routes for email capture."
[]
(let [session-store (cookie/cookie-store
{:key (config/get-config :session-key)})]
(ring/ring-handler
(ring/router e2e-routes)
(ring/routes
(ring/create-file-handler
{:path "/"
:root "static"})
(ring/create-default-handler))
{:middleware [[params/wrap-params]
[keyword-params/wrap-keyword-params]
[session/wrap-session
{:store session-store
:cookie-attrs {:http-only true
:same-site :lax}}]
[routes/wrap-locale]
[routes/wrap-no-cache-authenticated]]})))
The middleware stack is real. The session handling is real. The cookie store is real (just with a deterministic key). This is important: the E2E server must exercise the same middleware chain as production, or you are not testing what you think you are testing. The only differences should be external integrations (email, databases) and the addition of test control endpoints.
We keep :same-site :lax to match production -- the magic-link flow is a cross-context GET, and :strict would block the cookie on that navigation (more on this in the email login-flow chapter). We do drop :secure, because the e2e server runs over plain HTTP on localhost; a :secure cookie would never be sent. Those are the only deliberate cookie differences.
The Start Function
Everything comes together in start!:
(defn start!
"Start the e2e test server.
Sets deterministic config, stubs email sending, initializes fresh DB,
and starts http-kit. Blocks indefinitely (for Playwright webServer)."
[{:keys [port]
:or {port 9876}}]
(let [port (if (string? port) (parse-long port) port)]
;; Install deterministic config
(alter-var-root #'config/config
(constantly (delay e2e-config)))
;; Stub email sending -- capture to atom instead of SMTP
(alter-var-root
#'email/send-magic-link!
(constantly
(fn [_locale email token base-url]
(swap! sent-emails conj
{:to email
:magic-link (str base-url "/auth/verify?token=" token)})
{:error :SUCCESS})))
;; Initialize fresh in-memory databases
(db/create-database!)
(analytics/create-database!)
;; Start server
(http-kit/run-server
(build-app)
{:port port
:ip "127.0.0.1"})
(println (str "E2E server ready on port " port))
@(promise)))
The sequence matters:
- Install config first. Everything downstream reads from this.
- Stub external services.
alter-var-rootreplaces the var's root binding, so all code that callsemail/send-magic-link!gets the stub. No dependency injection framework needed -- just Clojure's var system. - Create fresh databases. In-memory Datomic databases are created empty, with schemas applied by
create-database!. - Start the HTTP server. http-kit listens on the configured port.
- Block forever.
@(promise)keeps the process alive. Playwright manages the lifecycle -- it starts this process before tests and kills it after.
The alter-var-root approach deserves a note. It is a blunt instrument -- it globally replaces the function. For E2E testing, this is exactly what you want. The test server is a separate process. There is no risk of affecting production code. And it means the stubbing works everywhere the function is called, without needing to thread a mock through the call stack.
Playwright Configuration
With the server in place, Playwright needs to know how to start it and where to find it:
// playwright.config.js
module.exports = {
testDir: './e2e',
use: {
baseURL: 'http://localhost:9876',
locale: 'en-US',
},
projects: [
{
name: 'chromium',
use: {
browserName: 'chromium',
launchOptions: {
args: ['--no-sandbox', '--disable-dev-shm-usage'],
},
},
},
],
webServer: {
command: 'clojure -X:test myapp.e2e-server/start!',
url: 'http://localhost:9876/health',
timeout: 120_000,
reuseExistingServer: !process.env.CI,
},
};
The webServer block is the key piece. Playwright will:
- Run the
commandto start the Clojure server. - Poll the
urluntil it returns a 200 response. This is your/healthendpoint -- a simple route that returns{"status": "ok"}. - Wait up to
timeoutmilliseconds (120 seconds here, because JVM startup plus Datomic schema transacting takes time). - Run the tests once the server is healthy.
- Kill the server process when tests finish.
The reuseExistingServer flag is useful during development. When not in CI, if a server is already running on port 9876, Playwright will use it instead of starting a new one. This lets you start the E2E server manually in a terminal, make changes, and re-run tests without the JVM startup penalty each time.
The clojure -X:test invocation uses Clojure's exec-fn mechanism. The :test alias in deps.edn adds the test/ directory to the classpath, making myapp.e2e-server available. The -X flag calls the start! function directly, passing any additional key-value pairs as a map argument.
The Chrome launch options (--no-sandbox, --disable-dev-shm-usage) are for CI environments where Chrome runs as root or in containers with limited shared memory.
The Auth Flow Spec
Now for the actual tests. Here is the complete auth spec that exercises the passwordless magic link flow:
// e2e/auth.spec.js
const { test, expect } = require('@playwright/test');
/** Fetch the most recent magic link sent to the given email address. */
async function getMagicLink(request, email) {
const res = await request.get(
`/test/emails?to=${encodeURIComponent(email)}`
);
const emails = await res.json();
expect(emails.length).toBeGreaterThan(0);
return emails[emails.length - 1]['magic-link'];
}
/** Generate a unique email for test isolation. */
function uniqueEmail() {
return `e2e-${Date.now()}@test.myapp.nl`;
}
Two helper functions set the stage. getMagicLink calls our test-only /test/emails endpoint to retrieve the magic link that was "sent" to a given address. uniqueEmail generates a timestamp-based email so each test gets its own user, even when tests run in parallel.
Shared Registration Flow
Since multiple tests need a registered user, the registration flow is extracted into a helper:
/** Register a new user through the full flow. */
async function registerUser(page, request, email) {
await page.goto('/');
await page.fill('input[name="email"]', email);
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(
page.getByRole('heading', { name: 'Check your email' })
).toBeVisible();
const magicLink = await getMagicLink(request, email);
await page.goto(magicLink);
await expect(page).toHaveURL(/\/terms\/welcome/);
await page.getByRole('button', { name: 'I agree to the terms' }).click();
await expect(page).toHaveURL(/\/dashboard/);
}
This walks through the entire registration: enter email, get the magic link from the test endpoint, visit it, accept terms, land on the dashboard. Any test that needs a logged-in user calls this first.
Test Isolation
Each test starts with a clean email inbox:
test.beforeEach(async ({ request }) => {
await request.delete('/test/emails');
});
This calls our DELETE /test/emails endpoint to clear the atom. Combined with unique email addresses per test, this ensures complete isolation between tests.
Test: New User Registration
test('new user registration', async ({ page, request }) => {
const email = uniqueEmail();
// Enter email on home page
await page.goto('/');
await page.fill('input[name="email"]', email);
await page.getByRole('button', { name: 'Sign in' }).click();
// Should see "check your email" content
await expect(
page.getByRole('heading', { name: 'Check your email' })
).toBeVisible();
// Get magic link from captured emails and visit it
const magicLink = await getMagicLink(request, email);
await page.goto(magicLink);
// New user -> redirected to terms acceptance
await expect(page).toHaveURL(/\/terms\/welcome/);
// Accept terms
await page.getByRole('button', { name: 'I agree to the terms' }).click();
// Should reach dashboard
await expect(page).toHaveURL(/\/dashboard/);
});
This test verifies the full new-user flow from landing page to dashboard. It exercises: form submission, server-side email "sending," token verification, terms acceptance, session creation, and the final redirect.
Test: Returning User Skips Terms
test('returning user login skips terms', async ({ page, request }) => {
const email = uniqueEmail();
// Register first (creates user with terms accepted)
await registerUser(page, request, email);
// Logout
await page.getByRole('button', { name: 'Sign out' }).click();
await expect(page.locator('input[name="email"]')).toBeVisible();
// Login again with same email
await page.fill('input[name="email"]', email);
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(
page.getByRole('heading', { name: 'Check your email' })
).toBeVisible();
const magicLink = await getMagicLink(request, email);
await page.goto(magicLink);
// Should go directly to dashboard (skip terms)
await expect(page).toHaveURL(/\/dashboard/);
});
This tests a critical branching point: returning users who have already accepted terms should land directly on the dashboard. The test registers a user, logs out, logs back in, and verifies the terms page is skipped. This is the kind of stateful behavior that is nearly impossible to unit test meaningfully -- you need the full session lifecycle.
Test: Logout Prevents Dashboard Access
test('logout prevents dashboard access', async ({ page, request }) => {
const email = uniqueEmail();
// Register and login
await registerUser(page, request, email);
// Sign out
await page.getByRole('button', { name: 'Sign out' }).click();
await expect(page.locator('input[name="email"]')).toBeVisible();
// Try to access dashboard directly -- should redirect to home
await page.goto('/dashboard');
await expect(page.locator('input[name="email"]')).toBeVisible();
});
A security test: after logout, navigating directly to /dashboard should redirect to the home page (the login form). This verifies that session destruction actually works, not just that the UI hides the button.
The Test Runner Script
The final piece is a shell script that ties it together:
#!/usr/bin/env bash
cd "$(dirname "$0")"
playwright test --config playwright.config.js
That is all. The cd ensures the script works regardless of where you invoke it from -- important when running from a CI pipeline or a project root. Playwright reads the config, starts the Clojure server, waits for it to be healthy, runs the specs, and tears everything down.
Run it:
./e2etest
Playwright handles the lifecycle. You do not need to start the server manually (though you can, during development, thanks to reuseExistingServer).
Design Decisions Worth Noting
Why a separate server process instead of starting the server in-test? Playwright expects to manage a server lifecycle via webServer. This is cleaner than trying to boot a JVM from within a Node.js test runner. It also means the Clojure server is a real process with real resource management, not something awkwardly embedded.
Why alter-var-root instead of dependency injection? For a test server that runs as its own process, global var replacement is the simplest approach. You are not composing test and production code in the same process. The E2E server is a standalone entry point. There is nothing to accidentally leak.
Why in-memory Datomic instead of a test database? Speed and isolation. In-memory databases are created in milliseconds, start empty, and disappear when the process exits. No cleanup, no port conflicts, no leftover state between test runs.
Why test-only HTTP endpoints instead of reading the atom directly? The tests run in a Node.js process. The server runs in a JVM process. They communicate over HTTP. The test endpoints are the bridge between these two worlds.
What You Now Have
After following this setup, you have:
- A dedicated E2E server that boots your full application stack with in-memory databases and stubbed external services
- Test-only endpoints for inspecting server-side state from your browser tests, without modifying production code
- A Playwright configuration that manages the server lifecycle automatically
- Auth flow specs that exercise registration, login, logout, and access control through a real browser
- A one-command test runner that handles everything end-to-end
The pattern extends naturally. Each new feature that needs E2E coverage gets a new spec file in e2e/. If the feature involves an external service, you stub it in the E2E server and add a test endpoint if needed. The infrastructure is in place -- you just write tests.
Unit tests tell you your functions are correct. E2E tests tell you your application works. You need both.
Building an Admin Dashboard: Datomic Queries, Live Stats, and CSS Animations
You do not need an admin dashboard on day one. But the moment you have even a handful of users, you need visibility into what is happening in your application. How many people signed up? Are magic links getting verified? Is the JVM healthy? Without a dashboard, you are flying blind -- SSHing into a server and running ad-hoc REPL queries every time you want a number.
This post builds a complete admin dashboard: a middleware layer that restricts access to a single admin email, Datomic queries across two databases, a stat grid component with live polling, and CSS-driven animated counters. No JavaScript frameworks, no charting libraries, no build step for the frontend. Just server-rendered HTML, a handful of Datomic queries, and about 30 lines of vanilla JS.
The Access Control Layer
Admin routes need to be locked down. Not just "requires authentication" but "requires a specific email address." This is a solo-operator SaaS -- there is exactly one admin. The middleware is straightforward:
(ns myapp.web.routes
(:require
[myapp.config :as config]
[myapp.web.handler :as handler]
[ring.util.response :as response]))
(defn wrap-admin
"Restricts access to admin routes. Checks session for admin email.
Unauthenticated HTML requests redirect to /, non-admin to /dashboard.
Routes with {:json? true} get 401/403 JSON responses instead."
[handler]
(fn [request]
(let [user-email (get-in request [:session :user-email])
json? (get-in request [:reitit.core/match :data :json?])]
(cond
(nil? user-email)
(if json?
(handler/json-response {:error "unauthorized"} :status 401)
(response/redirect "/"))
(not= user-email (config/get-config :admin-email))
(if json?
(handler/json-response {:error "forbidden"} :status 403)
(response/redirect "/dashboard"))
:else (handler request)))))
Three branches, each with two sub-branches for HTML vs JSON responses:
- Not authenticated at all -- redirect to the home page (or 401 for JSON).
- Authenticated but not the admin -- redirect to the regular dashboard (or 403 for JSON).
- Is the admin -- pass through to the handler.
The json? check uses reitit route data. This matters because the dashboard has both an HTML page and a JSON polling endpoint. You do not want the polling endpoint to return a 302 redirect with an HTML body when the session expires -- the JavaScript fetch call would silently follow the redirect and try to parse the login page as JSON.
The admin email is stored in config, not hardcoded. In routes, wrap-admin is applied to the entire /admin route group:
["/admin" {:middleware [wrap-admin]}
["" {:get handler/admin-dashboard}]
["/stats"
{:json? true
:get handler/admin-stats}]]
The :json? true metadata on the /stats route tells wrap-admin to return JSON error responses. The HTML dashboard route inherits the middleware but uses the default HTML behavior.
The Analytics Database
One design decision worth explaining: analytics data lives in a separate Datomic database from operational data. Same transactor, same underlying PostgreSQL storage -- zero new infrastructure. But logically separated so you can delete and recreate the analytics database without touching user data.
(ns myapp.analytics.db
"Analytics database layer.
Separate Datomic database for usage/analytics events. Same transactor,
same PostgreSQL -- zero new infrastructure. Can be deleted/recreated
without affecting operational data."
(:require
[datomic.api :as d]
[myapp.config :as config])
(:import
[java.time Instant]
[java.util Date]))
(set! *warn-on-reflection* true)
(def schema
"Analytics schema -- magic link tracking."
[{:db/ident :magic-link/email
:db/valueType :db.type/string
:db/cardinality :db.cardinality/one}
{:db/ident :magic-link/requested-at
:db/valueType :db.type/instant
:db/cardinality :db.cardinality/one}
{:db/ident :magic-link/verified-at
:db/valueType :db.type/instant
:db/cardinality :db.cardinality/one}])
The schema is minimal: three attributes for magic link tracking. Every magic link records the email it was sent to, when it was requested, and when (if) it was verified. This is enough to build a signup funnel.
The database setup functions follow:
(defn analytics-uri
"Returns the analytics database URI from config."
[]
(config/get-config :analytics-database-uri))
(defn create-database!
"Creates the analytics database and transacts the schema."
[]
(let [uri (analytics-uri)]
(d/create-database uri)
(let [conn (d/connect uri)]
@(d/transact conn schema)
conn)))
(defn get-connection
"Returns a connection to the analytics database."
[]
(d/connect (analytics-uri)))
(defn get-db
"Returns the current analytics database value."
[]
(d/db (get-connection)))
Recording events needs one small utility -- Datomic uses java.util.Date but the rest of the application works with java.time.Instant. The record! function handles the conversion:
(defn- convert-instant
"Converts java.time.Instant to java.util.Date for Datomic compatibility."
[x]
(if (instance? Instant x) (Date/from x) x))
(defn record!
"Transacts analytics events, converting Instants to Dates."
[tx-data]
(let [conn (get-connection)]
@(d/transact
conn
(mapv (fn [m] (into {} (map (fn [[k v]] [k (convert-instant v)])) m)) tx-data))))
The record! function walks each map in the transaction data, converting any Instant values to Date. This lets the rest of the codebase work with java.time while Datomic gets the java.util.Date it expects.
Admin Queries
The admin dashboard needs data from both databases: user information from the operational database and magic link analytics from the analytics database. The query layer lives in its own namespace:
(ns myapp.admin.core
"Admin dashboard queries.
Reads from both operational DB (users) and analytics DB (magic links)."
(:require
[datomic.api :as d])
(:import
[java.time Duration]
[java.util Date]))
(set! *warn-on-reflection* true)
Counting Users
The simplest query -- total registered users:
(defn total-users
"Returns the total number of registered users."
[db]
(or
(d/q
'[:find (count ?e) .
:where [?e :user/email]]
db)
0))
The . after (count ?e) is Datomic's scalar return syntax -- it returns a single value instead of a set of tuples. The or with 0 handles the empty-database case, where d/q returns nil.
Listing All Users
For the users table, we need more detail:
(defn all-users
"Returns all users sorted newest-first, with dates converted to Instants."
[db]
(->> (d/q '[:find
[(pull ?e [:user/email :user/created-at
:user/terms-accepted-at :user/active?]) ...]
:where [?e :user/email]]
db)
(sort-by :user/created-at #(compare %2 %1))
(mapv (fn [u]
(let [convert (fn [^Date d] (when d (.toInstant d)))]
(-> u
(update :user/created-at convert)
(update :user/terms-accepted-at convert)))))))
A few things to note here:
- The
[... ...]collection find spec returns a flat vector of results instead of nested tuples. pullfetches multiple attributes in one shot, avoiding N+1 queries.- The sort uses
#(compare %2 %1)for reverse chronological order -- newest first. - Dates are converted from
java.util.Date(Datomic) tojava.time.Instant(the rest of the app).
Recent Magic Links with Time-to-Click
This query is more interesting. It joins the request and verification timestamps, computes the time between them, and returns the 50 most recent:
(defn recent-magic-links
"Returns the 50 most recent magic links with time-to-click computed."
[analytics-db]
(let [to-instant (fn [^Date d] (when d (.toInstant d)))]
(->> (d/q '[:find
[(pull ?e [:magic-link/email :magic-link/requested-at
:magic-link/verified-at]) ...]
:where [?e :magic-link/email]]
analytics-db)
(sort-by :magic-link/requested-at #(compare %2 %1))
(take 50)
(mapv (fn [ml]
(let [requested (to-instant (:magic-link/requested-at ml))
verified (to-instant (:magic-link/verified-at ml))
ttc (when (and requested verified)
(Duration/between requested verified))]
(cond-> {:email (:magic-link/email ml)
:requested-at requested}
verified (assoc :verified-at verified)
ttc (assoc :time-to-click ttc))))))))
Time-to-click is a genuinely useful metric. If users take 10 minutes to click a magic link, there might be a deliverability problem. If they click in 3 seconds, the flow is working. The Duration/between computation is only done when both timestamps exist -- unverified links get nil.
The cond-> threading macro conditionally adds :verified-at and :time-to-click only when they have values. This avoids polluting the result maps with nil entries.
Signup Funnel Stats
The funnel query spans both databases:
(defn funnel-stats
"Returns signup funnel counts: links sent, verified, and terms accepted."
[db analytics-db]
(let [links-sent (or
(d/q
'[:find (count ?e) .
:where [?e :magic-link/requested-at]]
analytics-db)
0)
links-verified (or
(d/q
'[:find (count ?e) .
:where [?e :magic-link/verified-at]]
analytics-db)
0)
terms-accepted (or
(d/q
'[:find (count ?e) .
:where [?e :user/terms-accepted-at]]
db)
0)]
{:links-sent links-sent
:links-verified links-verified
:terms-accepted terms-accepted}))
Three counts, two databases. Links sent and verified come from the analytics database. Terms accepted comes from the operational database. Together they show the full funnel: how many people requested a magic link, how many clicked it, and how many accepted terms and actually signed up.
JVM Stats
The JVM memory query is pure Java interop -- no Datomic involved:
(defn jvm-stats
"Returns current JVM memory usage."
[]
(let [runtime (Runtime/getRuntime)
max-mem (.maxMemory runtime)
total-mem (.totalMemory runtime)
free-mem (.freeMemory runtime)
used-mem (- total-mem free-mem)
mb (fn [^long n] (format "%.0f MB" (double (/ n 1048576))))]
{:max (mb max-mem)
:total (mb total-mem)
:free (mb free-mem)
:used (mb used-mem)}))
Four numbers: max (what the JVM is allowed to use), total (what it has claimed from the OS), free (unused within the claimed space), and used (total minus free). The mb helper converts bytes to megabytes with no decimal places.
Note the ^long type hint on the mb function parameter. Without it, the division would trigger a boxed math warning -- exactly the kind of thing the strict compilation setup from the strict-compilation chapter catches.
The Polling Endpoint
The JSON endpoint for live polling bundles everything into a flat map with numeric values:
(defn dashboard-stats
"Returns raw numeric stats for the live-polling JSON endpoint."
[db analytics-db]
(let [funnel (funnel-stats db analytics-db)
runtime (Runtime/getRuntime)
mb (fn [^long n] (long (/ n 1048576)))]
{:total-users (total-users db)
:links-sent (:links-sent funnel)
:links-verified (:links-verified funnel)
:terms-accepted (:terms-accepted funnel)
:jvm-used-mb (mb (- (.totalMemory runtime) (.freeMemory runtime)))
:jvm-free-mb (mb (.freeMemory runtime))
:jvm-total-mb (mb (.totalMemory runtime))
:jvm-max-mb (mb (.maxMemory runtime))}))
This function returns long values (not formatted strings) because the JavaScript frontend needs numbers for comparison and animation. The key names match the data-stat attributes in the HTML, which is how the polling script knows which element to update.
The Handler Layer
The handler orchestrates queries and renders the view:
(defn admin-dashboard
"Renders the admin dashboard. Access control handled by wrap-admin middleware."
[request]
(let [db (d/db (db/get-connection))
analytics-db (analytics/get-db)
runtime (Runtime/getRuntime)
mb (fn [^long n] (long (/ n 1048576)))
jvm-used-mb (mb (- (.totalMemory runtime) (.freeMemory runtime)))
user-email (get-in request [:session :user-email])]
{:status 200
:headers {"Content-Type" "text/html"}
:body (str
(admin-views/admin-dashboard
{:total-users (admin/total-users db)
:users (admin/all-users db)
:magic-links (admin/recent-magic-links analytics-db)
:funnel (admin/funnel-stats db analytics-db)
:jvm {:jvm-free-mb (mb (.freeMemory runtime))
:jvm-total-mb (mb (.totalMemory runtime))
:jvm-max-mb (mb (.maxMemory runtime))}
:jvm-used-mb jvm-used-mb
:user-email user-email}))}))
(defn admin-stats
"JSON endpoint for live-polling admin stat cards.
Access control handled by wrap-admin middleware."
[_request]
(let [db (d/db (db/get-connection))
analytics-db (analytics/get-db)]
(json-response (admin/dashboard-stats db analytics-db))))
Two handlers, one route group. The dashboard handler fetches everything and renders HTML. The stats handler returns JSON for the polling script. Both are protected by wrap-admin at the route level, so the handlers themselves do not need to check authorization.
Notice that the dashboard handler calls d/db to get a point-in-time snapshot. This is important -- all queries within a single request see the same database state, even if transactions happen concurrently. This is one of Datomic's strengths: you never get inconsistent reads within a request.
The Stat Grid Component
The view layer renders a grid of stat cards using Hiccup:
(defn- stat-card
"Renders a single stat cell in the shared-border grid.
Optional trending text appears top-right (e.g. '83%')."
([label stat-key raw-value] (stat-card label stat-key raw-value nil))
([label stat-key raw-value trending]
[:div
{:class
"flex flex-wrap items-baseline justify-between gap-x-4 gap-y-2
bg-surface px-4 py-8 sm:px-6"}
[:dt {:class "text-sm/6 font-medium text-text-secondary"} label]
(when trending
[:dd {:class "text-xs font-medium text-positive"} trending])
[:dd
{:class "w-full flex-none text-3xl/10 font-medium tracking-tight
text-text-primary"
:data-stat stat-key
:data-value (str raw-value)
:style (str "--stat-value:" raw-value)}]]))
Each stat card has three data attributes that make live polling work:
data-stat-- the key name (matches the JSON response keys).data-value-- the current numeric value (used to detect changes).style="--stat-value:N"-- a CSS custom property that drives the animated counter.
The card does not render the number as text content. Instead, the value is set via the --stat-value CSS custom property, and CSS renders it using counter(). This is what enables the smooth animated transitions on update.
The dashboard view assembles eight cards into a 4-column, 2-row grid:
(defn admin-dashboard
"Renders the admin dashboard page."
[{:keys [users magic-links funnel jvm total-users jvm-used-mb user-email]}]
(let [verification-rate
(when (pos? (long (:links-sent funnel)))
(format
"%.0f%%"
(* 100.0 (/ (double (:links-verified funnel))
(double (:links-sent funnel))))))
jvm-total-mb (long (:jvm-total-mb jvm))
jvm-max-mb (long (:jvm-max-mb jvm))
jvm-used-pct
(when (pos? jvm-total-mb)
(format "%.0f%%" (* 100.0 (/ (double jvm-used-mb)
(double jvm-total-mb)))))
jvm-total-pct
(when (pos? jvm-max-mb)
(format "%.0f%%" (* 100.0 (/ (double jvm-total-mb)
(double jvm-max-mb)))))]
(views/app-layout
:en
user-email
:admin
{:admin? true}
[:div
[:dl
{:class "grid grid-cols-2 gap-px rounded-lg bg-border
overflow-hidden sm:grid-cols-4 mb-8"}
(stat-card "Total Users" "total-users" total-users)
(stat-card "Links Sent" "links-sent" (:links-sent funnel))
(stat-card "Verified" "links-verified" (:links-verified funnel)
verification-rate)
(stat-card "Terms Accepted" "terms-accepted" (:terms-accepted funnel))
(stat-card "JVM Used" "jvm-used-mb" jvm-used-mb jvm-used-pct)
(stat-card "JVM Free" "jvm-free-mb" (:jvm-free-mb jvm))
(stat-card "JVM Total" "jvm-total-mb" jvm-total-mb jvm-total-pct)
(stat-card "JVM Max" "jvm-max-mb" jvm-max-mb)]
[:div.space-y-8
(users-table users)
(magic-links-table magic-links)]
(live-stats-style)
(live-stats-script)])))
The gap-px and bg-border classes create a shared-border effect: the grid container has the border color as its background, and each card has a solid surface background. The 1px gap between cards reveals the container's border-colored background, creating the appearance of shared borders without any actual border elements. This is a nice Tailwind pattern.
The verification rate ("83%") appears as a trending indicator on the "Verified" card, giving at-a-glance conversion info. JVM stats show percentages too -- used as a percentage of total, and total as a percentage of max.
The live-stats-style and live-stats-script functions inject inline CSS and JavaScript at the bottom of the page. These are loaded from classpath resources using a defn-asset macro that reads the file once in production and re-reads on every call in development (for hot-reload):
(defn-asset live-stats-style "myapp/admin/views.css")
(defn-asset live-stats-script "myapp/admin/views.js")
CSS Animated Counters
The CSS is where things get interesting. Modern CSS has a feature called @property that lets you define custom properties with types. When combined with CSS counters and transitions, you get animated number counting with no JavaScript animation code:
@property --stat-value {
syntax: "<integer>";
initial-value: 0;
inherits: false;
}
[data-stat] {
--stat-value: 0;
counter-reset: stat var(--stat-value);
transition: --stat-value 600ms cubic-bezier(0.33, 1, 0.68, 1),
color 400ms;
}
[data-stat]::after {
content: counter(stat);
}
Here is how it works:
@propertydeclares--stat-valueas an integer type. This is critical -- CSS can only animate between values it understands. A raw custom property is just a string and cannot be interpolated. Declaring it as<integer>tells the browser it is a number.counter-reset: stat var(--stat-value)sets a CSS counter namedstatto the value of the custom property.- The
::afterpseudo-element renderscounter(stat)as the visible text. - The
transitionproperty animates changes to--stat-valueover 600ms with an ease-out curve.
When JavaScript updates --stat-value from 5 to 8, the browser interpolates through 6 and 7, updating the counter display at each frame. The result is a smooth counting animation with zero JavaScript animation logic.
JVM stat cards append " MB" to their counter display:
[data-stat="jvm-used-mb"]::after {
content: counter(stat) " MB";
}
[data-stat="jvm-free-mb"]::after { content: counter(stat) " MB"; }
[data-stat="jvm-total-mb"]::after { content: counter(stat) " MB"; }
[data-stat="jvm-max-mb"]::after { content: counter(stat) " MB"; }
Change Direction Indicators
When a value changes, a small arrow appears briefly to show the direction:
[data-stat].changed-up { color: #16a34a; }
[data-stat].changed-down { color: #dc2626; }
[data-stat]::before {
font-size: 0.6em;
margin-right: 0.15em;
opacity: 0;
}
[data-stat].changed-up::before {
content: "\2191";
color: #16a34a;
animation: fade-arrow 3s forwards;
}
[data-stat].changed-down::before {
content: "\2193";
color: #dc2626;
animation: fade-arrow 3s forwards;
}
@keyframes fade-arrow {
0% { opacity: 1; }
60% { opacity: 1; }
100% { opacity: 0; }
}
An upward arrow in green for increases, a downward arrow in red for decreases. The arrow fades out after 3 seconds. The forwards fill mode keeps the arrow hidden after the animation completes. The number itself also briefly changes color to match.
Live Polling with Vanilla JavaScript
The polling script is intentionally simple. No framework, no build step, no dependencies:
(function() {
function poll() {
fetch('/admin/stats', {credentials: 'same-origin'})
.then(function(r) { return r.ok ? r.json() : null; })
.then(function(data) {
if (!data) return;
var els = document.querySelectorAll('[data-stat]');
for (var i = 0; i < els.length; i++) {
var el = els[i];
var key = el.getAttribute('data-stat');
if (!(key in data)) continue;
var oldVal = parseInt(el.getAttribute('data-value'), 10);
var newVal = data[key];
if (oldVal !== newVal) {
el.style.setProperty('--stat-value', newVal);
el.setAttribute('data-value', newVal);
el.classList.remove('changed-up', 'changed-down');
void el.offsetWidth;
var cls = newVal > oldVal ? 'changed-up' : 'changed-down';
el.classList.add(cls);
setTimeout(function(e, c) { e.classList.remove(c); }, 3000, el, cls);
}
}
})
.catch(function() {});
}
setInterval(poll, 20000);
})();
Every 20 seconds, it fetches /admin/stats and compares each value against the current data-value attribute. When a value changes:
- Update the
--stat-valueCSS custom property (triggers the counter animation). - Update
data-valueso the next poll has a correct baseline. - Remove any existing direction class, then force a reflow with
void el.offsetWidth(this restarts the CSS animation). - Add the appropriate direction class (
changed-uporchanged-down). - Schedule removal of the direction class after 3 seconds.
The void el.offsetWidth trick is worth explaining. If an element already has the changed-up class and the value increases again, simply removing and re-adding the class would not restart the animation -- the browser would not see a state change. Reading offsetWidth forces a synchronous layout recalculation between the remove and add, which the browser treats as a genuine state transition.
The credentials: 'same-origin' option ensures cookies are sent with the request, which is necessary for the session-based authentication that wrap-admin checks.
The empty .catch(function() {}) silently swallows network errors. This is intentional -- if the server is briefly unreachable, the dashboard just keeps showing the last known values. No error toast, no retry backoff, no complexity. The next poll in 20 seconds will pick up where things left off.
The Data Tables
Below the stat grid, the dashboard renders two data tables. Here is the users table:
(defn- users-table
"Renders the users table."
[users]
[:div.bg-surface.border.border-border.rounded-lg.overflow-hidden
[:div.px-6.py-4.border-b.border-border
[:h3.text-lg.font-medium.text-text-primary "Users"]]
(if (seq users)
[:table.min-w-full.divide-y.divide-border
[:thead.bg-surface-subtle
[:tr
[:th.px-6.py-3.5.text-left.text-xs.font-medium.text-text-secondary.uppercase
"Email"]
[:th.px-6.py-3.5.text-left.text-xs.font-medium.text-text-secondary.uppercase
"Created"]
[:th.px-6.py-3.5.text-left.text-xs.font-medium.text-text-secondary.uppercase
"Terms Accepted"]
[:th.px-6.py-3.5.text-left.text-xs.font-medium.text-text-secondary.uppercase
"Active"]]]
[:tbody.bg-surface.divide-y.divide-border
(for [u users]
[:tr {:key (:user/email u)}
[:td.px-6.py-3.5.text-sm.text-text-primary (:user/email u)]
[:td.px-6.py-3.5.text-sm.text-text-secondary
(fmt-instant (:user/created-at u))]
[:td.px-6.py-3.5.text-sm.text-text-secondary
(fmt-instant (:user/terms-accepted-at u))]
[:td.px-6.py-3.5.text-sm
(if (:user/active? u)
[:span.text-positive "Yes"]
[:span.text-negative "No"])]])]]
[:p.px-6.py-4.text-sm.text-text-secondary "No users yet."])])
And the magic links table, which includes the time-to-click metric:
(defn- magic-links-table
"Renders the recent magic links table."
[links]
[:div.bg-surface.border.border-border.rounded-lg.overflow-hidden
[:div.px-6.py-4.border-b.border-border
[:h3.text-lg.font-medium.text-text-primary "Recent Magic Links"]]
(if (seq links)
[:table.min-w-full.divide-y.divide-border
[:thead.bg-surface-subtle
[:tr
[:th.px-6.py-3.5.text-left.text-xs.font-medium.text-text-secondary.uppercase
"Email"]
[:th.px-6.py-3.5.text-left.text-xs.font-medium.text-text-secondary.uppercase
"Requested"]
[:th.px-6.py-3.5.text-left.text-xs.font-medium.text-text-secondary.uppercase
"Verified"]
[:th.px-6.py-3.5.text-left.text-xs.font-medium.text-text-secondary.uppercase
"Time to Click"]]]
[:tbody.bg-surface.divide-y.divide-border
(for [ml links]
[:tr {:key (str (:email ml) (:requested-at ml))}
[:td.px-6.py-3.5.text-sm.text-text-primary (:email ml)]
[:td.px-6.py-3.5.text-sm.text-text-secondary
(fmt-instant (:requested-at ml))]
[:td.px-6.py-3.5.text-sm.text-text-secondary
(or (fmt-instant (:verified-at ml))
[:span.text-warning "Pending"])]
[:td.px-6.py-3.5.text-sm.text-text-secondary
(or (fmt-duration (:time-to-click ml)) "-")]])]]
[:p.px-6.py-4.text-sm.text-text-secondary "No magic links yet."])])
Unverified magic links show "Pending" in a warning color instead of a timestamp. Time-to-click shows a dash for links that have not been clicked yet.
The date formatting uses java.time.format.DateTimeFormatter configured for the Europe/Amsterdam timezone:
(def ^:private ^DateTimeFormatter datetime-fmt
(-> (DateTimeFormatter/ofPattern "yyyy-MM-dd HH:mm:ss")
(.withZone (ZoneId/of "Europe/Amsterdam"))))
(defn- fmt-instant
"Formats an Instant as yyyy-MM-dd HH:mm:ss in Europe/Amsterdam."
[^Instant inst]
(when inst (.format datetime-fmt inst)))
(defn- fmt-duration
"Formats a Duration as a human-readable string."
[^Duration dur]
(when dur
(let [secs (.toSeconds dur)]
(cond
(< secs 60) (str secs "s")
(< secs 3600) (format "%dm %ds" (quot secs 60) (rem secs 60))
:else (format "%dh %dm" (quot secs 3600) (quot (rem secs 3600) 60))))))
The duration formatter is simple but covers the useful range: seconds for short durations, minutes and seconds for medium ones, hours and minutes for long ones. A magic link that takes "2h 15m" to be clicked is a strong signal that something is landing in spam.
How It All Fits Together
The data flow is:
- User hits
/admin--wrap-adminchecks the session, confirms the admin email. - The handler queries both databases (operational for users, analytics for magic links).
- Hiccup renders the stat grid with
data-statattributes and--stat-valueCSS properties. - The browser renders the page, CSS counter shows the initial values.
- Every 20 seconds, JavaScript polls
/admin/statsfor fresh numbers. - When a value changes, JavaScript updates the CSS custom property.
- The browser animates the counter from the old value to the new value over 600ms.
- A directional arrow briefly appears and fades out.
No WebSockets, no server-sent events, no client-side state management. The page is server-rendered, the polling is a simple setInterval with fetch, and the animations are pure CSS. The total JavaScript is about 30 lines.
What You Have Now
After implementing this, you have:
- Access-controlled admin routes using a
wrap-adminmiddleware that checks a single admin email from config, with proper JSON error responses for API endpoints. - A separate analytics database that can be independently created, queried, or destroyed without touching operational data.
- Datomic queries across two databases for user stats, signup funnel metrics, and magic link analytics including time-to-click.
- JVM memory monitoring with used/free/total/max stats and percentage indicators.
- A stat grid with live polling that updates every 20 seconds without a page reload.
- CSS animated counters using
@property, CSScounter(), and transitions -- no JavaScript animation code. - Directional change indicators (arrows and color changes) that show whether a stat went up or down.
The entire feature is about 250 lines of Clojure and 70 lines of CSS/JavaScript. It gives you real-time visibility into your application with minimal complexity. No monitoring service to pay for, no dashboard framework to learn, no JavaScript build pipeline to maintain. Just your server, your database, and the browser's built-in capabilities.
The Production Asset Pipeline: Content Hashing, SRI, Import Maps, and CSP
The app is built. It server-renders HTML from Hiccup, styles it with Tailwind, enhances it with a handful of small ES modules, and morphs the DOM with a vendored library. In development all of that is served straight out of the source tree at stable URLs, and that is fine -- development optimizes for fast feedback, not for the wire.
Production is a different problem. Every served asset needs to be cache-busted on deploy so a new release is never masked by a stale cache. It needs to be tamper-evident over the wire. And the whole front-end needs to be locked down by a Content-Security-Policy strict enough to be worth the name -- one that would block an injected <script> even if our output escaping somehow failed.
This chapter covers that pipeline end to end. How every served asset gets a content hash. How the JavaScript modules are minified per-file -- no bundler -- with Subresource Integrity. How the vendored library is built. How the running app resolves a logical asset name to a hashed URL through a manifest, and how an integrity gate keeps a lying filename out of production. Then the two delivery modes -- a stable-URL no-store engine for development and immutable content-hashed assets for production -- and why they never drift. Finally the security layer: the escaping renderer, the strict CSP the app emits, the long-lived headers Caddy adds, and the regression tests that pin it all down.
By the end you will have one build that produces a byte-identical artifact for both dev and prod, a manifest the app reads at startup, and a defense-in-depth security posture that starts at output encoding and ends at the browser's CSP enforcement.
Project layout: sources vs. the generated tree
The single most important idea in this chapter is the split between sources and the generated served tree.
myapp/
input.css # Tailwind source: imports, tokens, custom CSS
static/ # SOURCES (committed)
js/
dispatcher.js # ESM modules, authored with absolute imports
live-form.js
defer-details.js
server-preview.js
admin-stats.js
util.js
idiomorph-0.7.4.js # vendored library source (committed)
fonts/GeistVF.woff2
icon.svg logo.svg ...
styles.css # dev-only Tailwind output, served unhashed (gitignored)
myapp/static/ # GENERATED served tree (gitignored)
styles.<hash>.css
js/dispatcher.<hash>.js ...
idiomorph-0.7.4.min.js idiomorph-0.7.4.min.js.map
asset-manifest.edn
src/myapp/web/
assets.clj # manifest, SRI, CSP, the defn-asset macro
views.clj # base-layout: link/importmap/script tags
dev/
hot_reload.clj # long-lived `tailwindcss --watch`, file watcher
build.clj # the `assets` and `verify-assets` tasks
static/ is committed source. You author your ESM modules there, you drop the vendored library source there, you keep fonts and SVGs there. What you do not commit is anything generated: the dev-time static/styles.css is git-ignored (dev serves it unhashed -- no hashing happens in development), and the entire myapp/static/ tree -- the production-served, content-hashed, minified output -- is git-ignored too.
/myapp/static
# Dev-generated CSS (Tailwind --watch writes this; sources are input.css + static/)
/static/styles.css
/static/styles.*.css
The build's assets task reads from static/ and writes the served tree into myapp/static/, alongside an asset-manifest.edn that the running app reads to map each logical asset name to its hashed URL. Nothing hashed is ever committed; the artifact is regenerated from source on every build.
One thing this layout makes explicit: development produces no hashed CSS. Dev serves static/styles.css unhashed at /styles.css. The styles.<hash>.css file only ever exists in the generated myapp/static/ tree, which only the production build writes.
One build, one content hash
The cache-busting strategy is the same for every served file: embed a content hash in the filename. styles.css becomes styles.2c7c3332.css; dispatcher.js becomes dispatcher.<hash>.js. When the content changes, the hash changes, the filename changes, and browsers fetch the new version. When it does not change, the filename is stable and the browser uses its cache. Perfect invalidation with zero revalidation traffic.
The hash is the first eight hex characters of the SHA-256 of the file's bytes. Eight characters give over four billion values -- more than enough to be collision-free across deploys -- and the helper is shared by both the build and the verifier so the two can never disagree about what "the hash" means.
(defn content-hash
"First 8 hex chars of the SHA-256 of a file's bytes -- the cache-bust fingerprint."
[^java.io.File file]
(let [md (java.security.MessageDigest/getInstance "SHA-256")
bs (.digest md (.readAllBytes (io/input-stream file)))]
(subs (format "%064x" (BigInteger. 1 bs)) 0 8)))
The same module also defines an SRI helper -- a base64 SHA-384 token -- used to make each JavaScript module tamper-evident over the wire:
(defn- sri384
"Subresource-Integrity token: base64 SHA-384 of a file's bytes, prefixed sha384-."
[^java.io.File file]
(let [bs (.digest (java.security.MessageDigest/getInstance "SHA-384")
(.readAllBytes (io/input-stream file)))]
(str "sha384-" (.encodeToString (java.util.Base64/getEncoder) bs))))
Content hash for cache busting; SRI for integrity. Two digests, two jobs.
The assets build task
A single tools.build task, assets, generates the entire served tree. It clears myapp/static/, then runs five passes, accumulating two maps as it goes -- logical-name to URL (assets*) and URL to SRI token (sri).
(def ^:private asset-src "static")
(def ^:private asset-out "myapp/static")
(def ^:private esbuild
"Standalone esbuild via npx (pinned). Same class of tool as the tailwindcss CLI."
["npx" "--yes" "esbuild@0.24.0"])
(defn assets
"Build the production static-asset tree into myapp/static/ + asset-manifest.edn.
Tailwind one-shot + content-hash; esbuild-minify each ESM module + content-hash;
esbuild-minify the vendored lib WITH a sourcemap (version-pinned filename, no
content hash); copy fonts/svgs/error through unchanged. Run: clojure -T:build assets"
[_]
(b/delete {:path asset-out})
(.mkdirs (io/file asset-out))
(let [assets* (atom {})
sri (atom {})]
;; 1. passthrough: everything except ESM sources, vendored lib sources, generated css
...
;; 2. CSS: Tailwind (minified) -> content-hash
(let [css (io/file asset-out "styles.css")]
(sh! "tailwindcss" "-i" "input.css" "-o" (.getPath css) "--minify")
(let [hn (str "styles." (content-hash css) ".css")]
(.renameTo css (io/file asset-out hn))
(swap! assets* assoc "styles.css" (str "/" hn))))
;; 3. app ESM: esbuild minify (no bundle, keep ESM + absolute imports) -> content-hash
(let [jsout (io/file asset-out "js")]
(.mkdirs jsout)
(doseq [^java.io.File f (sort (.listFiles (io/file asset-src "js")))
:when (str/ends-with? (.getName f) ".js")
:let [nm (.getName f)
tmp (io/file jsout nm)]]
(apply sh! (concat esbuild [(.getPath f) "--minify" "--format=esm"
(str "--outfile=" (.getPath tmp))]))
(let [out (io/file jsout (insert-hash nm (content-hash tmp)))
url (str "/js/" (.getName out))]
(.renameTo tmp out)
(swap! assets* assoc (str "js/" nm) url)
(swap! sri assoc url (sri384 out)))))
;; 4. vendored lib: our own minify + sourcemap (upstream ships no map); version in
;; filename (NOT content-hashed) so it survives app deploys; debuggable when needed.
(let [idsrc (io/file asset-src "idiomorph-0.7.4.js")
idmin (io/file asset-out "idiomorph-0.7.4.min.js")]
(apply sh! (concat esbuild [(.getPath idsrc) "--minify" "--sourcemap"
(str "--outfile=" (.getPath idmin))]))
(swap! assets* assoc "idiomorph" "/idiomorph-0.7.4.min.js")
(swap! sri assoc "/idiomorph-0.7.4.min.js" (sri384 idmin)))
;; 5. manifest the running app reads: {:assets name->url :sri url->sri}
(spit (io/file asset-out "asset-manifest.edn")
(pr-str {:assets (into (sorted-map) @assets*) :sri (into (sorted-map) @sri)}))
(println (str "assets: " (count @assets*) " entries (+SRI) -> " asset-out))
@assets*))
Walking the passes:
1. Passthrough. Everything under static/ that is not an ESM source, the vendored library source, or generated CSS is copied straight through: fonts, SVGs, the error/ directory. These keep their names; Caddy gives them a conservative TTL.
2. CSS. Tailwind runs once (--minify), writing styles.css into the output tree, which is then renamed to styles.<hash>.css. The logical name "styles.css" maps to the hashed URL.
3. App ESM. Each .js under static/js/ is minified by esbuild per file -- --minify --format=esm, no --bundle. This is the deliberate choice: we keep one module per file, with absolute import specifiers intact. esbuild here is a minifier, not a bundler. Each minified file is content-hashed, gets an SRI token, and its logical name (js/dispatcher.js) maps to its hashed URL (/js/dispatcher.<hash>.js).
Keeping the modules separate -- rather than bundling into one blob -- means each is independently cacheable, independently integrity-checked, and the browser's native module loader resolves the graph. The import map (below) rewrites the absolute specifiers to the hashed URLs at load time.
4. Vendored library. The committed idiomorph-0.7.4.js source is minified with a sourcemap (upstream ships none) into idiomorph-0.7.4.min.js. Its version lives in the filename, so it is not content-hashed: the version string already changes whenever the bytes do, and a stable name lets it survive routine app deploys in the browser cache. It still gets an SRI token. The sourcemap makes the rare debugging session into the morph engine bearable.
5. Manifest. Finally the two maps are written to asset-manifest.edn as {:assets name->url :sri url->sri}, both sorted for a stable diff.
A representative manifest:
{:assets {"idiomorph" "/idiomorph-0.7.4.min.js"
"js/admin-stats.js" "/js/admin-stats.<hash>.js"
"js/dispatcher.js" "/js/dispatcher.<hash>.js"
"js/live-form.js" "/js/live-form.<hash>.js"
"styles.css" "/styles.<hash>.css"}
:sri {"/idiomorph-0.7.4.min.js" "sha384-..."
"/js/dispatcher.<hash>.js" "sha384-..."
...}}
Resolving assets at runtime: load-manifest! and asset
The application never hard-codes a hashed filename. It asks assets.clj for a logical name and gets back the served URL. The manifest is loaded once at startup, into an atom.
(def dev?
"True in the dev environment (the dev/ source dir is on the classpath)."
(some? (io/resource "hot_reload.clj")))
(def static-root
"Dir the Ring file handler serves from: source static/ in dev, the built
myapp/static/ tree in prod (also what Caddy mounts)."
(if dev? "static" "myapp/static"))
(defonce ^:private manifest
;; {:assets {logical-name served-url} :sri {served-url sri-token}}
(atom {:assets {} :sri {}}))
(defn load-manifest!
"Load the asset manifest once at startup. PROD reads myapp/static/asset-manifest.edn;
DEV derives an identity/source manifest from static/."
[]
(reset! manifest
(if dev?
(dev-manifest)
(let [f (io/file asset-out "asset-manifest.edn")]
(if (.exists f)
(edn/read-string (slurp f))
(do (println "Assets: WARNING no asset-manifest.edn -- run `clojure -T:build assets`")
{:assets {} :sri {}})))))
(println (str "Assets: " (count (:assets @manifest))
(if dev? " dev" " prod") " manifest entries")))
load-manifest! is called once from myapp.core/start-server!, before the database is even initialized. In production it reads myapp/static/asset-manifest.edn. In development there is no generated tree, so it derives a manifest from the live static/ directory -- identity URLs (styles.css -> /styles.css, js/dispatcher.js -> /js/dispatcher.js), the vendored library served unminified at /idiomorph-0.7.4.js, and no SRI (the source files change as you edit them, so a fixed integrity hash would be wrong by design).
The lookups themselves are tiny:
(defn asset
"Resolve a logical asset name (e.g. \"styles.css\", \"js/dispatcher.js\",
\"idiomorph\") to its served URL. Falls back to an identity URL if unmapped."
[name]
(or (get-in @manifest [:assets name]) (str "/" name)))
(defn asset-sri
"SRI token for a served URL, or nil (e.g. always nil in dev)."
[url]
(get-in @manifest [:sri url]))
There is one source of truth -- the manifest the build emitted -- and two ways to obtain it: read the file in prod, derive it from source in dev. No globbing for styles.<hash>.css, no probing of multiple candidate directories, no atom poked by the hot-reload loop.
The import map and SRI-aware script tags
Because the ESM modules are not bundled and keep their absolute import specifiers, the browser needs to know that /js/dispatcher.js actually lives at /js/dispatcher.<hash>.js. That is exactly what an import map is for. assets.clj builds it from the manifest:
(defn importmap-json
"JSON for a <script type=importmap> remapping each ESM module's identity URL to
its served (hashed) URL, with an `integrity` block (per-module SRI) in prod so a
hash-based CSP can authorize the resolved modules. Identity no-op in dev. Emit it
BEFORE any module script."
[]
(let [as (:assets @manifest)
sri (:sri @manifest)
imports (into (sorted-map)
(for [[k v] as :when (str/starts-with? k "js/")]
[(str "/" k) v]))
integrity (into (sorted-map)
(for [[_ v] imports :when (sri v)] [v (sri v)]))]
(json/write-value-as-string
(cond-> {"imports" imports}
(seq integrity) (assoc "integrity" integrity)))))
The map remaps every /js/*.js identity URL to its hashed URL and, in production, carries an integrity block so the browser checks each resolved module's SRI. In development the imports are identity no-ops and there is no integrity block.
The layout emits the map (before any module loads) and then the scripts. A small script-tag helper attaches the SRI integrity attribute whenever the manifest has one:
(defn- script-tag
"A <script> for a served asset, with SRI integrity when the manifest provides it
(prod). `attrs` adds e.g. {:type \"module\"} or {:defer true}."
[logical attrs]
(let [url (assets/asset logical)]
[:script (cond-> (assoc attrs :src url)
(assets/asset-sri url) (assoc :integrity (assets/asset-sri url)))]))
And the head of base-layout:
[:link {:rel "stylesheet" :href (assets/asset "styles.css")}]
;; Import map (must precede any module script) remaps each module's absolute
;; import specifier to its hashed URL in prod; identity no-op in dev.
[:script {:type "importmap"} (h/raw (assets/importmap-json))]
;; Idiomorph (classic script) must load before the dispatcher module
;; so window.Idiomorph is available when dispatcher.js runs.
(script-tag "idiomorph" {:defer true})
(script-tag "js/dispatcher.js" {:type "module"})
(script-tag "js/live-form.js" {:type "module"})
(script-tag "js/defer-details.js" {:type "module"})
(script-tag "js/server-preview.js" {:type "module"})
(script-tag "js/admin-stats.js" {:type "module"})
The stylesheet link is (assets/asset "styles.css"), which renders as <link rel="stylesheet" href="/styles.<hash>.css"> in production and <link ... href="/styles.css"> in dev. The import-map JSON is emitted with h/raw for a reason we will come back to in the CSP section: the bytes the browser receives must be exactly the bytes the CSP hashed.
verify-assets: an integrity gate, not a rebuild
A filename that embeds a content hash is making a promise: "my bytes hash to this." verify-assets enforces that promise. It is a gate, not a build step -- it never runs Tailwind or esbuild. It just checks that the generated tree is internally consistent.
(defn verify-assets
"Integrity gate for the built asset tree. Asserts: a manifest exists; every
manifest target file exists; and every content-hashed filename matches the
SHA-256 of its own bytes (so a name can never lie about its contents).
Run `clojure -T:build assets` first. Run: clojure -T:build verify-assets"
[_]
(let [mf (io/file asset-out "asset-manifest.edn")]
(when-not (.exists mf)
(println "FAIL: no asset-manifest.edn -- run `clojure -T:build assets` first")
(System/exit 1))
(let [m (:assets (read-string (slurp mf)))
problems
(for [[name url] m
:let [f (io/file asset-out (subs url 1))] ; url is "/..."
:when (or (not (.exists f))
(when-let [[_ h] (re-find #"\.([a-f0-9]{8})\.(?:css|js)$" url)]
(not= h (content-hash f))))]
(str name " -> " url (if (.exists f) " (hash mismatch)" " (missing)")))]
(if (seq problems)
(do (println "FAIL: asset integrity problems:")
(doseq [p problems] (println " " p))
(System/exit 1))
(println (str "OK: " (count m) " assets verified"))))))
It asserts three things:
- The manifest exists. If not, the build never ran -- stop.
- Every manifest target exists on disk. A logical name pointing at a missing file is a broken deploy.
- Every content-hashed filename matches its own bytes. For any URL ending in
.<hash>.cssor.<hash>.js, it recomputes the SHA-256 of the file and compares it to the hash in the name, using the samecontent-hashhelper the build used. A name can never lie about its contents.
The vendored library is intentionally exempt from the hash check (its URL has no .<hash>. segment), but its existence is still verified.
Run it in CI right after assets:
clojure -T:build assets
clojure -T:build verify-assets
If the gate fails, the pipeline stops and no inconsistent asset tree reaches production.
One engine, two deliveries
Here is the part that makes the whole pipeline drift-free. There is exactly one set of sources and one build. Development and production differ only in how the same files are delivered -- not in what they are.
Development: stable URLs + no-store. Dev serves the static/ source directory directly (static-root is "static"), at stable, unhashed URLs. A single long-lived tailwindcss --watch process rebuilds static/styles.css incrementally as you edit; esbuild is not involved (the ESM is served as-is, the vendored library unminified). Because the URLs are stable but the bytes behind them change as you work, the Ring file handler is wrapped to send Cache-Control: no-store for every .css/.js:
(defn wrap-dev-no-store
"Dev only: Cache-Control: no-store on served .css/.js so a stable (unhashed) dev
URL never serves stale bytes after Tailwind --watch / esbuild rewrites the file."
[handler]
(fn [request]
(let [resp (handler request)]
(if (and resp (re-find #"\.(?:css|js)$" (or (:uri request) "")))
(assoc-in resp [:headers "Cache-Control"] "no-store")
resp))))
The file handler is only wrapped in dev:
(cond-> (ring/create-file-handler {:path "/" :root assets/static-root})
assets/dev? wrap-dev-no-store)
Production: content hash + immutable. Prod serves the generated myapp/static/ tree (static-root is "myapp/static"), at content-hashed URLs, with year-long immutable cache headers set by Caddy. No no-store -- a hashed URL's bytes can never change, so the browser need never revalidate.
The key property: the bytes prod ships are produced from the exact sources dev develops against. You are never reconciling two parallel asset trees, and there is no "did you remember to rebuild and commit the hashed file" failure mode, because nothing hashed is committed -- it is regenerated by assets and gated by verify-assets on every build. Dev's stability comes from no-store on a stable URL; prod's immutability comes from a hash in the URL. Same engine, two deliveries.
Caddy: immutable caching and long-lived security headers
Caddy sits in front of the app and serves the static tree directly. Here is the production-shaped vhost (the committed Caddyfile shows the myapp.lan dev block, which is identical in structure -- it mounts the same /static root and the app behind reverse_proxy):
myapp.lan {
tls /certs/myapp.lan/myapp.lan.crt /certs/myapp.lan/myapp.lan.key
encode zstd gzip
# Long-lived, request-invariant security headers (applied to every response).
# The per-document Content-Security-Policy is set by the app, which owns the
# inline-script hashes -- Caddy must NOT set CSP or it would conflict.
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
Referrer-Policy "strict-origin-when-cross-origin"
Permissions-Policy "geolocation=(), microphone=(), camera=()"
Cross-Origin-Opener-Policy "same-origin"
Cross-Origin-Resource-Policy "same-origin"
-Server
}
root * /static
@static file
handle @static {
# Content-hashed filenames (styles.<hash>.css, dispatcher.<hash>.js) are immutable
@hashed path_regexp \.([a-f0-9]{8})\.(css|js)$
header @hashed Cache-Control "public, max-age=31536000, immutable"
# Vendored libs are version-pinned in the filename, so equally immutable
@vendor path /idiomorph-*.min.js /idiomorph-*.min.js.map
header @vendor Cache-Control "public, max-age=31536000, immutable"
# Static assets that rarely change
@assets path *.svg *.png *.jpg *.woff2
header @assets Cache-Control "public, max-age=604800"
file_server
}
handle {
reverse_proxy myapp:3000
}
}
Caching tiers. Content-hashed files (styles.<hash>.css, dispatcher.<hash>.js) get a one-year immutable lifetime -- the browser never revalidates, and a content change means a new filename. The vendored library gets the same immutable treatment because its version is pinned in the filename. Fonts, icons, and images -- which keep their plain names -- get a one-week TTL.
Long-lived security headers. Caddy owns the request-invariant headers, applied to every response: HSTS (Strict-Transport-Security), X-Content-Type-Options: nosniff, Referrer-Policy, a restrictive Permissions-Policy, and the cross-origin isolation pair Cross-Origin-Opener-Policy / Cross-Origin-Resource-Policy (both same-origin). It also strips the Server header. These are static -- they do not depend on the page being rendered -- so the proxy is the right place for them.
Caddy does NOT set the Content-Security-Policy. That is the one security header Caddy must stay out of. The CSP is per-document -- it embeds the hashes of the inline scripts the app emits -- so only the app can build it. The @static file matcher checks that a file exists on disk before serving it; everything else falls through to reverse_proxy and reaches the Clojure app, which attaches the CSP to its HTML responses.
Output escaping: the primary XSS defense
Before the CSP, the more fundamental fix. The base layout used to render through a non-escaping HTML helper, so user-supplied fields -- recipe titles, descriptions -- were written into the page verbatim. A title of <script>steal()</script> would execute. A stored XSS.
The layout now renders through the escaping hiccup2 renderer. h/html HTML-escapes every string by default; the only content emitted verbatim is what is explicitly wrapped in h/raw -- rendered markdown, intentional inline scripts and styles, the import map.
(defn- base-layout
"Base HTML5 wrapper. All pages use this -- never called directly by page fns."
[locale & body]
;; Rendered with the ESCAPING hiccup2 renderer (h/html): all string content is
;; HTML-escaped by default -- the primary XSS defense. Only h/raw content
;; (markdown, inline scripts/styles) is emitted verbatim; the strict CSP is the
;; defense-in-depth backup.
(h/html
{:mode :html}
(h/raw "<!DOCTYPE html>")
[:html {:lang (name locale)}
...]))
Output encoding is the primary XSS defense. The CSP that follows is defense-in-depth behind it -- a second wall that would also block the payload, not a substitute for escaping.
The strict, no-nonce Content-Security-Policy
The app emits a strict CSP, set by the wrap-csp middleware on every HTML response:
(defn wrap-csp
"Set the app's strict, no-nonce Content-Security-Policy on HTML responses; static
assets (served by Caddy in prod) don't need it. See myapp.web.assets/csp-header."
[handler]
(fn [request]
(let [resp (handler request)
ct (get-in resp [:headers "Content-Type"])]
(if (and ct (str/includes? ct "text/html"))
(-> resp
(assoc-in [:headers "Content-Security-Policy"] (assets/csp-header))
(assoc-in [:headers "Reporting-Endpoints"] "csp=\"/csp-report\""))
resp))))
The policy is built in assets.clj. It is hash-based, not nonce-based: instead of stamping a per-request nonce onto inline scripts, it allows exactly the inline scripts it knows it emits, by their SHA-256 content hash.
(defn- build-csp-header
[]
(str "default-src 'none'; "
"script-src 'self' " (str/join " " (map #(str "'" % "'") (csp-script-hashes))) "; "
"connect-src 'self'" (if dev? " ws: wss:" "") "; "
csp-rest
"; report-uri /csp-report; report-to csp"))
(def ^:private csp-rest
"style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'")
Reading it directive by directive:
default-src 'none'-- deny everything by default, then open the minimum.script-src 'self' <sha256...>-- same-origin scripts (the modules, authorized further by SRI) plus the SHA-256 hash of every inline<script>the app can emit. Crucially there is no'unsafe-inline'for scripts. An injected<script>whose hash is not on the list simply does not run -- which is exactly why the CSP would have blocked the stored XSS even if escaping had failed.style-src 'self' 'unsafe-inline'-- this one is pragmatic. A truly strictstyle-srcis unsolved across DOM-morphing front-ends (inline style attributes get rewritten during morphs), so this directive is the deliberate concession. It is documented as such, not hand-waved.connect-src 'self'-- plusws: wss:in dev for the hot-reload socket.- The rest:
img-src 'self' data:,font-src 'self',object-src 'none',base-uri 'none',form-action 'self',frame-ancestors 'none'. report-uri /csp-report; report-to csp-- violations are reported back to the app.wrap-cspalso sets aReporting-Endpoints: csp="/csp-report"header (the modern reporting group);report-uriis the widely-supported fallback.
Reports land at a small public endpoint that logs them:
(defn csp-report
"Receive a browser CSP violation report and log it. Public + unauthenticated, so
in production you would sample/rate-limit this (or point report-to at a managed
collector) -- an open report sink can be spammed."
[request]
(try
(when-let [body (:body request)]
(log/warn "CSP violation" {:report (slurp body)}))
(catch Exception _ nil))
{:status 204 :headers {}})
The route is public and unauthenticated, declared alongside the other public routes:
["/csp-report" {:post #'handler/csp-report}]
How inline-script hashes get into the policy
The inline scripts the app emits are defined through a macro, defn-asset, which both produces the hiccup element and registers the script's resource path so its hash enters the CSP:
(defn-asset toast-script "myapp/web/toast.js")
(defn-asset dev-reload-script "myapp/web/dev-reload.js")
(defn-asset inspector-script "myapp/web/inspector.js")
The macro records each script's path with register-inline-script!, and csp-script-hashes then hashes each registered inline script plus the import-map JSON:
(defn- csp-script-hashes
"sha256 CSP tokens for every inline <script> the app may emit (registered inline
assets + the import map JSON). Recomputed each call (cheap); in dev the inline
content hot-reloads, so the policy self-heals."
[]
(conj (mapv (fn [p] (sha256-b64 (slurp (io/resource p)))) (sort @inline-scripts))
(sha256-b64 (importmap-json))))
There are two subtleties worth internalizing:
The emitted bytes must equal the hashed bytes. The CSP authorizes a script by hashing its content; if the browser receives even one byte different from what we hashed, the script is blocked. That is why inline content is emitted with h/raw -- escaping would alter the bytes and break the hash. The defn-asset macro wraps every inline asset's content in h/raw for exactly this reason.
A hash-allowed import map still needs per-module SRI. Allowing the inline <script type="importmap"> by hash lets the map load, but the modules it resolves to are then fetched as same-origin scripts. The per-module SRI in the map's integrity block is what makes those resolved modules tamper-evident -- which is why importmap-json emits both the imports and the integrity block in production. Hash for the map, SRI for what the map points at.
A final note on the morphing front-end: DOM morphing is not fundamentally at odds with a strict CSP. A morph injects inert DOM nodes; inline scripts inside swapped content do not auto-execute, and the optional "re-run scripts" path is the only thing the CSP governs there. The invariant the app holds is simply: no inline <script> lives inside <main>. Enhancements like the admin live-stats are idiomatic ES modules loaded from <head>, not inline blobs in the body.
In production the policy is computed once and cached (delay); in dev it is rebuilt on each request so it tracks hot-reloaded inline scripts and self-heals as you edit.
The security tests
These guarantees are pinned by regression tests in test/myapp/web/security_test.clj. They guard against silently reintroducing the non-escaping renderer or loosening the CSP -- revert the escaping renderer or weaken a directive and the build fails.
Output escaping prevents stored XSS. A caller-supplied payload routed through base-layout (via error-page, which takes the same escaping path a recipe title did) must come out HTML-escaped, and the raw executable form must not appear:
(deftest output-escaping-prevents-stored-xss
(testing "user-controlled content is HTML-escaped by the shared layout"
(let [payload "<img src=x onerror=alert(document.cookie)>"
html (str (views/error-page :en payload))]
(is (str/includes? html "<img src=x onerror=alert(document.cookie)>")
"the payload must render ESCAPED")
(is (not (str/includes? html "<img src=x onerror"))
"the raw executable payload must NOT appear in the output"))))
The CSP is strict. The policy must keep default-src 'none', hash-based script-src, the locked-down object-src / base-uri / frame-ancestors / form-action, and reporting -- and it must never allow 'unsafe-eval' or an 'unsafe-inline' script source:
(deftest csp-is-strict
(testing "the Content-Security-Policy locks sources down"
(let [csp (assets/csp-header)]
(is (str/includes? csp "default-src 'none'"))
(is (re-find #"script-src 'self' 'sha256-" csp) "scripts: self + inline hashes")
(is (str/includes? csp "object-src 'none'"))
(is (str/includes? csp "base-uri 'none'"))
(is (str/includes? csp "frame-ancestors 'none'"))
(is (str/includes? csp "form-action 'self'"))
(is (str/includes? csp "report-uri /csp-report") "violations are reported")
(is (not (str/includes? csp "'unsafe-eval'")) "must NEVER allow eval")
(is (not (re-find #"script-src[^;]*'unsafe-inline'" csp))
"scripts must never be unsafe-inline"))))
The CSP authorizes the import map. With a stubbed manifest, the import map's own SHA-256 must appear in script-src -- otherwise the browser would block the very <script type="importmap"> the app emits:
(deftest csp-authorizes-the-import-map
(testing "the import map's own content hash is in script-src (else the browser blocks it)"
(let [a @#'assets/manifest
saved @a]
(try
(reset! a {:assets {"js/app.js" "/js/app.abcdef12.js"}
:sri {"/js/app.abcdef12.js" "sha384-deadbeef"}})
;; build fresh (not the cached prod value) so it reflects this manifest
(let [csp (#'assets/build-csp-header)]
(is (str/includes? csp (sha256-b64 (assets/importmap-json)))
"the importmap hash must appear in script-src"))
(finally (reset! a saved))))))
Asset resolution and import-map shape. A stubbed manifest exercises the lookups: asset resolves logical names and falls back to identity for unknowns, asset-sri returns the token, and importmap-json remaps identity URLs to hashed URLs and carries the integrity block when SRI exists:
(deftest asset-resolution-and-importmap-shape
(testing "manifest resolution, SRI lookup, and import map with integrity"
(let [a @#'assets/manifest
saved @a]
(try
(reset! a {:assets {"styles.css" "/styles.abcdef12.css"
"js/dispatcher.js" "/js/dispatcher.12345678.js"
"idiomorph" "/idiomorph-0.7.4.min.js"}
:sri {"/js/dispatcher.12345678.js" "sha384-MODHASH"}})
(is (= "/styles.abcdef12.css" (assets/asset "styles.css")))
(is (= "/missing.js" (assets/asset "missing.js")) "identity fallback for unknown names")
(is (= "sha384-MODHASH" (assets/asset-sri "/js/dispatcher.12345678.js")))
(let [im (assets/importmap-json)]
(is (str/includes? im "\"/js/dispatcher.js\":\"/js/dispatcher.12345678.js\"")
"imports remap identity URL -> hashed URL")
(is (str/includes? im "integrity") "integrity block present when SRI exists")
(is (str/includes? im "sha384-MODHASH")))
(finally (reset! a saved))))))
What you have now
After this setup, the project has:
- A single content-hash pipeline for every served file, built by one
assetstask from committed sources into a git-ignoredmyapp/static/tree plus anasset-manifest.edn. - Per-file ESM minification with SRI -- no bundler, absolute imports preserved, each module independently cacheable and integrity-checked, wired together by an import map.
- A vendored library built to a version-pinned, sourcemapped, SRI-protected file.
- A manifest-driven runtime --
load-manifest!at startup,asset/asset-sri/importmap-jsonlookups, no directory scanning. verify-assetsas an integrity gate that proves no hashed filename can lie about its contents.- One engine, two deliveries -- dev serves source at stable URLs with
no-store; prod serves the hashed tree asimmutable-- with no drift, because the prod artifact is built from the same sources. - A real security posture -- escaping as the primary XSS defense, a strict no-nonce CSP (script hashes + per-module SRI, no
'unsafe-inline'for scripts) emitted by the app, the long-lived headers (HSTS, nosniff, Referrer-Policy, Permissions-Policy, immutable caching) set by Caddy, and regression tests that fail the build if any of it regresses.
No webpack, no PostCSS plugin chain, no application bundle. A Tailwind CLI, a pinned esbuild used purely as a minifier, a few functions for hashing, SRI, the manifest, and the CSP. That is the entire pipeline.
100% Lighthouse Scores: Automated Performance Audits in CI
Lighthouse is Google's open-source tool for auditing web page quality across four categories: performance, accessibility, best practices, and SEO. Most teams run it manually, glance at the scores, and move on. The scores slowly degrade. Nobody notices until a customer complains about load times or a screen reader user cannot navigate the app.
There is a better approach. Wire Lighthouse into your CI pipeline with hard score thresholds. Every commit gets audited. If any category drops below 100, the build fails. You fix the regression immediately, while the change is fresh, instead of hunting through weeks of commits later.
The challenge for a SaaS app is that most pages live behind authentication. Lighthouse cannot log in. You need a test server that automatically authenticates every request, seeds realistic data, and serves the same pages your real users see. This post shows how to build that in Clojure, configure Lighthouse CI, and hit perfect scores across the board.
The Test Server
The Lighthouse test server lives on the test classpath -- it is never compiled into the production jar. Its job is simple: start a real instance of the app with a middleware that auto-authenticates every request as a test user.
(ns myapp.lighthouse
"Lighthouse CI server entry point.
Starts a clean app instance with auto-authentication for auditing
both public and authenticated pages. Never shipped to production --
lives on the test classpath only."
(:require
[myapp.analytics.db :as analytics]
[myapp.config :as config]
[myapp.db.core :as db]
[myapp.web.assets :as assets]
[myapp.web.routes :as routes]
[org.httpkit.server :as http-kit]
[reitit.ring :as ring]
[ring.middleware.keyword-params :as keyword-params]
[ring.middleware.params :as params]
[ring.middleware.session :as session]
[ring.middleware.session.cookie :as cookie])
(:import
[java.time Instant]
[java.util UUID]))
(set! *warn-on-reflection* true)
The namespace requires the same modules as the real app: routes, database, assets, Ring middleware. It is the full application stack, not a mock.
The Auto-Auth Test User
Lighthouse needs to see authenticated pages, but it has no way to perform a login flow. The solution is a middleware that injects a session on every request:
(defn- test-email
"Email for the auto-authenticated Lighthouse test user.
Read at runtime (not namespace-load time) so it always reflects the
active config. Must match :admin-email so admin pages are accessible."
[]
(config/get-config :admin-email))
(defn- wrap-auto-auth
"Inject a session with the test user on every request.
Lighthouse sees authenticated pages without needing cookies."
[handler]
(fn [request] (handler (assoc-in request [:session :user-email] (test-email)))))
Two things to note here. First, the test email comes from the admin email in config, resolved at request time -- a function, not a top-level def, so it tracks whatever profile the server starts under rather than freezing the value when the namespace loads (the same runtime-config discipline as the web-server chapter's delay and the email login-flow chapter's SMTP config). This means the auto-authenticated user has admin privileges, so Lighthouse can audit admin pages too. Second, wrap-auto-auth is trivial -- one line of middleware that sets :user-email in the session map. The rest of the application sees a normal authenticated request.
Seeding Test Data
An empty database produces empty pages. Empty pages get perfect performance scores but do not reflect what real users see. The test server seeds a user with accepted terms so the dashboard and other authenticated pages render with their full UI:
(defn- seed-test-user!
"Create a test user with terms accepted so dashboard renders fully."
[conn]
(let [now (Instant/now)]
@(db/transact* conn
[{:user/id (UUID/randomUUID)
:user/email (test-email)
:user/created-at now
:user/active? true
:user/terms-accepted-at now}])))
The :user/terms-accepted-at field is important. Without it, the app redirects to the terms acceptance page. With it, the dashboard renders normally. Seed the minimum data needed for pages to render their real content.
Building the App
The test server reconstructs the Ring handler from the same route table the production app uses. The only difference is the middleware stack, which inserts wrap-auto-auth between session handling and the route handlers:
(defn- build-app
"Build the Ring handler with auto-auth in the middleware stack.
Reconstructs the app from route data so auto-auth sits between
session middleware (which reads cookies) and the handlers."
[]
(let [session-store (cookie/cookie-store {:key (config/get-config :session-key)})]
(ring/ring-handler
(ring/router routes/routes)
(ring/routes
(ring/create-file-handler
{:path "/"
:root "static"})
(ring/create-default-handler))
{:middleware [[params/wrap-params] [keyword-params/wrap-keyword-params]
[session/wrap-session
{:store session-store
:cookie-attrs {:http-only true
:same-site :lax}}]
[wrap-auto-auth] [routes/wrap-locale]]})))
The middleware ordering matters. wrap-params and wrap-keyword-params run first to parse the request. wrap-session sets up cookie-based sessions. Then wrap-auto-auth injects the test user identity. Finally wrap-locale determines the locale for i18n. The handlers see a request that looks identical to a real authenticated request. As with the e2e server in the e2e-testing chapter, we keep :same-site :lax to match production but omit :secure, because this server runs over plain HTTP on localhost.
The Entry Point
The start! function ties everything together. It creates fresh databases, discovers the CSS asset path, seeds the test user, and starts an HTTP server. It defaults to port 9876 -- the same port the e2e server in the e2e-testing chapter uses; that is safe because the two run as separate, sequential CI steps and never bind the port at the same time:
(defn start!
"Start a Lighthouse audit server.
Initializes a fresh database, seeds a test user, and starts http-kit.
Prints a ready message that lhci startServerReadyPattern can match."
[{:keys [port]
:or {port 9876}}]
(let [port (if (string? port) (parse-long port) port)]
(db/create-database!)
(analytics/create-database!)
(assets/discover-stylesheet!)
(seed-test-user! (db/get-connection))
(http-kit/run-server
(build-app)
{:port port
:ip "127.0.0.1"})
(println (str "Lighthouse server ready on port " port))
@(promise)))
The final line -- @(promise) -- blocks the main thread indefinitely. Without it, the JVM would exit after starting the server. The println message is not decorative; Lighthouse CI uses it to know when the server is ready to accept requests, as we will see in the configuration below.
Lighthouse CI Configuration
With the test server in place, the Lighthouse CI configuration tells lhci how to start it, which URLs to audit, and what scores to enforce. Create a lighthouserc.js at the project root:
module.exports = {
ci: {
collect: {
startServerCommand: 'clojure -X:test myapp.lighthouse/start!',
startServerReadyPattern: 'Lighthouse server ready on port',
startServerReadyTimeout: 60000,
url: [
'http://localhost:9876/',
'http://localhost:9876/legal/algemene-voorwaarden',
'http://localhost:9876/legal/privacyverklaring',
'http://localhost:9876/terms/welcome',
'http://localhost:9876/dashboard',
'http://localhost:9876/admin',
],
numberOfRuns: 1,
settings: {
chromeFlags: '--no-sandbox --headless --disable-dev-shm-usage --disable-gpu',
},
},
assert: {
assertions: {
'categories:performance': ['error', {minScore: 1}],
'categories:accessibility': ['error', {minScore: 1}],
'categories:best-practices': ['error', {minScore: 1}],
'categories:seo': ['warn', {minScore: 1}],
},
},
},
};
Let me walk through each section.
collect
startServerCommand launches the Lighthouse test server using Clojure's -X (exec) flag. The :test alias adds the test/ directory to the classpath, making myapp.lighthouse available. The function start! takes a map argument (the -X convention), so it receives {} by default and falls back to port 9876.
startServerReadyPattern is a string that lhci watches for in the server's stdout. When it sees "Lighthouse server ready on port", it knows the server is accepting connections and begins the audit. This is why the println in start! matters -- it is a protocol between your server and the test runner.
startServerReadyTimeout gives the JVM 60 seconds to start. Clojure's startup is not instant, especially with AOT compilation disabled on the test classpath. Sixty seconds is generous but avoids flaky failures in CI where CPU is constrained.
url lists every page to audit. This covers the full range: the public landing page (/), legal pages, the terms acceptance flow, the authenticated dashboard, and the admin panel. Because wrap-auto-auth is active, Lighthouse accesses all of them without authentication ceremony.
numberOfRuns is set to 1. Lighthouse defaults to multiple runs and takes the median, which is useful for catching performance variance. For CI, a single run keeps the feedback loop fast. If you have flaky scores, increase this.
chromeFlags configures headless Chrome for a CI environment. --no-sandbox is required in most Docker/CI environments where Chrome cannot create its sandbox. --disable-dev-shm-usage avoids shared memory issues in containers with limited /dev/shm.
assert
The assertion block is where you set your standards. Each category maps to an assertion level and a minimum score:
- Performance, Accessibility, Best Practices are set to
errorwithminScore: 1(which is 100%). Any score below 100 fails the build. This is aggressive but achievable for a server-rendered app. - SEO is set to
warnwithminScore: 1. It warns rather than errors because some SEO checks (like canonical URLs or structured data) may not apply to authenticated pages. The warning keeps it visible without blocking deploys.
The Runner Script
The lhtest script is minimal:
#!/usr/bin/env bash
cd "$(dirname "$0")"
lhci autorun
The cd "$(dirname "$0")" ensures lhci runs from the project root regardless of where you invoke the script. This matters because lhci autorun looks for lighthouserc.js in the current directory. Running from the wrong directory means it silently uses defaults instead of your configuration.
Run it with:
./lhtest
lhci autorun handles everything: it starts your server (using startServerCommand), waits for the ready pattern, runs Lighthouse against each URL, collects the reports, and checks the assertions. If any assertion fails, it exits non-zero, which fails your CI step.
Hitting 100%
Setting minScore: 1 is easy. Actually achieving it requires attention to several areas that Lighthouse checks. Here are the fixes that matter for a server-rendered Clojure app.
Meta Tags
Every page needs viewport and description meta tags. Without them, Lighthouse docks you on SEO and best practices. In Hiccup, the base layout handles this:
(defn- base-layout
[locale & body]
(page/html5
{:lang (name locale)}
[:head [:meta {:charset "UTF-8"}]
[:meta
{:name "viewport"
:content "width=device-width, initial-scale=1.0"}]
[:meta
{:name "description"
:content (t locale :meta/description)}]
;; ...
]))
The {:lang (name locale)} attribute on the <html> element is easy to miss but Lighthouse checks for it. It tells screen readers and search engines what language the page is in. The viewport meta tag ensures mobile rendering works correctly. The description meta tag satisfies SEO audits.
Because these live in the shared base layout, every page gets them automatically. You set them once and never worry about a new page missing them.
Font Display
Custom fonts are a common performance pitfall. If the browser waits for a font to download before rendering text, users see a blank page (or flash of invisible text). The fix is font-display: swap in your @font-face declaration:
@font-face {
font-family: "Geist";
src: url("/fonts/GeistVF.woff2") format("woff2");
font-weight: 100 900;
font-style: normal;
font-display: swap;
}
With swap, the browser immediately renders text in a fallback font, then swaps in the custom font when it finishes loading. Users see content instantly. Lighthouse gives you full marks on the "Ensure text remains visible during webfont load" audit.
Using a variable font (the VF in GeistVF.woff2) also helps performance. Instead of loading separate files for each weight (regular, bold, semibold), a single variable font file covers the entire 100 900 weight range. Fewer network requests, smaller total download.
Semantic HTML
Lighthouse's accessibility audits check for proper HTML semantics. A <div> soup application will fail. The key elements:
- Use
<main>for the primary content area. Lighthouse checks that exactly one<main>element exists. - Use
<nav>for navigation sections. This helps screen readers identify and skip navigation. - Use proper heading hierarchy (
<h1>,<h2>, etc.) without skipping levels. - Use
<label>elements associated with form inputs via theforattribute.
In the app layout, this looks like:
(defn app-layout
[locale user-email active-tab opts & body]
(let [admin? (:admin? opts)]
(base-layout
locale
[:div.min-h-screen.flex.flex-col.bg-surface-subtle
(top-nav locale user-email active-tab admin?) ;; renders <nav>
[:main.flex-1.pb-16.md:pb-0 ;; semantic <main>
[:div.mx-auto.max-w-7xl.px-4.py-6.sm:px-6.lg:px-8
body]]
(bottom-tabs locale active-tab admin?)]))) ;; renders <nav>
And forms use explicit label associations:
[:label.block.text-sm.font-medium.text-text-primary {:for "email"}
(t locale :home/email-label)]
[:input {:type "email" :id "email" :name "email" :required true
:placeholder (t locale :home/email-placeholder)}]
These are not difficult changes, but they are easy to forget. Lighthouse in CI catches the omission before it ships.
Color Contrast
Lighthouse's accessibility audit checks that text has sufficient contrast against its background, following the WCAG 2.1 guidelines. This means your color palette needs to be designed with contrast ratios in mind from the start.
In the CSS theme definition:
@theme {
--color-text-primary: #0f172a; /* near-black on white: ~15.4:1 ratio */
--color-text-secondary: #64748b; /* slate on white: ~4.6:1 ratio */
--color-surface: #ffffff;
--color-surface-subtle: #f8fafc;
}
The minimum ratio for normal text is 4.5:1 (AA standard). For large text it is 3:1. The text-secondary color at #64748b against a white background gives roughly a 4.6:1 ratio -- just above the threshold. If you had picked a lighter gray, Lighthouse would catch it.
The lesson: pick your grays carefully, and let Lighthouse verify the math. Eyeballing contrast is unreliable.
How It Fits in CI
The Lighthouse audit runs alongside the other verification scripts:
| Script | What it checks |
|---|---|
./reformat | Code formatting (zprint) |
./lint | Static analysis (clj-kondo) |
./unittest | Unit tests with coverage |
./e2etest | Playwright end-to-end tests |
./lhtest | Lighthouse performance/accessibility/SEO audit |
All five must pass before merging to main. The CI pipeline runs them in sequence. If Lighthouse fails, you know the exact commit that introduced the regression, and the Lighthouse report tells you exactly which audit failed and why.
What You Have Now
After this setup, you have:
- A Lighthouse test server (
lighthouse.clj) that starts your real application with auto-authentication, so Lighthouse can audit both public and authenticated pages without a login flow. - Test data seeding that creates a user with accepted terms, ensuring pages render their full UI rather than empty shells.
- A
lighthouserc.jsconfiguration that audits six URLs across your application and demands 100% scores in performance, accessibility, and best practices. - A three-line runner script that integrates with your existing CI pipeline.
- Fixes for common Lighthouse failures: viewport and description meta tags in the base layout,
font-display: swapfor custom fonts, semantic HTML elements (<main>,<nav>,<label>), and a color palette with sufficient contrast ratios.
The total cost is one Clojure file on the test classpath, one JavaScript configuration file, and one shell script. The ongoing cost is zero -- Lighthouse runs automatically on every commit. The value is that performance, accessibility, and SEO regressions are caught at the moment they are introduced, not weeks later when someone happens to run an audit manually.
Start with 100. Stay at 100. The CI pipeline enforces it.
CI/CD for a Clojure SaaS: Forgejo Actions, Podman, and Automated Deployment
Over the past fifteen posts we have built a Clojure/Datomic SaaS piece by piece: strict compilation, server-rendered HTML, passwordless authentication, Datomic modeling, an asset pipeline, end-to-end tests, Lighthouse audits, and more. Each piece works on its own, but without a pipeline that ties them together, shipping is a manual checklist that grows longer with every feature. Forget one step and a broken build makes it to production. Forget two and your users notice.
This final post wires everything into a single automated pipeline. Push to main, and the system formats, lints, builds the static assets and verifies their integrity, runs tests with coverage, executes end-to-end tests in a real browser, audits performance with Lighthouse, builds an uberjar, and deploys -- all without human intervention. That is the goal. Let's build it.
A note on what is actually wired up in the companion repository. The pipeline in this chapter is the application deployment pipeline -- the one you would run for the SaaS itself. The companion repository for this book runs a different, much smaller CI workflow: a single GitHub Actions job that builds these chapters with mdBook and publishes the result to GitHub Pages. The application pipeline below (uberjar, Tailwind, esbuild, asset verification, Caddy, SSH deploy) is taught here in full, but it is not the workflow that ships this book's prose. Where it matters -- the asset-integrity gate especially -- I will be explicit about whether a step runs continuously or as a local pre-deploy check. Treat the application pipeline as the design you would adopt the day you stand up your own forge runner; the asset-integrity gate is useful today, even run by hand before a deploy.
Why Forgejo Actions
Forgejo is a self-hosted Git forge (a fork of Gitea) that includes a built-in CI/CD system called Forgejo Actions. The workflow syntax is compatible with GitHub Actions, which means you get the benefit of a familiar YAML-based pipeline definition without depending on GitHub's infrastructure. For a self-hosted SaaS where you already run your own Git server, this is a natural fit. No external CI service to pay for, no secrets leaving your network, and the CI runner lives on the same infrastructure as everything else.
The key difference from GitHub Actions is the runner model. Instead of ephemeral VMs, Forgejo Actions can run directly on the host machine. We use runs-on: host because our pipeline runs each step inside a Podman container anyway -- the host is just the orchestrator. This gives us full control over caching, networking, and the container runtime.
The CI Container Image
Every CI step (except checkout and deploy) runs inside a purpose-built container. This ensures the CI environment is reproducible and isolated from whatever is installed on the host. Here is the Dockerfile:
# CI build image for Forgejo Actions
# Contains only what's needed to lint, test, and build the uberjar.
FROM docker.io/library/debian:trixie
ENV LANG=en_US.UTF-8
ENV LANGUAGE=en_US:en
ENV LC_ALL=en_US.UTF-8
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
wget \
git \
gpg \
ca-certificates \
openssh-client \
rlwrap \
locales \
unzip \
&& sed -i -e 's/# en_US.UTF-8 UTF-8/en_US.UTF-8 UTF-8/' /etc/locale.gen \
&& dpkg-reconfigure --frontend=noninteractive locales \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# Install Java
RUN wget -qO - https://packages.adoptium.net/artifactory/api/gpg/key/public \
| gpg --dearmor | tee /etc/apt/trusted.gpg.d/adoptium.gpg > /dev/null \
&& echo "deb https://packages.adoptium.net/artifactory/deb \
$(awk -F= '/^VERSION_CODENAME/{print$2}' /etc/os-release) main" \
| tee /etc/apt/sources.list.d/adoptium.list \
&& apt-get update \
&& apt-get install -y --no-install-recommends temurin-25-jdk rlwrap \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
ENV JAVA_HOME=/usr/lib/jvm/temurin-25-jdk-amd64
# Install Clojure CLI
RUN curl -L -O https://github.com/clojure/brew-install/releases/latest/download/linux-install.sh \
&& chmod +x linux-install.sh \
&& ./linux-install.sh \
&& rm linux-install.sh \
&& clojure -M -e '(println "Clojure installed")'
# Install babashka
RUN curl -s https://raw.githubusercontent.com/babashka/babashka/master/install | bash
# Install zprint formatter
RUN curl -sL https://github.com/kkinnear/zprint/releases/download/1.3.0/zprintl-1.3.0 \
-o /usr/local/bin/zprint \
&& chmod +x /usr/local/bin/zprint
# Install clj-kondo linter
RUN curl -sL https://raw.githubusercontent.com/clj-kondo/clj-kondo/master/script/install-clj-kondo \
| bash
# Install Node.js, Playwright, Lighthouse CI, and Tailwind CSS
ENV NVM_DIR=/usr/local/nvm
RUN mkdir -p "$NVM_DIR" \
&& curl -fsSL https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash \
&& . "$NVM_DIR/nvm.sh" \
&& nvm install --lts \
&& nvm alias default lts/* \
&& ln -s "$NVM_DIR/versions/node/$(nvm version)/bin/node" /usr/local/bin/node \
&& ln -s "$NVM_DIR/versions/node/$(nvm version)/bin/npm" /usr/local/bin/npm \
&& ln -s "$NVM_DIR/versions/node/$(nvm version)/bin/npx" /usr/local/bin/npx \
&& npm install -g @playwright/test @lhci/cli tailwindcss @tailwindcss/cli \
&& ln -s "$NVM_DIR/versions/node/$(nvm version)/bin/playwright" /usr/local/bin/playwright \
&& ln -s "$NVM_DIR/versions/node/$(nvm version)/bin/lhci" /usr/local/bin/lhci \
&& ln -s "$NVM_DIR/versions/node/$(nvm version)/bin/tailwindcss" /usr/local/bin/tailwindcss \
&& playwright install --with-deps \
&& ln -s "$(find /root/.cache/ms-playwright -name chrome \
-type f -path '*/chrome-linux64/*' | head -1)" /usr/local/bin/chromium
ENV NODE_PATH=/usr/local/lib/node_modules
A few design choices worth explaining.
Debian Trixie as the base. We need a recent enough base to support current versions of Java, Node, and Chromium. Debian is stable, well-understood, and produces reasonably small images.
Everything is pinned or versioned. Java comes from Adoptium's Temurin 25 JDK. The zprint binary is pinned to 1.3.0. Clojure CLI pulls the latest stable release. The goal is reproducibility -- you want the same CI results whether you run the pipeline today or three months from now.
Playwright with Chromium. The playwright install --with-deps command downloads Chromium and all its system dependencies (X11 libraries, fonts, etc.) into the container. The final ln -s creates a /usr/local/bin/chromium symlink so Lighthouse CI can find the browser without extra configuration.
Symlinks for global tools. NVM installs Node into a deeply nested versioned directory. The symlinks make node, npm, npx, playwright, lhci, and tailwindcss available on the default PATH. Without these, every podman run command would need to source NVM first.
Why not a multi-stage build? The CI image is a build tool, not a production artifact. We want everything in one layer so the container starts fast with no copying between stages. Image size matters less here than build speed.
The Pipeline
Here is the complete ci.yml workflow:
name: CI
on:
push:
branches: [main]
paths:
- 'myapp/**'
- '.forgejo/**'
pull_request:
branches: [main]
paths:
- 'myapp/**'
- '.forgejo/**'
env:
CI_IMAGE: myapp-ci:latest
APP_HOST: ${{ vars.APP_HOST }}
CACHE_VOLS: >-
-v /var/cache/ci/m2:/root/.m2:Z
-v /var/cache/ci/gitlibs:/root/.gitlibs:Z
jobs:
build-and-deploy:
runs-on: host
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Create cache dirs
run: mkdir -p /var/cache/ci/m2 /var/cache/ci/gitlibs
- name: Build CI image
run: podman build -t $CI_IMAGE -f .forgejo/ci.Dockerfile .
- name: Check formatting
run: >
podman run --rm
-v ${{ github.workspace }}:/workspace:Z
-w /workspace/myapp
$CI_IMAGE
bash -O globstar -c "zprint '{:search-config? true}' -c src/**/*.clj test/**/*.clj"
- name: Lint with clj-kondo
run: >
podman run --rm
-v ${{ github.workspace }}:/workspace:Z
-w /workspace/myapp
$CI_IMAGE
clj-kondo --lint src test
- name: Build static assets
run: >
podman run --rm
-v ${{ github.workspace }}:/workspace:Z $CACHE_VOLS
-w /workspace/myapp
$CI_IMAGE
clojure -T:build assets
- name: Verify asset integrity
run: >
podman run --rm
-v ${{ github.workspace }}:/workspace:Z $CACHE_VOLS
-w /workspace/myapp
$CI_IMAGE
clojure -T:build verify-assets
- name: Run tests with coverage
run: >
podman run --rm
-v ${{ github.workspace }}:/workspace:Z $CACHE_VOLS
-w /workspace/myapp
$CI_IMAGE
clojure -M:coverage
- name: Run e2e tests
run: >
podman run --rm
-v ${{ github.workspace }}:/workspace:Z $CACHE_VOLS
-w /workspace/myapp
-e CI=true
$CI_IMAGE
playwright test --config playwright.config.js
- name: Run Lighthouse CI
run: >
podman run --rm
-v ${{ github.workspace }}:/workspace:Z $CACHE_VOLS
-w /workspace/myapp
$CI_IMAGE
lhci autorun
- name: Build uberjar
run: >
podman run --rm
-v ${{ github.workspace }}:/workspace:Z $CACHE_VOLS
-w /workspace/myapp
$CI_IMAGE
clojure -T:build uber
- name: Verify jar exists
run: test -f myapp/target/myapp.jar
- name: Deploy static files
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
env:
SSH_OPTS: -o StrictHostKeyChecking=no -i /root/.ssh/deploy_ed25519
run: |
scp -r $SSH_OPTS myapp/static/* deploy@$APP_HOST:/mnt/data/static/
- name: Deploy app
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
env:
SSH_OPTS: -o StrictHostKeyChecking=no -i /root/.ssh/deploy_ed25519
run: |
scp $SSH_OPTS myapp/target/myapp.jar deploy@$APP_HOST:/tmp/myapp.jar
ssh $SSH_OPTS deploy@$APP_HOST '/etc/scripts/deploy-myapp.sh /tmp/myapp.jar'
Let's walk through every stage.
Stage by Stage
Triggers and Path Filtering
on:
push:
branches: [main]
paths:
- 'myapp/**'
- '.forgejo/**'
pull_request:
branches: [main]
paths:
- 'myapp/**'
- '.forgejo/**'
The pipeline triggers on pushes to main and on pull requests targeting main, but only when files under myapp/ or .forgejo/ change. If you edit infrastructure configs, documentation, or feature specs, the CI pipeline does not run. This is important in a monorepo -- you do not want to spend ten minutes building and testing the application because someone updated a README in a different directory.
Environment Variables
env:
CI_IMAGE: myapp-ci:latest
APP_HOST: ${{ vars.APP_HOST }}
CACHE_VOLS: >-
-v /var/cache/ci/m2:/root/.m2:Z
-v /var/cache/ci/gitlibs:/root/.gitlibs:Z
Three variables defined at the workflow level so every step can use them.
CI_IMAGE is the tag for the CI container image. It is built fresh from the Dockerfile on every run to ensure the CI environment always matches the Dockerfile in the repository.
APP_HOST comes from a Forgejo repository variable (configured in Settings -> Actions -> Variables). This keeps the production hostname out of the workflow file. If you move to a different server, you change the variable, not the pipeline.
CACHE_VOLS is the key to fast builds. These are Podman bind mounts that map host directories into the container. Let's look at this more closely.
Cache Volumes
CACHE_VOLS: >-
-v /var/cache/ci/m2:/root/.m2:Z
-v /var/cache/ci/gitlibs:/root/.gitlibs:Z
Clojure's dependency resolution downloads JARs to ~/.m2 (the Maven local repository) and Git-based deps to ~/.gitlibs. Without caching, every CI run downloads the entire dependency tree from scratch. For a project with dozens of dependencies, that is minutes of network I/O.
By mounting host directories into the container, dependencies persist across runs. The first build downloads everything; subsequent builds resolve from the local cache in seconds. The :Z suffix is a SELinux relabeling flag that Podman needs on systems with SELinux enabled (like Fedora or RHEL-based hosts). It tells Podman to relabel the mount so the container process can read and write to it.
The Create cache dirs step ensures these host directories exist before any container tries to mount them:
- name: Create cache dirs
run: mkdir -p /var/cache/ci/m2 /var/cache/ci/gitlibs
Podman, Not Docker
Every containerized step uses podman run instead of docker run. Podman is a daemonless container runtime -- there is no background daemon process managing containers. Each container is a child process of the calling shell. This matters for CI because:
- No daemon dependency. If a Docker daemon crashes, all containers stop. With Podman, each container is independent.
- Rootless capable. Podman can run containers without root privileges, though in our CI setup we run as root for simplicity.
- CLI-compatible with Docker. The command-line interface is nearly identical, so if you already know Docker, you know Podman.
The pattern for every CI step is the same:
podman run --rm
-v ${{ github.workspace }}:/workspace:Z # mount the checkout
$CACHE_VOLS # mount dependency caches
-w /workspace/myapp # set working directory
$CI_IMAGE # use the CI image
<command> # run the tool
--rm removes the container after it exits. The workspace is mounted at /workspace inside the container, and the working directory is set to the application subdirectory. This means every tool sees the same file layout as a developer working locally.
Check Formatting
- name: Check formatting
run: >
podman run --rm
-v ${{ github.workspace }}:/workspace:Z
-w /workspace/myapp
$CI_IMAGE
bash -O globstar -c "zprint '{:search-config? true}' -c src/**/*.clj test/**/*.clj"
This runs zprint in check mode (-c) across all Clojure files in src/ and test/. The -c flag means zprint reads each file, formats it in memory, and compares the result to the original. If they differ, the file is not formatted correctly and the step fails with a non-zero exit code.
The bash -O globstar enables recursive globbing (**), which is not on by default in bash. The {:search-config? true} option tells zprint to find the .zprintrc configuration file by walking up the directory tree from each file.
Note that this step does not mount the cache volumes. Formatting does not need Maven dependencies -- it only needs the source files and the zprint binary.
Lint with clj-kondo
- name: Lint with clj-kondo
run: >
podman run --rm
-v ${{ github.workspace }}:/workspace:Z
-w /workspace/myapp
$CI_IMAGE
clj-kondo --lint src test
Static analysis across all source and test files. clj-kondo reads the .clj-kondo/config.edn from the project root for linter configuration (covered in detail in the strict-compilation chapter). Like formatting, this step does not need the cache volumes -- clj-kondo analyzes source code directly without resolving dependencies.
Build Static Assets and Verify Their Integrity
- name: Build static assets
run: >
podman run --rm
-v ${{ github.workspace }}:/workspace:Z $CACHE_VOLS
-w /workspace/myapp
$CI_IMAGE
clojure -T:build assets
- name: Verify asset integrity
run: >
podman run --rm
-v ${{ github.workspace }}:/workspace:Z $CACHE_VOLS
-w /workspace/myapp
$CI_IMAGE
clojure -T:build verify-assets
These two steps are where the asset pipeline from earlier in the series (the assets and verify-assets build tasks) becomes a deployment gate. They mount $CACHE_VOLS because both run under clojure -T:build, which needs resolved dependencies.
clojure -T:build assets produces the served tree. It runs Tailwind CSS once over input.css to emit the minified stylesheet, content-hashes it to styles.<hash>.css; runs esbuild over each ESM module under static/js/ to minify it (no bundling, absolute imports preserved) and content-hash it; minifies the vendored idiomorph source into idiomorph-0.7.4.min.js with a sourcemap (its version lives in the filename, so it is not content-hashed); copies fonts, SVGs and the error pages through unchanged; and finally writes asset-manifest.edn -- a map of {:assets {logical-name url} :sri {url sri}} that the running application reads at boot to resolve each logical asset name to its hashed URL and Subresource-Integrity token. The whole tree, plus the manifest, lands under myapp/static/ (which is gitignored -- only the sources under static/ are committed).
clojure -T:build verify-assets is the integrity gate. It does not re-run Tailwind or esbuild, and it does not compare against committed bytes. It asserts three invariants about the tree assets just produced:
- An
asset-manifest.ednexists (if not, it tells you to runclojure -T:build assetsfirst). - Every URL named in the manifest points at a file that actually exists on disk.
- Every content-hashed filename matches the SHA-256 of its own bytes -- so a filename can never lie about its contents. A
styles.2c7c3332.csswhose bytes hash to something other than2c7c3332fails the gate.
That third invariant is the load-bearing one. Because Caddy serves content-hashed assets with Cache-Control: public, max-age=31536000, immutable, a wrong hash is not a cosmetic bug -- it is a year-long cache poisoning. verify-assets catches a corrupted or hand-edited build artifact before it can be deployed under an immutable URL. Run it locally right before you ship; if you stand up an application CI runner, it slots in immediately after the assets step, exactly as shown above.
There is no separate scripts/verify-css-hash.bb or verify-css task; asset integrity lives entirely in build.clj as verify-assets, covering CSS, JavaScript and the manifest in one pass.
Run Tests with Coverage
- name: Run tests with coverage
run: >
podman run --rm
-v ${{ github.workspace }}:/workspace:Z $CACHE_VOLS
-w /workspace/myapp
$CI_IMAGE
clojure -M:coverage
Runs the test suite with code coverage tracking via the :coverage alias. This executes all unit and integration tests and produces a coverage report. A failing test means a non-zero exit code, which fails the pipeline step.
Run End-to-End Tests
- name: Run e2e tests
run: >
podman run --rm
-v ${{ github.workspace }}:/workspace:Z $CACHE_VOLS
-w /workspace/myapp
-e CI=true
$CI_IMAGE
playwright test --config playwright.config.js
This is where the Chromium browser inside the CI image earns its keep. Playwright launches a headless browser, starts the application, and runs through user flows -- clicking buttons, filling forms, verifying page content. The -e CI=true environment variable tells the test configuration to adjust timeouts and other settings for CI (where things may be slightly slower than on a developer machine).
These tests catch an entire class of bugs that unit tests miss: broken routes, missing templates, JavaScript errors, form submissions that silently fail. They are slower than unit tests but worth every second.
Run Lighthouse CI
- name: Run Lighthouse CI
run: >
podman run --rm
-v ${{ github.workspace }}:/workspace:Z $CACHE_VOLS
-w /workspace/myapp
$CI_IMAGE
lhci autorun
Lighthouse CI starts the application, loads key pages in Chromium, and measures performance, accessibility, best practices, and SEO. The lhci autorun command reads its configuration from a lighthouserc.js file in the project root, which defines which URLs to audit and what score thresholds to enforce.
If performance drops below the threshold -- maybe someone added a render-blocking resource or a large unoptimized image -- the pipeline fails. This catches performance regressions before they reach users, automatically and on every push.
Build the Uberjar
- name: Build uberjar
run: >
podman run --rm
-v ${{ github.workspace }}:/workspace:Z $CACHE_VOLS
-w /workspace/myapp
$CI_IMAGE
clojure -T:build uber
- name: Verify jar exists
run: test -f myapp/target/myapp.jar
The uberjar build compiles all Clojure source with strict compilation flags (*warn-on-reflection* and *unchecked-math* :warn-on-boxed), copies resources, and packages everything into a single JAR file. As covered in the strict-compilation chapter, the build fails if any reflection or boxed math warnings are detected in application code.
The Verify jar exists step is a simple sanity check that runs on the host (not in a container). If the uberjar build succeeded but somehow did not produce the expected file, this catches it.
Conditional Deployment
- name: Deploy static files
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
env:
SSH_OPTS: -o StrictHostKeyChecking=no -i /root/.ssh/deploy_ed25519
run: |
scp -r $SSH_OPTS myapp/static/* deploy@$APP_HOST:/mnt/data/static/
- name: Deploy app
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
env:
SSH_OPTS: -o StrictHostKeyChecking=no -i /root/.ssh/deploy_ed25519
run: |
scp $SSH_OPTS myapp/target/myapp.jar deploy@$APP_HOST:/tmp/myapp.jar
ssh $SSH_OPTS deploy@$APP_HOST '/etc/scripts/deploy-myapp.sh /tmp/myapp.jar'
Deployment only happens when two conditions are met: the branch is main and the event is a push (not a pull request). Pull requests run the full pipeline -- format, lint, test, build -- but stop short of deploying. This gives you confidence that a PR is ready to merge without actually touching production.
The deployment itself is straightforward:
-
Static files are copied via
scpfrommyapp/static/-- the built tree thatclojure -T:build assetsproduced andverify-assetsjust signed off on -- to the directory the reverse proxy serves. That tree contains the content-hashed stylesheet and ESM modules, the version-pinnedidiomorph-0.7.4.min.js(and its sourcemap), the passed-through fonts and SVGs, andasset-manifest.edn. The manifest must travel with the assets: the running application reads it at boot to map each logical asset name to its hashed URL and SRI token, so deploying the hashed files without the manifest would leave the app unable to resolve them. -
The uberjar is copied to
/tmp/on the server, then a deploy script moves it into place and restarts the application. The deploy script handles the atomic swap: stop the running process, move the new JAR into position, start the new process. This keeps downtime to a few seconds.
Static assets are served by Caddy, not by the JVM. In the companion repository's Caddyfile, the proxy applies Cache-Control: public, max-age=31536000, immutable to any content-hashed filename (the \.([a-f0-9]{8})\.(css|js)$ pattern) and to the version-pinned idiomorph-*.min.js, and it sets the long-lived security headers (HSTS, X-Content-Type-Options, Referrer-Policy, Permissions-Policy, and the Cross-Origin-Opener-Policy/Cross-Origin-Resource-Policy isolation pair). It deliberately does not set a Content-Security-Policy: the per-document CSP carries the SHA-256 hashes of the application's inline scripts and import map, so the application must own it. This is why immutable caching and asset integrity are two sides of one coin -- once a hashed file is out, browsers may cache it for a year, so verify-assets is the last chance to catch a hash that does not match its bytes.
The SSH key (deploy_ed25519) is pre-installed on the CI runner and grants access to a deploy user on the application server. This user has limited permissions -- it can write to the static directory and execute the deploy script, nothing else.
The Pipeline Order
The stages are deliberately ordered from fastest to slowest, and from cheapest to most expensive:
- Format check -- Seconds. No dependencies needed. Catches formatting issues before anything else runs.
- Lint -- Seconds. Static analysis only. Catches code quality issues early.
- Build assets -- Seconds. Runs Tailwind once and esbuild per module to produce the hashed, minified served tree plus the manifest.
- Verify asset integrity -- A fraction of a second. No tools, just hashing: every content-hashed filename must match its bytes and every manifest target must exist.
- Coverage -- Tens of seconds. Runs the test suite. First stage that executes application code.
- E2E tests -- Minutes. Launches a browser and runs user flows. Expensive but catches integration bugs.
- Lighthouse -- Minutes. Launches a browser and audits performance. Catches regressions.
- Uberjar -- Tens of seconds. Compiles everything with strict flags. Produces the deployable artifact.
- Deploy -- Seconds. Copies files and restarts the app.
If formatting is wrong, you find out in seconds, not after waiting for the entire test suite and build to run. This fast feedback loop makes CI less painful -- developers fix issues quickly because the pipeline tells them quickly.
What You Have Now
Once this pipeline is wired up to your forge runner, every push to main triggers a fully automated sequence:
- Code quality gates: formatting with zprint, static analysis with clj-kondo
- Asset integrity: the
assetsbuild produces the hashed, minified served tree and manifest;verify-assetsproves every content-hashed filename matches its bytes and every manifest target exists - Correctness verification: unit tests with coverage, end-to-end browser tests
- Performance assurance: Lighthouse CI audits with score thresholds
- Build integrity: strict AOT compilation that rejects reflection and boxed math warnings
- Automated deployment: conditional deployment of the built static tree (with its manifest) and the uberjar via SSH
The entire pipeline runs inside Podman containers built from a single Dockerfile, using host-mounted cache volumes for fast dependency resolution. Pull requests get the full quality gate treatment without deploying. Only pushes to main go to production.
There is nothing to remember. No manual checklist. No "did I run the tests?" anxiety before deploying. The pipeline enforces the standards every time, whether it is Monday morning or Friday evening.
A closing honesty about scope: the workflow above is the application pipeline. The repository that holds these chapters runs only the mdBook-to-Pages job described at the top of this chapter -- the prose ships continuously; the application pipeline is the design you reach for the day you run your own SaaS on your own runner. Until then, clojure -T:build assets followed by clojure -T:build verify-assets is a two-command pre-deploy ritual you can run by hand: build the tree, prove its integrity, then ship it.
Reflecting on the Series
This is the final post in "Building a Clojure/Datomic SaaS from Scratch." Over the course of this series, we have assembled a complete application stack from first principles:
We started with a Clojure project structure and strict compilation. We added server-side rendering with escaping Hiccup. We modeled our domain in Datomic. We built passwordless authentication. We built a content-hashed asset pipeline -- Tailwind, esbuild, an import map and Subresource Integrity -- behind a strict Content-Security-Policy. We wrote tests at every level -- unit, integration, and end-to-end. We measured performance with Lighthouse. And now we have tied everything together with continuous integration and automated deployment.
Every piece was built to be understood by one person. No magic frameworks, no hidden abstractions, no build tools that require a dedicated team to maintain. A single Dockerfile for CI. A single YAML file for the pipeline. SSH and a shell script for deployment.
That simplicity is the point. A solo operator building a SaaS does not have the luxury of complexity. Every moving part is a part that can break at 2 AM with no one else to call. The system we have built is not sophisticated -- it is simple, transparent, and entirely within one person's ability to understand, debug, and maintain.
Whether you followed along post by post or jumped straight to the topics that interested you, I hope this series demonstrated that building a production SaaS with Clojure and Datomic is not only feasible but genuinely enjoyable. The tools are mature. The ecosystem is stable. And the language rewards you with a codebase that stays manageable as it grows.
Now go build something.