Choosing Between shadow-cljs and cljs.main for ClojureScript Web Apps
Guidance on picking shadow-cljs or the official ClojureScript compiler for a web app, covering npm integration, REPL workflow, and build trade‑offs.
27 Mar 2026, 08:56 UTC

Decision: shadow-cljs vs. cljs.main for a ClojureScript web app
When starting a new ClojureScript project that will run in the browser you must pick a build toolchain. The decision affects how you bring in npm packages how fast you see changes and how much configuration you maintain.
Takeaway: If your app relies on many npm libraries or you want a single file configuration that gives hot reload REPL and advanced builds out of the box start with shadow-cljs. If you prefer to stay on the official Clojure CLI toolchain need minimal moving parts or are building a library where npm use is rare use cljs.main driven by deps.edn and add a separate bundler only when needed.
Constraints to consider
- Amount of npm dependencies zero a few or many
- Desired developer feedback loop instant hot reload versus manual page refresh
- Preference for avoiding a Node based server in production builds
- Team familiarity with shadow‑cljs versus plain Clojure CLI tools
- Long term maintenance concerns community maintained versus official compiler
Comparison table
| Feature | shadow-cljs | cljs.main (deps.edn) |
|---|---|---|
| npm integration | Built in :npm-deps and require shim most plain JS libraries can be required directly | Requires manual steps :foreign libs with :global exports or an external bundler webpack esbuild to consume npm packages |
| REPL and hot reload | Built in watch server nREPL connection and automatic browser reload on file save | Provides a simple browser REPL hot reload needs extra tooling Figwheel Main or manual page refresh |
| Advanced compilation | Wraps Google Closure Compiler with sensible defaults :modules code splitting and automatic externs inference | You invoke cljs.main directly you must configure :optimizations :modules and externs yourself |
| Zero config dev server | shadow-cljs.edn defines :devtools {:http-port 8000} and serves compiled assets | No built in server you typically run a static file server python -m http.server alongside the compile command |
| Learning curve | One configuration file concepts of :builds :npm-deps and :modules | Familiar if you already use deps.edn and clojure -M:cljs otherwise you need to learn the cljs.main options |
| Maintenance burden | Depends on a separate community project watch for releases and compatibility with ClojureScript versions | Relies on the official ClojureScript compiler fewer moving parts but you may need to maintain your own bundler config |
Trade offs explained
When shadow‑cljs is advantageous
- Your project pulls in several npm packages UI kits date helpers charting libraries The :npm-deps feature lets you require ["lodash"] without extra tooling
- You value a rapid feedback loop saving a ClojureScript file triggers a recompile and the browser updates instantly
- You prefer a single source of truth for build options rather than juggling deps.edn a separate bundler and a Figwheel config
When cljs.main/deps.edn is preferable
- The application is mostly ClojureScript with only occasional JS interop or you are distributing a library where consumers expect a plain CLJS artifact
- You want to avoid a Node process in your CI pipeline the build can run with just the JVM and the Clojure CLI
- Your team already standardizes on deps.edn for both Clojure and ClojureScript projects and wishes to keep the tooling uniform
Concrete implementation setting up a minimal app with shadow‑cljs
- Create a project directory and initialize a deps.edn optional but useful for JVM dependencies
mkdir my-app
cd my-app
echo '{} > deps.edn
- Add a shadow‑cljs configuration file
cat > shadow-cljs.edn <<'EOF'
{:source-paths ["src"]
:dependencies []
:npm-deps {:date-fns "2.30.0"}
:builds {:app {:target :browser
:output-dir "public/js"
:asset-path "/js"
:modules {:main {:init-fn my-app.core/init}}
:devtools {:http-port 8000}}}}
EOF
- Create a simple ClojureScript namespace that uses the npm package
mkdir -p src/my_app
cat > src/my_app/core.cljs <<'EOF'
(ns my-app.core
(:require ["date-fns" :as dfn]))
(defn ^:export init []
(let [now (js/Date.)
formatted (.format dfn/format now "yyyy-MM-dd")]
(js/alert (str "Today is " formatted))))
EOF
- Start the watch process run in a terminal with normal user permissions
npx shadow-cljs watch app
Shadow‑cljs will compile the ClojureScript bundle the date‑fs npm package via its require shim and serve http://localhost:8000/index.html place a static HTML file in public/index.html that loads /js/main.js After saving a change to src/my_app/core.cljs the browser should reload and show the updated alert without a manual refresh.
What to check Open the browser console there should be no warnings about missing externs or unresolved symbols The alert should display a date formatted by date‑fs.
Concrete implementation using cljs.main with an external bundler
- Initialize a deps.edn that pulls in the ClojureScript compiler
cat > deps.edn <<'EOF'
{:deps {org.clojure/clojurescript {:mvn/version "1.11.132"}
}}
EOF
- Create a ClojureScript source file same as above
mkdir -p src/my_app
cat > src/my_app/core.cljs <<'EOF'
(ns my-app.core
(:require ["date-fns" :as dfn]))
(defn ^:export init []
(let [now (js/Date.)
formatted (.format dfn/format now "yyyy-MM-dd")]
(js/alert (str "Today is " formatted))))
EOF
- Compile to a plain JavaScript module no bundling yet
clojure -M:cljsbuild -co '{:output-dir "target"
:optimizations :none
:target :browser
:main my-app.core}'
This emits ES6 style imports for the npm package because we used the string "date-fns" in a require The output file target/main.js will contain an import like import * as dfn from "date-fns";
- Run an external bundler example with esbuild to resolve the npm import and produce a browser ready bundle
npx esbuild target/main.js --bundle --outdir=public --servable=8000
Esbuild will fetch the date‑fs package from npm bundle it with your compiled code and serve http://localhost:8000 After editing src/my_app/core.cljs repeat steps 3 and 4 or set up a watch script to see changes.
What to check The served page should load without console errors and the alert should show the formatted date Verify that the bundle size is reasonable and that no duplicate copies of date‑fs appear.
Limitations and validation steps
- Shadow‑cljs adds a Node.js dependency and a separate process if you need a strictly JVM only CI environment you must account for installing Node and running npx
- Using cljs.main with an external bundler means you maintain two configuration sources deps.edn and the bundler config Changes to npm dependencies may require updating both
- Advanced compilation can break JS interop if externs are missing In both toolchains you should add ^js type hints or provide an externs file for libraries that mutate global names
- To validate a release build run npx shadow-cljs release app or the equivalent cljs.main command with :optimizations :advanced then serve the generated public/js/main.js with a static server and open the page in a browser Confirm that the app works and that the console shows no warnings about undefined globals
By comparing the setup steps feedback loop and maintenance considerations outlined above you can choose the toolchain that matches your project npm usage desired developer experience and long term maintenance goals.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.