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.