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>"})
;; Create the hot reload middleware
(let [{:keys [handler start! stop!]} (hot/wrap-hot-reload my-handler)]
;; `handler` is your new Ring handler — use it with your server adapter
;; Start watching for file changes
(def watcher-handle (start!))
;; ... later, to stop:
;; (stop! 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/")))})
wrap-hot-reload returns a map:
| Key | Description |
|---|---|
:handler | The Ring handler to pass to your server adapter |
:watcher | The Watcher instance (for advanced use) |
:start! | (fn []) — starts watching; returns a handle |
:stop! | (fn [handle]) — stops watching, cleans up resources |
:notify! | (fn []) — manually trigger a reload |
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 watcher-handle (atom nil))
(defn start! []
(let [{:keys [handler start! stop!]} (hot/wrap-hot-reload #'my-handler)]
(reset! server (jetty/run-jetty handler {:port 3000 :join? false}))
(reset! watcher-handle (start!))))
(defn stop! []
(when-let [h @watcher-handle]
;; stop! from the wrap-hot-reload return map
;; you'll need to capture it; see note below
)
(when-let [s @server]
(.stop s)))
Tip: Capture the
stop!function from the return map alongside the watcher handle so you can call it during shutdown.
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 [ring.hot-reload.nrepl/wrap-hot-reload-nrepl]}
{:aliases
{:dev {:extra-deps {nrepl/nrepl {:mvn/version "1.3.0"}}
:main-opts ["-m" "nrepl.cmdline"
"--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()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 |