- Clojure 97.3%
- HTML 1.7%
- CSS 1%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| dev | ||
| public | ||
| src | ||
| .dir-locals.el | ||
| .gitignore | ||
| deps.edn | ||
| package-lock.json | ||
| package.json | ||
| README.md | ||
| shadow-cljs.edn | ||
ClojureScript app template
A small starting point: shadow-cljs, Reagent, Tailwind CSS v4, Emacs / CIDER, plus a Clojure HTTP API and SQLite.
Daily development is jack-in from Emacs. CIDER starts shadow-cljs, which watches the :app build, serves the page on port 8080, rebuilds Tailwind, hot-reloads JS and CSS, and proxies /api to the Ring server on port 3000.
This is a learning skeleton, not a batteries-included framework. Copy it, rename it, grow it.
Prerequisites
- Java 21+ (LTS recommended)
- Clojure CLI (
clojureonPATH) - Node.js (current LTS is fine) and npm
- Emacs with CIDER (this repo was developed against CIDER 1.x on Emacs 31)
Check:
java -version
clojure --version
node --version
Start a new project from this template
-
Copy the repo (or use GitHub’s “Use this template”).
-
Rename
my-appif you want a real project name:- directory
src/my_app/→src/your_app/ - namespaces
my-app.*insrc/your_app/ :init-fn/:after-load/:repl-init-nsinshadow-cljs.edn
- directory
-
Update
package.json"name"andpublic/index.html<title>. -
From the project root:
npm install -
Open
src/my_app/core.cljsin Emacs. If Emacs asks about risky local variables from.dir-locals.el, accept them (!to remember). -
M-x cider-jack-in-clj&cljsis the daily command (both REPLs).cider-jack-in-cljsstill starts the same nREPL if you only want the browser REPL. -
If CIDER asks shadow-cljs vs clojure-cli, choose shadow-cljs.
.dir-locals.elis supposed to skip that question; if it still asks,M-x revert-bufferoncore.cljsso dir-locals load. -
Open http://localhost:8080. The ClojureScript REPL is live only after that tab is open. Until then you will see “no connected JS runtime”.
The nREPL starts in the user namespace and calls (user/start), so the API should already be up. If /api fails, eval (user/start) in the Clojure REPL.
Quit with M-x cider-quit.
Terminal instead of jack-in
npx shadow-cljs watch app
Then in Emacs: M-x cider-connect-cljs to localhost:7888, REPL type shadow, build app. If the API did not start (no CIDER connection to load user), from another terminal:
npx shadow-cljs clj-eval "(user/start)"
Never jack-in and run npx shadow-cljs watch at the same time. They fight over ports 8080 and 7888. Same for npm run css:watch: shadow already starts Tailwind.
| How you start | Terminal | Emacs |
|---|---|---|
| Jack-in (preferred) | nothing | cider-jack-in-clj&cljs |
| Connect | npx shadow-cljs watch app |
cider-connect-cljs → localhost:7888 |
Layout
deps.edn JVM libraries
shadow-cljs.edn builds, nREPL, :dev-http + /api proxy, Tailwind hooks
package.json npm: shadow-cljs CLI, React, Tailwind
.dir-locals.el CIDER: prefer shadow-cljs, REPL type shadow, build app
dev/user.clj (start) / (stop) / (restart) — not Integrant
src/my_app/core.cljs UI
src/my_app/server.clj http-kit + Reitit
src/my_app/api.clj /api routes
src/my_app/db.clj next.jdbc + SQLite
src/css/app.css Tailwind entry (v4 is CSS-first; no tailwind.config.js)
src/hooks/tailwind.clj
src/hooks/dev_proxy.clj /api proxy + `/` → index.html
public/index.html served at http://localhost:8080
data/app.sqlite created at runtime (gitignored)
Generated (gitignored): node_modules/, .shadow-cljs/, .cpcache/, public/js/, public/css/, data/*.sqlite.
How the browser talks to Clojure
In development you open one origin: http://localhost:8080.
/js/*,/css/*,/index.html→ files inpublic/(shadow-cljs)/api/*→ proxied tohttp://localhost:3000(Ring)/and other unmatched paths →public/index.html(SPA shell)
This is the same idea as a Vite or webpack dev-server proxy. It is the usual SPA setup so the browser does not hit CORS, cookies stay first-party, and you still get CLJS hot-reload from shadow. It is a dev-time convenience, not a production architecture.
In production you typically stop using the proxy: npx shadow-cljs release app, then have Ring (or nginx) serve public/ and /api on one port. This template does not do that yet.
If the API is down, /api/items fails and the UI shows a hint to run (user/start).
SPA vs server pages vs widgets
This repo is a straight SPA. That is the intended setup. You develop at http://localhost:8080. shadow-cljs serves the page and JS, hot-reloads, and proxies /api to Ring on :3000 so the browser stays on one origin (no CORS). Ring is the JSON + SQLite backend, not an HTML renderer.
That proxy is the usual ClojureScript SPA development story (same idea as Vite or webpack). It is the right default here. Do not punch extra HTML routes through it unless you have decided this app is no longer a straight SPA.
Clojure + ClojureScript together is normal. How you split pages is a product choice. Three common shapes:
1. Straight SPA + JSON API (this repo)
One index.html, Reagent owns the UI, fetch("/api/...") talks to Ring. Extra screens are more Reagent components on the same page (and later, client-side routing).
Advantages
- Matches this template:
:8080for the app,:3000for the API, proxy in the middle. - Hot-reload of UI without a full page load.
- One SPA origin in the browser; relative
/apiURLs work in dev and in production (once Ring or nginx serves both on one host). - Natural for a highly interactive UI (forms, live lists, client state).
Disadvantages
- First paint waits on JS. Without JS you get an empty
#app. - “Pages” like
/foodare not real documents until you add a client-side router (or you fake them and still ship the whole SPA). - SEO and shareable document URLs are weaker than server HTML.
- You hold more UI state in the browser; refresh and back-button need thought once routing exists.
Client-side routing (Reitit frontend, etc.) is how a SPA looks like /food and /children without leaving Reagent. Still one index.html; the router swaps views. That is the usual next step for a straight SPA, and is not in this repo yet — explore it here when ready.
2. Classic Ring app + ClojureScript widgets (different kind of project)
Ring renders each URL in Hiccup (/food, /children, …). Full page loads. ClojureScript mounts on specific DOM nodes (a table, a form, a live counter), not on the whole document.
Advantages
- Each URL is a real HTML document. View-source works. Works with little or no JS.
- Simple mental model: Django-like.
GETrenders,POST+ redirect saves. - CLJS stays small and local. You add interactivity where it pays, not a second application.
- Production is just Ring (plus static
/jsand/css). No SPA fallback, no “is this path the shell or a page?”
Disadvantages
- Every navigation reloads the page. No single long-lived Reagent tree.
- Sharing state across pages is the server (session, DB), not a client atom.
- Dev looks different: Ring is the browser origin, shadow only compiles JS. The
:8080+/apiproxy story in this repo is the wrong default. - Easy to accidentally grow a widget into a hidden SPA.
This is a good second learning repo, not a bolt-on to this one. The stack (Ring, Hiccup, shadow-cljs, a bit of Reagent) can be the same; the page owner is Ring. That repo lives next to this one as my-learning-ring.
3. Hybrid: SPA at /, Hiccup at /food, /children, …
Some URLs are the Reagent app; others are server-rendered documents. Same codebase, two page models.
Advantages
- Honest split when those URLs really are different (marketing vs app, or a document you want as HTML).
- You can reuse the DB and
/apifrom Ring.
Disadvantages
- Two ways to build a screen. Navigation between them is a full load (
<a href="/food">), not a Reagent view swap. - In this repo’s dev setup, the proxy currently sends every non-
/apipath to the SPA shell. Hybrid URLs need extra proxy holes, or you invert so Ring is the HTTP server. That extra wiring is a smell that you have left “straight SPA.” - Easy to give the same path two owners (client router and Ring HTML). Pick one per URL.
Use hybrid only when you mean it. For this learning SPA, stay on (1). For a classic multi-page app with sprinkles of CLJS, start a new repo on (2).
REPLs and eval
cider-jack-in-clj&cljs starts one nREPL (the shadow-cljs JVM) and two CIDER sessions on it:
| REPL | Where code runs | What it is for |
|---|---|---|
Clojure (user, my-app.server, my-app.db, …) |
JVM | HTTP server, SQLite, Ring/Reitit, (user/start) |
ClojureScript (my-app.core) |
the browser tab | Reagent UI, DOM, js/fetch, js/alert |
Coming from Django: CLJ is runserver + shell. CLJS is the browser, with a console that speaks Clojure.
CIDER picks the REPL from the file you are in. You do not choose each time.
.cljsbuffer → ClojureScript REPL (needs http://localhost:8080 open).cljbuffer → Clojure REPL (no browser required)- Stay in source files.
C-c C-zjumps to the matching REPL if you want to poke around.
Keys in a source buffer: C-c C-e last form, C-c C-c top-level defn, C-c C-k whole file.
Saving a .cljs file is enough for UI work: shadow recompiles, reload! remounts. Eval is for trying a form without saving.
defonce values (items, the React root) survive reload on purpose. Re-evaling a defonce does not reset it. To clear the list, eval (reset! items []) from a cljs buffer.
After changing Ring routes or server.clj, (user/restart). The handler is built once at start, so C-c C-k on api.clj alone does not change what http-kit is already serving. db helpers can often just be re-evaluated.
A concrete five minutes:
- Change a Tailwind class in
core.cljs, save, watch the page. - From
core.cljsor the cljs REPL:(js/console.log @items). - From
db.cljor the clj REPL:(my-app.db/create-item! "from the REPL"), then let the page reload items. - Change
list-itemsinapi.clj,C-c C-k, then(user/restart).
Clojure REPL commands
(user/start) ; http-kit on :3000, creates the SQLite file and tables
(user/stop)
(user/restart) ; after you change server/api/db routing
There is no Integrant or Component. user holds the server in an atom, like calling runserver and Ctrl-C. When you outgrow that (multiple stateful bits: pool, scheduler), look at Integrant. Not needed here.
Editing the UI
- Save a
.cljsfile → shadow recompiles →my-app.core/reload!remounts the UI. - Save
src/css/app.cssor add a new complete Tailwind class in Hiccup → Tailwind rebuilds → shadow reloads/css/app.css. - Eval in the buffer:
C-c C-e(last sexp),C-c C-c(top-level form),C-c C-k(buffer). The CLJS REPL starts inmy-app.core. - Keep the React root in
defonce. Creating a second root on#appthrows.
Hiccup classes must be complete strings Tailwind can see in the file:
;; good
[:div {:class "text-red-600"}]
[:div {:class (if error? "text-red-600" "text-green-600")}]
;; broken — Tailwind never sees text-red-600
[:div {:class (str "text-" (if error? "red" "green") "-600")}]
Theme tokens and extra CSS go in src/css/app.css (@theme { ... }). Do not add a tailwind.config.js unless you have a v3-era plugin that still needs one.
src/css/app.css uses @import "tailwindcss" source(none) plus explicit @source paths. Leave that alone: auto-detect would also watch compiled public/js and retrigger Tailwind on every CLJS rebuild.
Database (SQLite now)
my-app.db uses next.jdbc and HoneySQL. The file is data/app.sqlite. Schema is CREATE TABLE IF NOT EXISTS in db/init! — enough to play; add Migratus (or similar) when you have real migrations.
Try it from the Clojure REPL:
(require '[my-app.db :as db])
(db/list-items)
(db/create-item! "hello")
Or from the browser at http://localhost:8080 (Items form) or:
curl -s http://localhost:8080/api/health
curl -s http://localhost:8080/api/items
curl -s -X POST http://localhost:8080/api/items \
-H 'content-type: application/json' \
-d '{"title":"from curl"}'
Direct to Ring (bypassing the proxy): http://localhost:3000/api/health.
Moving to Postgres later
The Clojure side is already JDBC. Rough steps:
- Add
org.postgresql/postgresqltodeps.edn. You can leavesqlite-jdbcuntil the cutover is done. - Change the datasource in
my-app.dbfrom{:dbtype "sqlite" :dbname "data/app.sqlite"}to something like{:dbtype "postgres" :host "localhost" :dbname "myapp" :user "..." :password "..."}(or ajdbc:postgresql://...URL). - Replace SQLite-only SQL (
datetime('now'),INTEGER PRIMARY KEY AUTOINCREMENT) with Postgres (now(),GENERATED ALWAYS AS IDENTITYorserial). - Run a real Postgres (Docker is fine) and apply schema with migrations, not
CREATE TABLE IF NOT EXISTSon boot. - Keep HoneySQL maps; only the dialect-specific bits change. next.jdbc code stays.
Do not introduce an ORM for this. next.jdbc + HoneySQL is the usual Clojure pair, analogous to using Django’s cursor or a thin query builder rather than rewriting the app in SQLAlchemy.
Production build (frontend)
npx shadow-cljs release app
Writes optimized JS to public/js/ and minified CSS to public/css/app.css. Dev output is not for production. Serving those files from Ring is a later step.
Keep dependencies current
Two files, two ecosystems. shadow-cljs must match on both sides.
| Where | What | Check |
|---|---|---|
deps.edn |
thheller/shadow-cljs |
Clojars |
package.json |
shadow-cljs |
npm view shadow-cljs version — same version as Clojars |
deps.edn |
reagent/reagent |
Clojars — 2.x needs React 18+ |
package.json |
react, react-dom |
keep the pair in lockstep; Reagent 2 is tested against React 18, works with 19 for this skeleton |
package.json |
tailwindcss, @tailwindcss/cli |
keep the same version; npm view tailwindcss version |
deps.edn |
cider/cider-nrepl |
stay close to your Emacs CIDER (see CIDER’s compatibility notes) |
deps.edn |
cider/piggieback |
needed by cider-nrepl’s CLJS middleware |
deps.edn |
binaryage/devtools |
Chrome CLJS formatting; optional but useful |
deps.edn |
http-kit, metosin/reitit |
HTTP stack |
deps.edn |
next.jdbc, honeysql, sqlite-jdbc |
DB stack; add org.postgresql/postgresql when you switch |
Do not pin org.clojure/clojurescript (or a second Clojure version) in deps.edn unless you have a reason. shadow-cljs already brings versions it supports. Pinning a different CLJS is a common way to get mysterious compiler errors.
Do not mix Tailwind major versions. This template is v4 (@import "tailwindcss", no tailwind.config.js).
A reasonable update ritual
When you clone this into a real app, or every few months:
npm view shadow-cljs version
npm view tailwindcss version
npm view react version
Then check Clojars for thheller/shadow-cljs, reagent, cider/cider-nrepl, http-kit, metosin/reitit, com.github.seancorfield/next.jdbc. Bump deps.edn and package.json together, then:
npm install
npx shadow-cljs compile app
If compile fails, the usual causes are: npm shadow-cljs ≠ thheller/shadow-cljs, a pinned CLJS that disagrees with shadow, or Reagent/React major mismatch.
After a CIDER upgrade in Emacs, bump cider/cider-nrepl if completion, inspector, or cljs eval starts acting oddly.
Ports
| Port | What |
|---|---|
| 8080 | App + /api proxy (:dev-http) |
| 3000 | Ring / http-kit (direct) |
| 7888 | nREPL |
| 9630 | shadow-cljs UI (optional) |
If 8080 is taken, change :dev-http in shadow-cljs.edn and the URL you open. If 3000 is taken, change the port in dev/user.clj and hooks.dev-proxy.
Common failures
- Prompted for shadow-cljs vs clojure-cli — this repo has both
deps.ednandshadow-cljs.edn. Always pick shadow-cljs. Dir-locals should stop the prompt once they are loaded. - No connected JS runtime — the browser is not on http://localhost:8080, or the tab loaded before the watch finished. Refresh after “Build completed”.
- Could not reach /api — Clojure server is not running.
(user/start)in the CLJ REPL. Confirmcurl -s http://localhost:3000/api/health. - Hot reload but no new Tailwind class — the class name is concatenated, or you ran a second Tailwind watcher. Use complete class strings; one watch only.
- Jack-in fails on port 7888 — leftover shadow-cljs JVM.
M-x cider-quit, then kill any leftoverjava/shadow-cljsfor this project. reagent.dom/create-react-classerrors — this template uses Reagent 2 +reagent.dom.client. Don’t copy Reagent 1 mount code.