- Clojure 95.4%
- HTML 2.9%
- CSS 1.7%
| 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).
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.