Fullstack Workflow with shadow-cljs
A common question is how you’d use shadow-cljs
in a fullstack setup with a CLJ backend.
In this post I’ll describe the workflow I use for pretty much all my projects, which often have CLJ backends with CLJS frontends. I’ll keep it generic, since backend and frontend stuff can vary and there are a great many options for which CLJ servers or CLJS frontends to use. All of them are fine to use with this pattern and should plug right in.
A common criticism of shadow-cljs
is its use of npm
. This even acts as a deterrent for some people, not even looking at shadow-cljs
, since they don’t want to infect their system with npm
. I get it. I’m not the biggest fan of npm
either, but what most people do not realize that npm
is entirely optional within shadow-cljs. It only becomes necessary once you want to install actual npm
dependencies. But you could install those running npm
via Docker if you must. So, I’ll try to write this so everyone can follow even without node/npm
installed.
I also used this setup with leiningen before, it pretty much works exactly the same. You just put your :dependencies
into project.clj
. Do not bother with any of project.clj
other features.
The Setup
The only requirements for any of this to work is a working deps.edn/tools.deps install, with a proper JVM version of course. I’d recommend JDK21+, but everything JDK11+ is fine.
Since the constraint here is to not use npm
(or npx
that comes with it), we’ll now have to create some directories and files manually. For those not minding npx
, there is a useful small minimal script npx create-cljs-project acme-app command to do this for use. But it is not too bad without.
Using the acme-app
example I already use in the shadow-cljs README Quickstart.
mkdir -p acme-app/src/dev
mkdir -p acme-app/src/main/acme
cd acme-app
src/main
is where all CLJ+CLJS files go later. src/dev
is where all development related files go. What you call these folders is really up to you. It could all go into one folder. It really doesn’t matter much, for me this is just habit at this point.
Next up we need to create our deps.edn
file, which I’ll just make as minimal as it gets. I do not usually bother with :aliases
at this point. That comes later, if ever.
{:paths ["src/main" "src/dev"]
:deps {thheller/shadow-cljs {:mvn/version "2.28.18"}}}
Of course, you’ll add your actual dependencies here later, but for me its always more important to get the workflow going as fast as possible first.
I also recommend creating the shadow-cljs.edn
file now, although this isn’t required yet.
{:deps true
:builds {}}
Starting the REPL
It is time for take off, so we need to start a REPL. The above setup has everything we need to get started.
For the CLJ-only crowd you run clj -M -m shadow.cljs.devtools.cli clj-repl
, and in case you have npm
you run npx shadow-cljs clj-repl
. Either command after a bit of setup should drop you right into a REPL prompt. After potentially a lot of Downloading: ...
you should see something like this:
shadow-cljs - server version: 2.28.18 running at http://localhost:9630
shadow-cljs - nREPL server started on port 60425
shadow-cljs - REPL - see (help)
To quit, type: :repl/quit
shadow.user=>
I do recommend to connect your editor now. shadow-cljs
already started a fully working nREPL
server for you. It is recommended to use the .shadow-cljs/nrepl.port
file to connect, which tells your editor which TCP port to use. Cursive has an option for this, as well as most other editors I’d assume. You can just use the prompt manually, but an editor with REPL support makes your life much easier.
REPL Setup
We have our REPL going now, but one its own that doesn’t do much. So, to automate my actual workflow I create my first CLJ file called src/dev/repl.clj
.
(ns repl)
(defn start []
::started)
(defn stop []
::stopped)
(defn go []
(stop)
(start))
The structure is always the same. I want a point to start
everything, something to stop
everything. To complete my actual workflow I have created a keybinding in Cursive (my editor of choice) to send (require 'repl) (repl/go)
to the connected REPL. This lets me restart my app by pressing a key. The ::started
keyword is there as a safeguard, make sure it always the last thing in the defn
. We will be calling this via the REPL, so the return value of (repl/start)
will be printed. Not strictly necessary, but returning some potentially huge objects can hinder the REPL workflow.
What start/stop
do is entirely up to your needs. I cannot possibly cover all possible options here, but maybe the file from shadow-cljs itself can give you an idea. It starts a watch for shadow-css to compile the CSS needed for the shadow-cljs UI. It is all just Clojure and Clojure functions.
The entire workflow can be customized for each project from here. Unfortunately, my projects that have a backend are not public, so I cannot share an actual example, but let me show a very basic example using ring-clojure with the Jetty Server.
(ns repl
(:require
[acme.server :as srv]
[ring.adapter.jetty :as jetty]))
(defonce jetty-ref (atom nil))
(defn start []
(reset! jetty-ref
(jetty/run-jetty #'srv/handler
{:port 3000
:join? false}))
::started)
(defn stop []
(when-some [jetty @jetty-ref]
(reset! jetty-ref nil)
(.stop jetty))
::stopped)
(defn go []
(stop)
(start))
We will of course also need the actual ring handler, referenced via srv/handler
in the above code. So we create a src/main/acme/server.clj
file.
(ns acme.server)
(defn handler [req]
{:status 200
:headers {"content-type" "text/plain"}
:body "Hello World!"})
Given that our initial deps.edn
file didn’t have Jetty yet, we’ll CTRL+C
the running clj
process (or (System/exit 0)
it from the REPL). Then we add the dependency and start again.
{:paths ["src/main" "src/dev"]
:deps {ring/ring-jetty-adapter {:mvn/version "1.12.2"}
thheller/shadow-cljs {:mvn/version "2.28.18"}}}
clj -M -m shadow.cljs.devtools.cli clj-repl
again, and then (require 'repl) (repl/go)
(keybind FTW). Once that is done you should have a working http server at http://localhost:3000.
REPL Workflow
This is already mostly covered. The only reason to ever restart the REPL is when you change dependencies. Otherwise, with this setup you’ll likely never need for that slow REPL startup during regular work.
One essential missing piece for this workflow is of course how changes make it into the running system. Given that the REPL is the driver here, making a change to the handler
fn (e.g. changing the :body
string) and saving the file does not immediately load it. You can either load-file
the entire file over the REPL and just eval the defn
form and the change should be visible if you repeat the HTTP request.
Cursive has the handy option to “Save + Sync all modified files” when pressing the repl/go
keybind. Or just a regular “Sync all modified files” in the REPL, since often the stop/start
cycle isn’t necessary. This is super handy and all I have for this. Another option may to use the new-ish clj-reload to do things on file save. I have not tried this, but it looks promising.
Either way, we want to do as much as possible over the REPL and if there are things to “automate” we put it into the start
function, or possibly create more functions for us to call in the repl
(or other) namespace.
Extending the HTTP Server
You’ll notice that the Jetty Server only ever responds with “Hello World!”, which of course isn’t all that useful. For CLJS to work we need to make it capable of serving files. For this we’ll use the ring-file middleware.
(ns acme.server
(:require
[ring.middleware.file :as ring-file]
[ring.middleware.file-info :as ring-file-info]))
(defn my-handler [req]
{:status 200
:headers {"content-type" "text/plain"}
:body "Hello World!"})
(def handler
(-> my-handler
(ring-file/wrap-file "public")
(ring-file-info/wrap-file-info)
))
Now we may create a public/index.html
file and the server will show that to use when loading http://localhost:3000. Don’t bother too much with its contents for now.
Adding CLJS
As this point it is time to introduce CLJS into the mix. I’ll only do a very basic introduction, since I cannot possibly cover all existing CLJS options. It doesn’t really matter if you build a full Single Page Application (SPA) or something less heavy. The setup will always be the same.
First, we create the src/main/acme/frontend/app.cljs
file.
(ns acme.frontend.app)
(defn init []
(println "Hello World"))
Then create or modify the currently empty shadow-cljs.edn
file to create the build for our CLJS. The most basic version looks like this:
{:deps true
:builds
{:frontend
{:target :browser
:modules {:main {:init-fn acme.frontend.app/init}}
}}}
The default is to output all files into the public/js
directory. So, this will create a public/js/main.js
file once the build is started.
I wrote a more detailed post on how Hot Reload works. Everything is already ready for it, you just basically need to same start/stop
logic here too and tell shadow-cljs via the :dev/after-load
metadata tag. For the purposes of this post I’ll keep it short.
You may start the build via either the shadow-cljs UI (normally at http://localhost:9630/builds) and just clicking “Watch”. Or since we are into automating you can modify your src/dev/repl.clj
file.
(ns repl
(:require
[shadow.cljs.devtools.api :as shadow]
[acme.server :as srv]
[ring.adapter.jetty :as jetty]))
(defonce jetty-ref (atom nil))
(defn start []
(shadow/watch :frontend)
(reset! jetty-ref
(jetty/run-jetty #'srv/handler
{:port 3000
:join? false}))
::started)
(defn stop []
(when-some [jetty @jetty-ref]
(reset! jetty-ref nil)
(.stop jetty))
::stopped)
(defn go []
(stop)
(start))
The only lines added were the :require
and the (shadow/watch :frontend)
. This does the same as clicking the “Watch” button in the UI. It isn’t necessary to stop the watch in the stop
fn, calling watch
again will recognize that it is running and do nothing. Either way you should now have a public/js/main.js
file. There will be more files in the public/js
dir, but you can ignore them for now.
Next we’ll need the HTML to make use of this JS. Change public/index.html
to this:
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>acme frontend</title>
</head>
<body>
<div id="root"></div>
<script src="/js/main.js"></script>
</body>
</html>
If you now open http://localhost:3000 in your browser you should see a blank page with Hello World
printed in the console. Where you take this from here is up to you. You have a working CLJ+CLJS setup at this point.
CLJS REPL
The REPL by default is still a CLJ-only REPL. You may eval (shadow.cljs.devtools.api/repl :frontend)
to switch that REPL session over to CLJS. Once done all evals happen in the Browser. Assuming you have that open, otherwise you’ll get a “No JS runtime.” error. To quit that REPL and get back to CLJ you can eval :cljs/quit
.
I personally only switch to the CLJS REPL occasionally, since most of the time hot-reload is enough. You may also just open a second connection to have both a CLJ and CLJS REPL available. That entirely depends on what your editor is capable off.
A few more Conveniences
Running clj -M -m shadow.cljs.devtools.cli clj-repl
AND (require 'repl) (repl/go)
is a bit more verbose than needed. We can change this to only clj -M -m shadow.cljs.devtools.cli run repl/start
by adding one bit of necessary metadata to our start
fn.
(defn start
{:shadow/requires-server true} ;; this is new
[]
(shadow/watch :frontend)
(reset! jetty-ref
(jetty/run-jetty #'srv/handler
{:port 3000
:join? false}))
::started)
Without this shadow-cljs run
assumes you just want to run the function and exit. In our case we want everything to stay alive though, and the middleware tells shadow-cljs to do that. Doing this will lose the REPL prompt in the Terminal though. So, only do this if you have your editor properly setup.
One thing I personally rely very much on is the Inspect UI. It might be of use for you to. It is basically println
on steroids, similar to other tools such as REBL or Portal. It is already all setup for CLJS and CLJ, so all you need is to open http://localhost:9630/inspect and tap>
something from the REPL (or your code). Try adding a (tap> req)
as the first line in acme.server/my-handler
. If you open http://localhost:3000/foo to trigger that handler and see the request show up in Inspect.
Getting To Serious Business
Please do not ever use the above setup to run your production server. Luckily getting to something usable does not require all that much extra work. All we need it to amend our existing acme.server
namespace like so:
(ns acme.server
(:require
[ring.adapter.jetty :as jetty]
[ring.middleware.file :as ring-file]
[ring.middleware.file-info :as ring-file-info]))
(defn my-handler [req]
{:status 200
:headers {"content-type" "text/plain"}
:body "Hello World!"})
(def handler
(-> my-handler
(ring-file/wrap-file "public")
(ring-file-info/wrap-file-info)
))
(defn -main [& args]
(jetty/run-jetty handler {:port 3000}))
That gives us a -main
function, which we can run directly via clj -M -m acme.server
to get our “production-ready” server. This will start only that server and not the whole shadow-cljs development environment.
For CLJS you could run clj -M -m shadow.cljs.devtools.cli release frontend
(or npx shadow-cljs release frontend
) to get the production-optimized outputs. They are just static .js
files, nothing else needed. Note that making a new CLJS release build does not require restarting the above CLJ server. It’ll just pick up the new files and serve them.
That is the most basic setup really. I personally do not bother with building uberjars or whatever anymore and just run via clj
. But every projects requirement is going to vary, and you can use things like tools.build to create them if needed.
Of course real production things will look a bit more complicated than the above, but all projects I have started like this.
Node + NPM
Since pure CLJS frontends are kinda rare, you’ll most likely want some kind of npm dependencies at some point. I do recommend to install node.js
via your OS package manager and just doing it via npm
directly. Don’t worry too much if its slightly out of date. All you need is to run npm init -y
in the project directory once to create our initial package.json
file. After that just npm install
the packages you need, e.g. npm install react react-dom
to cover the basics. You just .gitignore
the node_modules
folder entirely and keep the package.json
and package-lock.json
version controlled like any other file.
If you are really hardcore about never allowing node
on your system, you might be more open to running things via Docker. I frankly forgot what the exact command for this is, but if you know Docker you’ll figure it out. The images are well maintained, and you can just run npm
in it. No need to bother with Dockerfile
and such.
ChatGPT suggested:
docker run -it -v $(pwd):/app -w /app node:20 npm install react react-dom
. I don’t know if that is correct.
Either way, once the packages are installed shadow-cljs
should be able to build them. No need to run any node
beyond that point.
Conclusion
I hope to have shown how I work in an understandable format. Working this way via the REPL really is the ultimate workflow for me. Of course, I have only scratched the surface, but the point of all this is to grow from this minimal baseline. I never liked “project generators” that generate 500 different files with a bunch of stuff I might not actually need. Instead, I add what I need when I need it. The learning curve will be a bit higher in the beginning, but you’ll actually know what your system is doing from the start.
There may be instances where it is not possible to run shadow-cljs embedded into your CLJ REPL due to some dependency conflicts. But the entire workflow really doesn’t change all that much. You just run shadow-cljs
as its own process. The main thing to realize is that the only thing CLJ needs to know about CLJS is where the produced .js
files live and how to serve them. There is no other integration beyond that point. All files can be built just fine from the same repo. Namespaces already provide a very nice way to structure things.
I personally do not like the common “jack-in” workflow that is recommended by editors such as emacs/cider or vscode/Calva, and I do not know how that would work exactly. But I’m certain that either have a “connect” option, to connect to an existing nREPL server, like the one provided by shadow-cljs.