A Ring middleware that provides hot reload for server-rendered Clojure applications. When server-side code changes (file save or REPL eval), the browser automatically re-fetches the page and morphs the DOM in place using idiomorph, preserving scroll position, focus, and form state.
Add to your deps.edn:
{:deps {com.github.brettatoms/ring-hot-reload {:mvn/version "RELEASE"}}}
Note: This library requires Ring 1.12+ for WebSocket support (
ring.websocket).
(require '[ring.hot-reload.core :as hot])
(defn my-handler [request]
{:status 200
:headers {"Content-Type" "text/html"}
:body "<html><body><h1>Hello</h1></body></html>"})
;; 1. Create a reloader
(def hr (hot/hot-reloader {:watch-paths ["src"]}))
;; 2. Wrap your handler with the middleware
(def app (hot/wrap-hot-reload my-handler hr))
;; 3. Start watching for file changes
(def watcher-handle (hot/start! hr))
;; ... later, to stop:
;; (hot/stop! hr watcher-handle)
The middleware:
/__hot-reload and handles WebSocket upgradessrc/ for file changes and notifies connected browserswrap-hot-reload accepts an options map:
| Option | Default | Description |
|---|---|---|
:watch-paths | ["src"] | Directories to watch for file changes |
:watch-extensions | #{".clj" ".cljc" ".edn" ".html" ".css"} | File extensions that trigger reload |
:uri-prefix | "/__hot-reload" | Path for the WebSocket endpoint |
:inject? | (constantly true) | Predicate (fn [request response]) controlling script injection |
:debounce-ms | 100 | Debounce window in milliseconds |
By default only "src" is watched. If your templates or other files live
elsewhere, add those directories. The handler must read files on each request
(not cached at startup) for changes to be reflected:
;; Watch both source code and template files
(hot/wrap-hot-reload handler {:watch-paths ["src" "resources/templates"]})
;; Handler reads the template on each request — changes are picked up
(defn my-handler [_request]
{:status 200
:headers {"Content-Type" "text/html"}
:body (slurp (io/resource "templates/page.html"))})
Override which file types trigger a reload:
(hot/wrap-hot-reload handler {:watch-extensions #{".clj" ".cljc" ".mustache"}})
Skip script injection for specific routes (e.g. API endpoints that happen to return HTML):
(hot/wrap-hot-reload handler
{:inject? (fn [request _response]
(not (str/starts-with? (:uri request) "/api/")))})
hot-reloaderCreates a reloader — a map of composable pieces. Does not start watching.
(def hr (hot/hot-reloader opts))
The reloader map contains:
| Key | Description |
|---|---|
:ws-handler | Ring handler for the WebSocket endpoint |
:injection-middleware | Ring middleware (fn [handler] -> handler) that injects the client script |
:script | JavaScript string containing the client code |
:uri-prefix | The WebSocket endpoint path |
wrap-hot-reloadStandard Ring middleware. Takes a handler and a reloader, returns a handler.
(def app (hot/wrap-hot-reload my-handler hr))
start! / stop!Start and stop the file watcher.
(def handle (hot/start! hr))
;; ... later:
(hot/stop! hr handle)
The caller is responsible for starting and stopping the watcher. A typical pattern with a Ring adapter:
(require '[ring.adapter.jetty :as jetty]
'[ring.hot-reload.core :as hot])
(defonce server (atom nil))
(defonce hr (hot/hot-reloader {:watch-paths ["src"]}))
(defonce watcher-handle (atom nil))
(defn start! []
(let [app (hot/wrap-hot-reload #'my-handler hr)]
(reset! server (jetty/run-jetty app {:port 3000 :join? false}))
(reset! watcher-handle (hot/start! hr))))
(defn stop! []
(when-let [h @watcher-handle]
(hot/stop! hr h))
(when-let [s @server]
(.stop s)))
The optional nREPL middleware triggers a hot reload whenever an eval operation
completes in your REPL. This means evaluating a form in your editor refreshes
the browser — even without saving the file.
The middleware must be configured at nREPL startup (it cannot be added dynamically). There are several ways to set it up:
{:middleware ^:concat [ring.hot-reload.nrepl/wrap-hot-reload-nrepl]}
{:aliases {:dev {:extra-deps {nrepl/nrepl {:mvn/version "1.3.0"}}
:main-opts ["-m" "nrepl.cmdline"
;; WARNING: This will replace any existing middleware
"--middleware" "[ring.hot-reload.nrepl/wrap-hot-reload-nrepl]"]}}}
Add to your .dir-locals.el (appends to the existing CIDER middleware list):
((clojure-mode
(eval . (progn
(make-local-variable 'cider-jack-in-nrepl-middlewares)
(add-to-list 'cider-jack-in-nrepl-middlewares "ring.hot-reload.nrepl/wrap-hot-reload-nrepl")))))
Or add it globally to your Emacs config:
(add-to-list 'cider-jack-in-nrepl-middlewares "ring.hot-reload.nrepl/wrap-hot-reload-nrepl")
Note: Setting
cider-jack-in-nrepl-middlewaresdirectly in.dir-locals.elreplaces the default CIDER middleware. Use theeval/add-to-listform above to append instead.
The nREPL middleware communicates with the Ring middleware through a global notification mechanism. No additional configuration is needed — it works automatically when both middleware are loaded in the same JVM.
For Clojure source changes, the typical workflow is REPL-driven:
C-c C-c in CIDER)Saving the file triggers the browser re-fetch, but the code must be evaluated in the REPL first for changes to take effect. The file watcher doesn't re-evaluate Clojure files — it only signals the browser.
For HTML templates, CSS, and other files read from disk at request time
(e.g. via slurp), saving the file is sufficient — no REPL eval needed:
Save file → watcher detects change → debounce (100ms) → notify browser
→ browser fetches page → server reads updated file → idiomorph morphs DOM
fetch()For applications using a router, you can use the reloader's pieces directly
instead of wrap-hot-reload:
(require '[ring.hot-reload.core :as hot]
'[reitit.ring :as ring])
(def hr (hot/hot-reloader {:watch-paths ["src" "resources/templates"]
:uri-prefix "/__hot-reload"}))
(def app
(ring/ring-handler
(ring/router
[[(:uri-prefix hr) {:get (:ws-handler hr)
:no-doc true}]
["" {:middleware [(:injection-middleware hr)]}
["/" {:get home-handler}]
["/about" {:get about-handler}]]])))
(def watcher-handle (hot/start! hr))
;; ... (hot/stop! hr watcher-handle) to shut down
This approach avoids wrapping the entire handler, so the WebSocket endpoint participates in your router's middleware stack naturally.
ring-hot-reload works alongside a Vite dev server. Use wrap-hot-reload for
Clojure/template changes (full page morph), and Vite's @vite/client for
CSS/JS HMR (instant, partial updates). The two mechanisms are independent —
no Vite plugin required.
For automatic Vite integration (dev server lifecycle, asset URL resolution,
@vite/client injection), see zodiac-assets.
This library uses Ring's standard ring.websocket protocol (introduced in Ring 1.12)
for the WebSocket endpoint. Your server adapter must support this protocol.
Compatible adapters:
ring/ring-jetty-adapter 1.12+info.sunng/ring-jetty9-adapter 0.35+http-kit 2.8+The WebSocket endpoint uses a dedicated path (/__hot-reload by default) and does
not conflict with other WebSocket endpoints in your application. If your app has its
own WebSocket routes, they work independently — Ring's WebSocket model is per-request,
so each path can return its own ::ws/listener response.
Watcher protocol so the implementation can
be swapped if needed.Copyright © 2025 Brett Adams
Distributed under the MIT License. See LICENSE for details.
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 |