Let's just learn Clojurescript through hacking
  • Clojure 97.3%
  • HTML 1.7%
  • CSS 1%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-09-07 16:44:41 +01:00
dev Adding children! 2026-08-27 12:18:08 +01:00
public Initial commit 2026-08-26 16:50:39 +01:00
src some update 2026-09-07 16:44:41 +01:00
.dir-locals.el Initial commit 2026-08-26 16:50:39 +01:00
.gitignore Initial commit 2026-08-26 16:50:39 +01:00
deps.edn Initial commit 2026-08-26 16:50:39 +01:00
package-lock.json some update 2026-09-07 16:44:41 +01:00
package.json some update 2026-09-07 16:44:41 +01:00
README.md Big update to explain architecture and options 2026-08-29 18:04:04 +01:00
shadow-cljs.edn Initial commit 2026-08-26 16:50:39 +01:00

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 (clojure on PATH)
  • 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

  1. Copy the repo (or use GitHubs “Use this template”).

  2. Rename my-app if you want a real project name:

    • directory src/my_app/src/your_app/
    • namespaces my-app.* in src/your_app/
    • :init-fn / :after-load / :repl-init-ns in shadow-cljs.edn
  3. Update package.json "name" and public/index.html <title>.

  4. From the project root:

    npm install
    
  5. Open src/my_app/core.cljs in Emacs. If Emacs asks about risky local variables from .dir-locals.el, accept them (! to remember).

  6. M-x cider-jack-in-clj&cljs is the daily command (both REPLs). cider-jack-in-cljs still starts the same nREPL if you only want the browser REPL.

  7. If CIDER asks shadow-cljs vs clojure-cli, choose shadow-cljs. .dir-locals.el is supposed to skip that question; if it still asks, M-x revert-buffer on core.cljs so dir-locals load.

  8. 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-cljslocalhost: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 in public/ (shadow-cljs)
  • /api/* → proxied to http://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: :8080 for the app, :3000 for the API, proxy in the middle.
  • Hot-reload of UI without a full page load.
  • One SPA origin in the browser; relative /api URLs 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 /food are 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. GET renders, POST + redirect saves.
  • CLJS stays small and local. You add interactivity where it pays, not a second application.
  • Production is just Ring (plus static /js and /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 + /api proxy 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 /api from 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 repos dev setup, the proxy currently sends every non-/api path 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.

  • .cljs buffer → ClojureScript REPL (needs http://localhost:8080 open)
  • .clj buffer → Clojure REPL (no browser required)
  • Stay in source files. C-c C-z jumps 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.cljs or the cljs REPL: (js/console.log @items).
  • From db.clj or the clj REPL: (my-app.db/create-item! "from the REPL"), then let the page reload items.
  • Change list-items in api.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 .cljs file → shadow recompiles → my-app.core/reload! remounts the UI.
  • Save src/css/app.css or 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 in my-app.core.
  • Keep the React root in defonce. Creating a second root on #app throws.

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:

  1. Add org.postgresql/postgresql to deps.edn. You can leave sqlite-jdbc until the cutover is done.
  2. Change the datasource in my-app.db from {:dbtype "sqlite" :dbname "data/app.sqlite"} to something like {:dbtype "postgres" :host "localhost" :dbname "myapp" :user "..." :password "..."} (or a jdbc:postgresql://... URL).
  3. Replace SQLite-only SQL (datetime('now'), INTEGER PRIMARY KEY AUTOINCREMENT) with Postgres (now(), GENERATED ALWAYS AS IDENTITY or serial).
  4. Run a real Postgres (Docker is fine) and apply schema with migrations, not CREATE TABLE IF NOT EXISTS on boot.
  5. 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 Djangos 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 versionsame 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 CIDERs compatibility notes)
deps.edn cider/piggieback needed by cider-nrepls 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-cljsthheller/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.edn and shadow-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. Confirm curl -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 leftover java/shadow-cljs for this project.
  • reagent.dom / create-react-class errors — this template uses Reagent 2 + reagent.dom.client. Dont copy Reagent 1 mount code.