A super-micro-framework for server-rendered Clojure web applications: the operational plumbing a project plugs in so it does not reassemble it every time. Server-rendered HTML with htmx 4 and Hiccup, routing with reitit, Jetty behind two functions.
SPEC.md says what it is and why every boundary sits where it sits. This file
says how to use it.
A library you call, never a framework that calls you. The host wires the modules explicitly. There is no plugin registry, no auto-discovery, no hook system, no ambient global state: configuration is passed in.
The membership test. Before adding anything: would a bicycle rental or a clinic's appointment book need this, unchanged? If not, it does not belong here.
;; deps.edn — from Clojars
dev.arkaitz/web-base {:mvn/version "0.4.0"}
;; or straight from git, to track a commit
dev.arkaitz/web-base {:git/url "https://github.com/arkaitz-dev/web-base"
:git/sha "<commit>"}
The badge at the top is the version actually published.
The library depends on metosin/reitit-ring, hiccup, ring-jetty-adapter,
tools.logging, tempura, ring-anti-forgery and integrant — and nothing
else reaches your classpath. Coercion is passed through: bring malli, spec or
schema yourself.
:wb/layouts, outermost first; nested routes
concatenate). Handlers return a Ring response whose :body is Hiccup —
response/ok builds it; the base renders it through the stack — the whole
stack for a navigation, nothing for an htmx swap, or exactly the height the
handler names with :wb/height.:wb/gate is a predicate over the
request. A refusal becomes a 303 for a navigation, an HX-Redirect for an
htmx swap (never a 302 htmx would follow into its target), or a 403 when a
subject is present.Secure/HttpOnly/SameSite=Lax by default,
and session/rotate for the moment someone logs in.:wb/locale and :wb/tr on
every request, <html lang> that never lies.nosniff, X-Frame-Options, Referrer-Policy, optional
HSTS, a per-request CSP nonce, CSRF through ring-anti-forgery, opt-in proxy
headers. Static assets answer before the session, and answer a conditional
GET with 304.start/stop functions; an optional Integrant
namespace for hosts that use it, and an optional one that lets the whole
thing compile to a native binary.What it does not do: authenticate, authorise, persist, or know your domain.
(ns my.app
(:require [dev.arkaitz.web-base :as wb]
[dev.arkaitz.web-base.response :as response]
[dev.arkaitz.web-base.session :as session]
[dev.arkaitz.web-base.shell :as shell]
[reitit.coercion.malli :as malli-coercion]))
(defn my-shell [{:keys [content request] :as slots}]
(shell/page (assoc slots
:title "My app"
:nav [:a {:href "/"} "Home"]
:identity (if-let [who (:wb/subject request)] (str "hi, " who) "anonymous")
:content content)))
(defn home [{:keys [wb/tr]}]
(response/ok [:p (tr :hi)]))
(defn private [request]
(response/ok [:p (str "Only you, " (:wb/subject request))] {:slots {:title "Private"}}))
(defn login [request]
;; the host authenticates however it likes; the base only learns a subject exists
(session/rotate (response/see-other "/private")
(assoc (:session request) :subject (get-in request [:form-params "name"]))))
(def app
(wb/handler
{:routes [["" {:wb/layouts [my-shell]}
["/" {:get home}]
["/login" {:post login}]
["/private" {:wb/gate wb/subject-present?
:get private}]]]
:coercion malli-coercion/coercion
:subject-fn #(get-in % [:session :subject])
:login-path "/login"
:session {:key (System/getenv "WB_SESSION_KEY")} ; base64 of 16 bytes
:static {:root "public"}
:i18n {:dict {:en {:hi "Hello"} :es {:hi "Hola"}} :default-locale :en}
:security {:csp "default-src 'self'; script-src 'nonce-{nonce}'"}}))
(def server (wb/start #'app {:port 3000})) ; => {:server … :port 3000}; (wb/stop server)
A method's value is the handler itself or a map with :handler and reitit's
:parameters.
The session travels in a cookie signed with a 16-byte key. Generate one once, in a REPL, and keep it in the environment or in the file the last section of this README describes:
(dev.arkaitz.web-base.session/generate-key) ; => "AAECAwQFBgcICQoLDA0ODw=="
The base refuses to invent one: a key generated at startup destroys every session on every deploy, silently, and differs per instance when there is more than one. Changing the key later has the same effect on purpose — it is how you invalidate everything at once.
Rotate the session on login. This one is yours to call. The mechanism belongs to the base and the moment belongs to you, because only your code knows when someone has just authenticated:
(session/rotate (response/see-other "/private")
(assoc (:session request) :subject who))
It is the defence against session fixation, and skipping it produces no symptom at all: everything works, and an attacker who planted a session id before the login still holds a valid one after it.
On http://localhost, opt out of Secure. The cookie carries
Secure/HttpOnly/SameSite=Lax by default, and a browser drops a Secure
cookie sent over plain HTTP without a word, so you get no session and no error:
:session {:key … :cookie-attrs {:secure false}} ; development only
The default is the safe one so that the opt-out is the thing you write, not the
thing you forget. And note what the cookie store cannot do: a cookie session
cannot be revoked from the server, it only expires. Pass your own :store — any
implementation of Ring's session store protocol — when you need to end a session
on demand.
| key | meaning |
|---|---|
:routes | reitit route data (required) |
:session | {:key base64-or-bytes} or {:store ring-session-store} (required); :cookie-attrs and :cookie-name (default ring-session) optional |
:subject-fn | request → subject or nil; default: always nil |
:login-path | where a refusal without a subject goes; required iff a route has :wb/gate |
:coercion | a reitit coercion, passed through |
:static | create-resource-handler options for the host's assets, e.g. {:root "public"} |
:error-layout | slot function used for error pages: receives :content, :request, :error |
:i18n | {:dict tempura-dict :default-locale k :locale-fn (fn [request] preferences)} |
:security | {:frame-options "DENY" :csp "…{nonce}…" :hsts {:max-age n} :proxy? bool} |
:csrf | false to disable the anti-forgery token; on for anything else |
:sessionless | {"/health" handler}: exact paths, any method, answered before the session — no session read or written, no CSRF, no locale, no subject; the request id and security headers still apply. A handler may be a var; a path one of :routes also matches is refused |
Every malformed or missing required value fails at construction, naming the key.
It is the product, so it is written down, outermost first:
request-id → security headers → proxy (opt-in) → [assets, sessionless] → session → params
→ i18n → csrf → subject → router: error → gate → render → coercion → handler
A route may add its own :middleware in route data; reitit merges it
innermost, inside the base's four, so it wraps the handler only. It sees
a handler's exception before error does; it does not see a layout, gate or
coercion failure, nor the default 404. :middleware ^:replace […] replaces the
base's four as well, silently. Three things a host may want there:
ring-devel's
wrap-stacktrace, or your own catcher; in production a 500 is logged with its
stack under the request id the response carries in X-Request-Id;ring.middleware.flash/wrap-flash — with a hazard: it consumes the flash on
any request that carries it, so an htmx fragment (a poll, a keyup in flight)
arriving between the 303 and the navigation eats the message. Consume it only
when (not (htmx/partial-request? request));ring.middleware.keyword-params/wrap-keyword-params — note Ring's merged
:params lets a query-string key shadow a form field; reitit's :parameters
give typed, keyword access without that.(response/ok body) is {:status 200 :body body}; (response/ok body {:slots m :height n}) adds :wb/slots and :wb/height. (response/see-other path) is
the 303 after a classic form. A 404 or 403 the handler decides is
(error/throw! {:status 404}): it reaches the error renderer, not the layouts.
:wb/height is rarely written. A navigation renders the whole stack and an htmx
swap renders none of it; name a height only for the case in between — a tab
swap that wants its section but not the shell. It counts from the innermost
layout, so an outer layout added later never invalidates a height already
written.
A layout is a function of one map of slots: :content, :request, plus whatever
the handler put under :wb/slots. Nesting is composition. shell/page is one
such layout, with slots :lang :title :head :header :nav :identity :content :footer; it links /wb/wb.css (structural CSS, every colour a --wb-* custom
property you redefine) and /wb/htmx.min.js, and puts the CSRF token in
hx-headers:inherited on <body> so every htmx request carries it. Classic forms
add (security/csrf-field request). A token is stored in the session only when a
request read it through csrf-field or csrf-token, so an anonymous request that
renders neither — a health probe, JSON, a redirect — creates no session; read it through
those two, while the handler runs, and never from :anti-forgery-token directly.
Two roads, and they do not meet. Route :parameters with a coercion refuse an
invalid request before the handler, as a 400 through the error renderer
with the humanized explanation under :wb/coercion for your error layout. A
form that must come back re-rendered with its errors is validated in the
handler — with malli:
(defn signup [request]
(let [values (select-keys (:form-params request) ["name" "email"])
parsed (m/decode Signup values (mt/string-transformer))]
(if-let [explanation (m/explain Signup parsed)]
(wb/rerender request "/signup" {:values values :errors (me/humanize explanation)})
(response/see-other "/welcome"))))
wb/rerender renders the page's own GET again — its gate, layouts and CSRF token
included — with {:values … :errors …} under :wb/form on the request and status 422,
so the GET handler that draws the form draws it refused too, and no redirect loses what
was typed. Give the form the same path for GET and POST and a reload does the sensible
thing. A form that swaps only itself with htmx answers its own fragment with
response/unprocessable instead, because the page's GET would render the whole page's
content into the form's target. Both rely on htmx 4 swapping a 422; under htmx 2 a host
would have to allow it in htmx.config.responseHandling.
:wb/tr takes a resource id, (tr :nav/home), an id with arguments,
(tr :greet ["Ann"]), or Tempura's vector of ids with fallbacks,
(tr [:nav/home :nav/default]). An id the dictionary lacks answers nil —
no exception, no placeholder — so a missing translation shows as an empty
element.
dev.arkaitz.web-base.testing reads the shapes the base emits, so a host's
tests need no regex of their own. With ring-mock:
(let [page (app (mock/request :get "/login"))
token (testing/csrf-token page) ; hidden field or the shell's body attribute
login (app (-> (mock/request :post "/login" {"name" "ada" "__anti-forgery-token" token})
(testing/with-cookies page))) ; the session cookie the page minted
swap (app (-> (mock/request :get "/private") (testing/with-cookies login) testing/fragment))]
…)
(testing/cookies response) is the map behind with-cookies: every cookie a
response set, with a deletion — Max-Age zero or less — as nil. with-cookies
keeps the request's own jar, so chaining it across a flow does what a browser
does.
For a whole flow, testing/browser is that chain as a value:
(let [b (-> (testing/browser app)
(testing/visit :get "/login") ; a page with a CSRF token
(testing/visit :post "/login" {"name" "ada"}))] ; the token rides along
(:path b) ; "/me" — where the 303 landed
(:response b)) ; the page it landed on
visit answers a new browser: the jar accumulated (a deletion forgets), the token of
the last page that had one, 301/302/303 followed as a GET up to ten times, and :path
— the last request's path and query string, which is the address bar. A POST with no
token known throws instead of sending; {:htmx? true} sends the token as the header
with the headers of a swap, and leaves an HX-Redirect for the test to read.
The base's htmx coupling lives in three named places: the request classifier
(HX-Request and HX-Request-Type), the redirect (HX-Redirect) and the error
renderer. htmx 4 swaps every response, 4xx and 5xx included, so an error inside
a swap lands inside its target. hx-on, hx-vals js: and trigger filters need
unsafe-eval; under a strict CSP, do without them — the demo does.
(def server (wb/start #'app {:port 3000})) ; => {:server <jetty> :port 3000}
(wb/stop server)
start takes the handler and ring-jetty-adapter's options. :port is required,
and 0 asks the operating system for a free one — which is why the handle
carries the port back: with :port 0 there is no other way to learn it without
reaching into Jetty. :join? is forced to false whatever you pass, because a
start that blocks its caller forever is a hang, not a server; keep the process
alive yourself, and stop the server on the way out:
(.addShutdownHook (Runtime/getRuntime) (Thread. #(wb/stop server)))
stop takes the handle start returned, not the Jetty object inside it. Passing
the var #'app rather than app lets a REPL redefine the handler without
restarting Jetty.
A web-base application compiles to a GraalVM native image. Measured on one machine, the same application both ways:
| startup | resident | |
|---|---|---|
| native binary | 41 ms | 25 MB |
| JVM | 625 ms | 220 MB |
The very first run of a freshly built binary takes about a second on macOS, which is the system verifying it, not the program starting: run it twice.
Require dev.arkaitz.web-base.native from your main namespace — like the
Integrant one, requiring it is the opt-in — put
com.github.clj-easy/graal-build-time on the classpath, AOT-compile your main
namespace, and then:
native-image -cp $(clojure -Spath) \
--features=clj_easy.graal_build_time.InitClojureClasses \
--initialize-at-build-time=org.slf4j \
-H:+UnlockExperimentalVMOptions \
-H:IncludeResourceBundles=jakarta.servlet.LocalStrings \
-H:IncludeResources='dev/arkaitz/web_base/public/[^/]+\.[a-z]+$' \
-H:IncludeResources='public/.*[^/]\.[a-z]+$' \
-o target/app my.app
Why each one, because a flag nobody can explain is a flag nobody can remove:
graal-build-time registers Clojure's namespaces for build-time
initialisation, which every Clojure image needs.tools.logging.jakarta.servlet.LocalStrings when it writes a
response; without the bundle every request answers 500.:static root.Match files, not directories, as those patterns do. A directory registered
as a resource is served as a listing of its names: measured, GET /css on a
host root containing css/site.css answered 200 with site.css where the
jar answers 404. The library refuses the spellings it can recognise — /wb/
and /css/ — and an include pattern that ends in an extension closes the rest.
These are GraalVM's flags, not the library's, and they move between releases; this set was observed working with GraalVM CE 25. Anything else your application brings — a database driver, a JSON library — is yours to configure, and you do not have to guess: run it once on a JVM under the tracing agent and it writes the configuration by watching.
java -agentlib:native-image-agent=config-output-dir=native-config -cp $(clojure -Spath) my.app
Exercise the application, stop it, and pass that directory to native-image
with -H:ConfigurationFileDirectories=native-config.
On macOS a Homebrew GraalVM is deliberately not linked, so the java on your
PATH stays the one you had. Use it one command at a time with
JAVA_HOME=/opt/homebrew/opt/graalvm, or call native-image by its full path.
{:my/web-config {…} ; your component building the map above
:dev.arkaitz.web-base/handler #ig/ref :my/web-config
:dev.arkaitz.web-base/server {:handler #ig/ref :dev.arkaitz.web-base/handler :port 3000}}
dev.arkaitz.web-base.integrant/read-string reads #ig/ref and #wb/env "VAR"
in the same EDN; requiring that namespace is what adds the methods. Its
two-argument form takes readers of your own, which is what the next section
uses.
#wb/env "VAR" resolves while the config is read, and has no default form:
a default is how a development key reaches production. On a development
machine, name the variables in an EDN file instead and hand the readers in:
;; env.local.edn — never committed
{"WB_SESSION_KEY" "…base64 of 16 bytes…"}
(wbi/read-string (config/env-file-readers "env.local.edn") ; or config/read-string, read-resource, read-file
(slurp (io/resource "config.edn")))
Both sides of the map are strings, and anything else is refused when the readers are built, naming the file. Then:
readers.dev.arkaitz.web-base.config, naming the variable and the file's absolute
path, so a file left next to a deployment is visible and findable. Silencing
that logger removes the only signal there is.#wb/env inside the file still means the environment.Keep the file out of the repository. *.local.edn in .gitignore is the
pattern this project uses, and the demo's test asks git itself rather than
trusting the pattern.
demo/ is the acceptance test: a small application using nothing but web-base —
tasks, live search, a validated signup, a login of its own, a gated page, a
deliberate error, two languages, a strict CSP.
WB_SESSION_KEY=<base64 of 16 bytes> clojure -M:demo [port]
clojure -M:dev # REPL: (go) (reset) (halt)
Or write the key once into env.local.edn in the directory you start from,
and drop the variable:
echo '{"WB_SESSION_KEY" "<base64 of 16 bytes>"}' > env.local.edn
clojure -M:demo [port]
clojure -T:build demo-uber && java -jar target/web-base-demo-0.4.0.jar [port]
clojure -M:test # the whole suite, demo included
clojure -T:build jar # target/web-base-0.4.0.jar
clojure -T:build install # into ~/.m2
CLOJARS_USERNAME=… CLOJARS_PASSWORD=<deploy token> clojure -T:build deploy
Every test was written against a contract and watched go red by named mutations;
the security-adjacent ones went through an adversarial review panel. The rules
are in CLAUDE.md.
MIT. See LICENSE.
Can you improve this documentation?Edit on GitHub
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |