100% Free Forever
AI-Powered Learning
Industry Expert Content
Certificates & Badges
Learn At Your Own Pace
Programming

Building a Web App with Ring

How to build a Clojure web application on Ring: the handler/middleware contract, request and response maps, routing with Reitit or Compojure, and running a Jetty server.

PracticeIntermediate11 min readJul 10, 2026
Analogies

The Ring Specification

Ring is the foundational Clojure web spec, analogous to Python's WSGI or Ruby's Rack, that defines a simple contract: an HTTP request becomes a plain Clojure map, and a handler is just a function that takes that map and returns a plain Clojure map describing the response. Because everything is data, you can compose, test, and debug web apps with the same ordinary tools you'd use on any Clojure data structure, with no framework-specific request or response classes required.

🏏

Cricket analogy: Ring's request-as-a-map contract is like the ICC's standardized scorecard format that every ground's scoring software must output identically, so any analysis tool can read it without caring which stadium generated it.

Handlers, Requests, and Responses

A Ring request map includes keys like :request-method (:get, :post, and so on), :uri, :headers, :query-string, and :body (an InputStream), and a minimal handler simply reads those keys and returns a response map with :status, :headers, and :body. Because a handler is "just a function," you can unit test it directly by calling it with a hand-built request map and asserting on the returned map, with zero HTTP server involved.

🏏

Cricket analogy: A Ring handler pattern-matching on :request-method is like an umpire reading just the delivery type off a simple signal sheet and responding with one ruling, testable by handing the umpire a hypothetical delivery card with no actual match being played.

clojure
(defn handler
  [request]
  (if (= (:request-method request) :get)
    {:status  200
     :headers {"Content-Type" "text/plain"}
     :body    "Hello, Ring!"}
    {:status  405
     :headers {}
     :body    "Method not allowed"}))

;; Pure function -- testable with no server running:
(handler {:request-method :get :uri "/"})
;; => {:status 200, :headers {"Content-Type" "text/plain"}, :body "Hello, Ring!"}

Middleware

Middleware in Ring is just a higher-order function that wraps a handler and returns a new handler, letting you layer cross-cutting concerns like wrap-json-response, wrap-params, or wrap-session around your core logic without touching it. Because middleware composition is plain function composition, you control ordering explicitly — wrapping wrap-params around wrap-keyword-params only makes sense if params are parsed before you try to keywordize their keys.

🏏

Cricket analogy: Middleware wrapping a handler is like a fielding-restriction rule during the powerplay wrapping the base game rules — it doesn't rewrite how batting works, it just adds a constraint layer around the core handler for a defined period.

The order middleware is applied in code is the reverse of the order requests actually flow through at runtime. If you write (-> handler wrap-a wrap-b), a request hits wrap-b first, then wrap-a, then the handler, because wrap-a is the outermost wrapping function applied last.

Routing with Reitit or Compojure

Raw Ring only gives you a single handler function, so real applications add a routing library — Compojure's (GET "/users/:id" [id] ...) macros, or the newer, data-driven Reitit, which defines routes as plain vectors of [path handler-or-opts] and supports both Ring and pluggable coercion and swagger/OpenAPI generation out of the box. Reitit's data-oriented routes are easier to introspect and generate API documentation from than Compojure's macro-based routes, which is why most new Ring projects since the late 2010s default to Reitit.

🏏

Cricket analogy: Reitit's data-driven routes, plain vectors, are like a fixture list stored as a structured spreadsheet any analysis tool can query, while Compojure's macro-based routes are like fixtures announced only via a commentator's spoken macro, harder to introspect programmatically.

clojure
;; Reitit: routes as plain data
(require '[reitit.ring :as ring])

(def app
  (ring/ring-handler
    (ring/router
      [["/" {:get (fn [_] {:status 200 :body "home"})}]
       ["/users/:id" {:get (fn [{:keys [path-params]}]
                              {:status 200 :body (str "user " (:id path-params))})}]])))

;; Compojure: routes as macros
(require '[compojure.core :refer [defroutes GET]])

(defroutes app-routes
  (GET "/" [] "home")
  (GET "/users/:id" [id] (str "user " id)))

Running a Server with Jetty

Ring ships an adapter-agnostic design; the most common production adapter is ring-jetty-adapter's run-jetty function, which takes your handler, ideally passed as a Var via #'app so code reloads without restarting the server, and an options map like {:port 3000 :join? false}. Wrapping your top-level handler with wrap-reload during development lets route changes take effect without restarting the JVM, which is one of the biggest REPL-driven-development wins Ring users rely on daily.

🏏

Cricket analogy: Passing a handler Var (#'app) to run-jetty is like a scoreboard operator reading live from the official scoring feed rather than a printed snapshot — the Var always reflects the latest code, just as the live feed always reflects the latest ball bowled.

Passing the handler function's value directly to run-jetty, instead of referencing it via a Var with #'app, means Jetty captures a snapshot of the handler at startup — redefining your handler at the REPL afterward won't be picked up until you restart the server.

  • Ring defines requests and responses as plain Clojure maps, and a handler is just a function from request map to response map.
  • Core request keys include :request-method, :uri, :headers, :query-string, and :body; core response keys are :status, :headers, and :body.
  • Middleware is a higher-order function wrapping a handler; application order in code is the reverse of runtime request flow.
  • Reitit's data-driven routes (plain vectors) are easier to introspect and generate docs from than Compojure's macro-based routes.
  • ring-jetty-adapter's run-jetty is the standard way to serve a Ring app in production, typically with {:port N :join? false}.
  • Always pass the handler as a Var (#'app) to run-jetty so REPL redefinitions take effect without restarting the server.
  • wrap-reload is the key development middleware that lets route/handler changes apply live during REPL-driven development.

Practice what you learned

Was this page helpful?

Topics covered

#Programming#ClojureStudyNotes#BuildingAWebAppWithRing#Building#Web#App#Ring#WebDevelopment#StudyNotes#SkillVeris