A reactive server-rendered web framework for Clojure built on Datastar and Reitit.
Hyper renders your pages as hiccup on the server using Chassis, then keeps them alive over SSE — when state changes, the server re-renders and patches the DOM automatically. No client-side framework, no JSON APIs, no JavaScript to write.
(require '[hyper.core :as h])
(defn home-page [req]
(let [count* (h/tab-cursor :count 0)]
[:div
[:h1 "Count: " @count*]
[:button {:data-on:click (h/action (swap! (h/tab-cursor :count) inc))}
"Increment"]]))
(def routes
[["/" {:name :home
:title "Home"
:get #'home-page}]])
(def handler (h/create-handler #'routes))
(def app (h/start! handler {:port 3000}))Hyper wouldn't exist without the generosity of the Clojure community. We're grateful to the people whose work and ideas made this possible:
- Anders Murphy's essay Realtime Collaborative Web Apps Without ClojureScript laid the groundwork — demonstrating that server-rendered Clojure + Datastar + SSE is a viable architecture for reactive web apps.
- David Yang and David Nolen at Lightweight Labs, whose talk From Tomorrow Back to Yesterday at Clojure/conj 2025 shaped our thinking on server-driven UI and the direction of web development in Clojure.
Hyper is in active alpha development and used in internal projects at Dynamic Alpha. The API is evolving rapidly — expect bugs and breakage until a 1.0 release.
We're building in the open to share with the Clojure community. Feedback and contributions are welcome.
We eventually intend to publish to Clojars, however while we are rapidly evolving the project we recommend to install via a :git/url instead. Make sure to grab the latest SHA.
{dynamic-alpha/hyper {:git/url "https://github.com/dynamic-alpha/hyper"
:git/sha "..."}}Hyper uses virtual threads for its per-tab rendering loop — each connected browser tab gets its own lightweight virtual thread that blocks on a semaphore until state changes trigger a re-render. This means you need JDK 21 or later.
Virtual threads were finalized in JDK 21 (JEP 444) and are available without
any flags. On JDK 19 or 20 they are a preview feature and require the
--enable-preview flag, but we recommend just using JDK 21+.
Cursors are the primary way to read and write state in Hyper. They behave just
like atoms — use deref, reset!, swap!, and add-watch as you would with a
normal atom.
Each cursor type scopes state differently:
(h/global-cursor :theme "light") ;; shared across everything
(h/session-cursor :user) ;; scoped to browser session
(h/tab-cursor :count 0) ;; scoped to a single tab
(h/path-cursor :page 1) ;; backed by URL query paramsThe first argument is a key — either a keyword for flat access, or a vector for
nested access. global-cursor, session-cursor, and tab-cursor all support
this:
(h/tab-cursor :count 0) ;; flat — state[:count]
(h/tab-cursor [:form :email] "") ;; nested — state[:form][:email]
(h/session-cursor [:user :name]) ;; nested — session[:user][:name]The optional second argument sets a default value when the key is nil.
| Cursor | Shared across tabs? | Shared across sessions? | Survives page reload? |
|---|---|---|---|
global-cursor |
✅ | ✅ | ✅ (global, in-memory) |
session-cursor |
✅ | No | ✅ (session length) |
tab-cursor |
No | No | No (in-memory) |
path-cursor |
No | No | ✅ (URL query params) |
Mutating any cursor triggers a re-render for every tab that depends on that scope — global changes re-render all tabs, session changes re-render tabs in that session, and so on.
Actions are server-side functions triggered by user interactions. The action
macro captures the current session and tab context at render time, registers a
handler on the server, and returns a Datastar expression string that can be
bound to any event attribute.
(defn counter [req]
(let [count* (h/tab-cursor :count 0)]
[:div
[:p "Count: " @count*]
[:button {:data-on:click (h/action (swap! (h/tab-cursor :count) inc))} "+1"]
[:button {:data-on:click (h/action (swap! (h/tab-cursor :count) dec))} "-1"]]))When the button is clicked, Datastar POSTs to the server, Hyper executes the action body, the cursor mutation triggers the watcher, and the tab re-renders over SSE — all in one round trip with no page reload.
Actions have full access to the request context, so you can use any cursor type inside them:
;; Toggle a global theme that affects all tabs and sessions
[:button {:data-on:click (h/action
(let [theme* (h/global-cursor :theme "light")]
(swap! theme* #(if (= % "light") "dark" "light"))))}
"Toggle theme"]
;; Update session state shared across tabs
[:button {:data-on:click (h/action
(reset! (h/session-cursor :user) {:name "Alice"}))}
"Log in"]Actions are scoped to the tab that rendered them and are cleaned up automatically when the tab disconnects. The body can contain arbitrary Clojure — call functions, hit databases, update multiple cursors — whatever happens, the resulting state changes trigger re-renders for the appropriate tabs.
Actions can capture client-side DOM values and transmit them to the server using special $ symbols:
| Symbol | Captures | Use case |
|---|---|---|
$value |
evt.target.value |
Input/select/textarea value |
$checked |
evt.target.checked |
Checkbox/radio boolean state |
$key |
evt.key |
Keyboard event key name |
$form-data |
All named form fields | Form submission as a map |
Example usage:
;; Capture input value on change
[:input {:data-on:change (h/action (reset! (h/tab-cursor :query) $value))}]
;; React to specific keys
[:input {:data-on:keydown
(h/action (when (= $key "Enter")
(search! $value)))}]
;; Checkbox toggle
[:input {:type "checkbox"
:data-on:change (h/action (reset! (h/tab-cursor :dark?) $checked))}]
;; Full form submission
[:form {:data-on:submit__prevent (h/action (save-user! $form-data))}
[:input {:name "email"}]
[:input {:name "password" :type "password"}]
[:button "Save"]]When $ symbols appear in the action body, the macro automatically generates a fetch() call instead of @post(), sending the extracted values as a JSON body. On the server, the action function receives these values bound to the corresponding $ symbols.
Hyper uses Reitit for routing. Routes are
plain vectors with :name, :get, and optional metadata like :title:
(def routes
[["/" {:name :home
:title "Home"
:get #'home-page}]
["/about" {:name :about
:title "About"
:get #'about-page}]
["/user/:id" {:name :user
:title (fn [req] (str "User " (get-in req [:hyper/route :path-params :id])))
:get #'user-page}]])Use navigate to create SPA links. It returns attributes for an <a> tag —
click navigates via Datastar + pushState, right-click / cmd-click opens in a new
tab via the :href:
[:a (h/navigate :home) "Home"]
[:a (h/navigate :user {:id "42"}) "View User"]
[:a (h/navigate :search {} {:q "clojure"}) "Search"]The :title metadata is included in the browser history entry so that
back/forward navigation shows meaningful titles. Titles can be static strings,
functions of the request, or deref-able values like cursors.
Pass routes as a Var (#'routes) to create-handler for live-reloading during
development — route changes are picked up on the next request without restarting
the server and any connected tabs will automatically re-render.
Every request passed to your render function includes :hyper/route — a map
with the current route's name, path, and parameters:
{:name :user
:path "/user/42"
:path-params {:id "42"}
:query-params {:tab "posts"}}This works identically on the initial page load and on every SSE re-render after SPA navigation, so it's safe to use anywhere — including shared components like navbars and breadcrumbs:
(defn navbar [req]
(let [current (get-in req [:hyper/route :name])]
[:nav
[:a (merge (h/navigate :home)
(when (= :home current) {:class "active"}))
"Home"]
[:a (merge (h/navigate :about)
(when (= :about current) {:class "active"}))
"About"]]))
(defn home-page [req]
[:div
(navbar req)
[:h1 "Home"]])You can also read it from context/*request* inside actions or anywhere within
the request context — the value is always consistent with the tab's current
route.
If a route handler returns a Ring response map (a map with :status) instead of
hiccup, Hyper passes it through as-is without wrapping it in HTML. This gives
you an escape hatch for redirects, error responses, or anything else that
doesn't fit the render-and-stream model:
(defn admin-page [req]
(if-not (admin? req)
{:status 302 :headers {"Location" "/login"} :body ""}
[:div "Secret admin stuff"]))This works for any status code or response shape — 301/302 redirects, 403 forbidden, JSON responses, etc.
You can suppress hyper wrapping an endpoint altogether by marking it as :hyper/disabled?
(def routes
[["/" {:name :home
:title "Home"
:get #'home-page}]
["/api/info" {:name :api-info
:hyper/disabled? true ;; disable hyper wrapping this endpoint
:get #'about-page}]])Under the hood, Hyper maintains a persistent SSE connection per tab. When state changes, the server re-renders your page function, diffs nothing — it sends the full HTML as a Datastar fragment, and Datastar morphs the DOM. Cursors changing state trigger this automatically, but for external sources you need to tell Hyper what to watch.
Call watch! from your render function to observe any external source. When it
changes, Hyper re-renders and pushes an update to the client:
(def db-results* (atom []))
(defn dashboard [req]
(h/watch! db-results*)
[:div
[:h1 "Results"]
[:ul (for [r @db-results*]
[:li (:name r)])]])watch! is idempotent — safe to call on every render. Watches are automatically
cleaned up when the tab disconnects.
By default, watch! works with anything that implements clojure.lang.IRef
(atoms, refs, agents, vars). For custom external sources, extend
hyper.protocols/Watchable:
(require '[hyper.protocols :as proto])
(extend-protocol proto/Watchable
my.db/QueryResult
(-add-watch [this key callback]
;; callback is (fn [old-val new-val])
;; Set up your change listener, call callback when data changes
)
(-remove-watch [this key]
;; Tear down the listener
))For sources that are tied to a specific page, declare them directly on the route
with :watches. Hyper sets them up when a tab navigates to that route and tears
them down when it navigates away:
(def live-orders* (atom []))
(def routes
[["/" {:name :dashboard
:title "Dashboard"
:get #'dashboard-page
:watches [live-orders*]}]])When the :get handler is a Var (e.g. #'dashboard-page), it's automatically
added to the route's watches. This means redefining the function at the REPL
triggers an instant live reload for all connected tabs — no page refresh needed.
For sources that should trigger a re-render on every page, pass :watches
to create-handler. These are added to all page routes automatically — useful
for things like a top-level config atom or feature-flags that affect every view:
(def feature-flags* (atom {:new-ui? false}))
(def handler
(h/create-handler
#'routes
:watches [feature-flags*]))Global watches are combined with any per-route :watches — global sources come
first, then route-specific ones.
Hyper doesn’t ship with an asset pipeline (Tailwind, Vite, etc.), but it does provide a couple small hooks so apps can easily:
- serve precompiled static assets (CSS/JS/images)
- inject tags into the HTML
<head>(stylesheets, scripts, meta tags)
Enable static serving when you create your handler:
(def handler
(h/create-handler
#'routes
:static-resources "public"))Put files under resources/public/ and they’ll be available by URL:
resources/public/app.css→GET /app.cssresources/public/favicon.ico→GET /favicon.ico
For filesystem-based serving (useful in dev):
(def handler
(h/create-handler
#'routes
:static-dir "public"))You can also pass multiple directories (first match wins):
(def handler
(h/create-handler
#'routes
:static-dir ["public" "target/public"]))Pass :head as either hiccup, or a function (fn [req] ...) that returns hiccup.
When :head is a function, it is re-evaluated on every SSE render cycle and the
full <head> is pushed to the client. This means dynamic stylesheets, meta tags,
and the <title> are all kept in sync reactively.
(def handler
(h/create-handler
#'routes
:static-resources "public"
:head [[:link {:rel "stylesheet" :href "/app.css"}]
[:script {:defer true :src "/app.js"}] ]))This is typically how you’d include your compiled Tailwind stylesheet.
Hyper uses brotli4j to compress both
initial page responses and streaming SSE updates. The core brotli4j library is
included as a dependency, but the platform-specific native library is not —
you need to add the right one for your OS and architecture.
Add one of the following to your project's :deps:
| Platform | Dependency |
|---|---|
| macOS (Apple Silicon) | com.aayushatharva.brotli4j/native-osx-aarch64 {:mvn/version "1.18.0"} |
| macOS (Intel) | com.aayushatharva.brotli4j/native-osx-x86_64 {:mvn/version "1.18.0"} |
| Linux (x86_64) | com.aayushatharva.brotli4j/native-linux-x86_64 {:mvn/version "1.18.0"} |
| Linux (aarch64) | com.aayushatharva.brotli4j/native-linux-aarch64 {:mvn/version "1.18.0"} |
For example, on an Apple Silicon Mac:
;; deps.edn
{:deps {dynamic-alpha/hyper {:git/url "..."}
com.aayushatharva.brotli4j/native-osx-aarch64 {:mvn/version "1.18.0"}}}If you deploy to a different platform than you develop on (e.g. dev on macOS, deploy on Linux), add both native deps — the correct one is selected at runtime.
If the native library is missing, you'll see an error like
UnsatisfiedLinkError or Brotli4jLoader failure at startup.
Hyper ships with clj-kondo config. Import it with:
clj-kondo --copy-configs --dependencies --lint "$(clojure -Spath)"Tests are run with Kaocha via the
:test alias. There are two test suites: :unit for fast in-process tests and
:e2e for browser-based end-to-end tests.
# Run unit tests only
clojure -M:test --focus :unit
# Run E2E browser tests only
clojure -M:test --focus :e2e
# Run all tests
clojure -M:testUnit tests live in test/hyper/ and cover cursors, actions, navigation, routing,
rendering, state management, and brotli compression. They run in-process with no
server or browser — just bind *request* and exercise the API directly.
End-to-end tests use Playwright via the
wally library to drive a real headless
Chromium browser against a running Hyper server. They're tagged with ^:e2e
metadata so Kaocha can filter them.
The E2E suite covers:
- Cursor isolation — multiple browser contexts (separate sessions) and multiple tabs within a session verify that global, session, tab, and URL cursors propagate to exactly the right scope
- Title live reload — redefining the routes Var updates
document.titlevia SSE without a page refresh - Content live reload — redefining the routes Var with new inline handler functions hot-swaps the page content via SSE