The Simplest Way to Deploy ClojureScript with Your API

Toni VäisänenToni Väisänen
11 min read

This time, we'll take the API we built and set up a frontend React application with Helix in ClojureScript to be deployed with the API. We'll restructure the backend code and folder structure to keep the code base cleaner. So, bear with me first with the minor refactoring and setting up the scene on the API side. After this step, we can add server-side routing to serve the API and the static files. SPAs do routing in the front end, so we need to default to the index.html to enable refreshing the site and successfully loading the page. This also allows direct links to frontend paths to be handled correctly. We won't be touching on frontend routing this time around.

You can find the previous steps from the earlier posts in the Building on DigitalOcean App Platform series.

Refactoring the API

First, let's update the source file structure from this

❯ tree src
src
└── main.clj

to this.

❯ tree src
src
└── clj
   └── api
      ├── db.clj
      └── main.clj

We'll add a new path src/cljs for the frontend-related code in a moment.

Let's continue where we left off last time after we created the migrations and moved the database-related code from the original namespace main to api.db.

(ns api.db
  (:require [migratus.core :as migratus]
            [next.jdbc :as jdbc]
            [next.jdbc.date-time]))

;; Use hardcoded default for now.
;; Configuration management needs to dealt with later.
(def dev-jdbc-url
  "jdbc:postgresql://localhost:5432/db?user=user&password=password")

(defn get-db-conf []
  {:dbtype  "postgres"
   :jdbcUrl (or (System/getenv "JDBC_DATABASE_URL")
                dev-jdbc-url)})

(defn migrations-config []
  {:db                   (get-db-conf)
   :store                :database
   :migration-dir        "migrations/"
   :migration-table-name "migrations"
   :init-in-transaction? false})

(defn create-migration [{:keys [name]}]
  (migratus.core/create (migrations-config)
                        name))

(defn datasource []
  (jdbc/get-datasource (get-db-conf)))

(defn get-migrations []
  (jdbc/execute! (datasource) ["SELECT * FROM migrations"]))

(defn run-migrations []
  (migratus/migrate (db/migrations-config)))

And then use the api.db in api.main and migrate the rest of the code.

(ns api.main
  (:require [ring.adapter.jetty :as jetty]
            [reitit.ring :as ring]
            [clojure.java.io :as io]
            [clojure.string :as str]
            [api.db :as db])
  (:gen-class))

(defn get-port []
  (Integer/parseInt (or (System/getenv "PORT")
                        "8000")))

(defn app [_request]
  (let [migrations (db/get-migrations)]
    {:status  200
     :headers {"Content-Type" "application/edn"}
     :body    (str migrations)}))

(defn -main [& _args]
  (db/run-migrations)
  (jetty/run-jetty #'app {:port (get-port)}))

One last thing: update the paths in deps.edn and the :exec-fn for run and create-migration.

modified   api/deps.edn
@@ -1,4 +1,4 @@
-{:paths ["src" "resources"]
+{:paths ["src/clj" "resources" "compiled-resources"]

  :deps {org.clojure/clojure {:mvn/version "1.11.0"}
         ring/ring-core {:mvn/version "1.6.3"}
@@ -10,7 +10,7 @@

  :aliases {:run
            {:main-opts ["-m" "main"]
-            :exec-fn   main/-main}
+            :exec-fn   api.main/-main}
@@ -24,4 +25,4 @@

            :create-migration
            {:exec-args {:name nil}
-            :exec-fn db/create-migration}}}
+            :exec-fn api.db/create-migration}}}

Now, we are good to continue on the CLJS side of things.

Configure Shadow CLJS

We'll be using Shadow CLJS to compile the ClojureScript.

shadow-cljs provides everything you need to compile your ClojureScript projects with a focus on simplicity and ease of use. The provided build targets abstract away most of the manual configuration so that you only have to configure the essentials for your build. Each target provides optimal defaults for each environment and get an optimized experience during development and in release builds.

Shadow-CLJS: UserGuide

Shadow CLJS has a built-in development time HTTP server to serve the files, but we won't use it this time since we want to serve the files from our backend to keep the development setup as close as possible to the deployment configuration. But before getting there, we must first configure the build process, which has three steps:

  1. Configure Shadow CLJS

  2. Define NPM dependencies

  3. Write ClojureScript code

1. Configure Shadow CLJS

Shadow CLJS is configured with shadow-cljs.edn file. It contains all of the ClojureScript-related configuration, dependencies, source file paths, build configurations, etc. I have added the cider/cider-nrepl and binaryage/devtools for convenience, but they are not required for the build.

The configuration is minimal, with only the browser build target defined. This means that when we run shadow-cljs compile/release app we'll get a Javascript bundle meant to be used in a browser.

{:source-paths ["src/cljs"]

 :dependencies [[binaryage/devtools "0.9.7"]
                [cider/cider-nrepl "0.36.0"]
                ;; React wrapper
                [lilactown/helix "0.1.5"]]

 :builds {:app {:target     :browser
                :output-dir "compiled-resources/public/js"
                :asset-path "/js"
                :modules    {:main {:entries [app.main]
                                    :init-fn app.main/init}}
                :devtools   {:preloads [devtools.preload]
                             :after-load app.main/init}}}}

The application output-dir is where the results will be compiled.

2. Define NPM Dependencies

NPM dependencies are defined in the package.json file. It has the npm dependencies the project uses, and since we are configuring a Helix app, we'll need the react and react-dom as dependencies.

{
  "name": "web",
  "version": "0.0.1",
  "private": true,
  "devDependencies": {
    "shadow-cljs": "2.26.2"
  },
  "dependencies": {
    "react": "^18.2.0",
    "react-dom": "^18.2.0"
  }
}

3. ClojureScript Source

For this step, we only need some ClojureScript code to get started. Let's start with something simple and return to the application code later.

(ns app.main)

(. js/console log  "Hello, app.main!")

Confirm the Build Works

Now that we have all the required steps completed let's compile the release build to see if we set up everything correctly.

❯ npx shadow-cljs release app

shadow-cljs - config: /home/tvaisanen/projects/digitalocean-clojure-minimal/api/shadow-cljs.edn
shadow-cljs - connected to server
[:app] Compiling ...
[:app] Build completed. (45 files, 1 compiled, 0 warnings, 1.25s)
❯ ls -ul compiled-resources/public/js

.rw-r--r--  175 tvaisanen  2 Jan 11:58 main.js
.rw-r--r-- 1.3k tvaisanen  2 Jan 11:58 manifest.edn
var shadow$provide = {};
(function(){
'use strict';/*

 Copyright The Closure Library Authors.
 SPDX-License-Identifier: Apache-2.0
*/
console.log("Hello, app.core!");
}).call(this);

It looks like it's working as expected. We are ready to continue to the next phase of serving the files from our backend.

Serving the SPA

In the previous step, we did not yet configure a way for the browser to load the built files. For this, we need an HTML file that loads the Javascript. So, let's create the file resources/public/index.html to do just that.

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
  </head>
  <body>
    <div id="app" />
    <script src="/js/main.js" type="text/javascript"></script>
  </body>
</html>

As you might have noticed, the index file has a relative path to the compiled resources, but our files are in resources and compiled-resources. We want to keep the static files separate from the build artifacts. This helps keep the build process cleaner since we do not need to think whether the compile-resources folder has development files. It'll be created in the build time and kept from version control.

❯ tree resources

resources
├── migrations
│  ├── 20231208151434-test.down.sql
│  └── 20231208151434-test.up.sql
└── public
   └── index.html

❯ tree compiled-resources

compiled-resources
└── public
   └── js
      ├── main.js
      └── manifest.edn

Effectively, we want to merge the resources and compiled-resources in the backend, when the files are served, their respective paths are seen by the browser like they are coming from the same path. We can do this by adding both resources and compiled-resources to the paths in deps.edn.

modified   api/deps.edn
@@ -1,4 +1,4 @@
-{:paths ["src/clj" "resources"]
+{:paths ["src/clj" "resources" "compiled-resources"]

Then, we look at resources by their names in the backend.

(require '[clojure.java.io :as io])

(for [resource (concat (file-seq (io/file (io/resource "public")))
                       (file-seq (io/file (io/resource "public/js"))))]
   (.getName resource))
;; => ("public" "index.html" "js" "main.js" "manifest.edn")

As expected, we can find all of the files with their names. This enables us to create a resource handler for the API to serve both resource directories. Next, let's jump to the backend routing.

Backend Routing

We'll be using metosin/reitit for the backend routing. It also provides functions to serve the resources.

modified   api/deps.edn
@@ -3,9 +3,10 @@
-        migratus/migratus {:mvn/version "1.5.4"}}
+        migratus/migratus {:mvn/version "1.5.4"}
+        metosin/reitit {:mvn/version "0.7.0-alpha7"}}

We need a couple of updates on the API code.

  1. Refactor the previous app to /api/* handler

  2. Configure the router to serve both the api and the static files.

(ns api.main
  (:require [api.db :as db]
            [ring.adapter.jetty :as jetty]
            [reitit.ring :as ring]
            [clojure.java.io :as io]
            [clojure.string :as str])
  (:gen-class))

(defn get-port []
  (Integer/parseInt (or (System/getenv "PORT")
                        "8000")))

(defn handler [_request]
  (let [migrations (db/get-migrations)]
    {:status  200
     :headers {"Content-Type" "application/edn"}
     :body    (str migrations)}))

(defn index-handler
  [_]
  {:status  200
   :headers {"content-type" "text/html"}
   :body    (io/file (io/resource "public/index.html"))})

(def app
  (ring/ring-handler
   (ring/router
    [["/api"
      ["*" {:get handler}]]
     ["/" index-handler]
     ["/*" (ring/create-resource-handler
            {:not-found-handler
             (fn [{:keys [uri] :as r}]
               (if (str/starts-with? uri "/api")
                 {:status 404}
                 (index-handler r)))})]]
    {:conflicts (constantly nil)})
   (ring/create-default-handler)))

(defn -main [& _args]
  (db/run-migrations)
  (jetty/run-jetty #'app {:port (get-port)}))

Let's break the router definition into parts to see what's happening. Our requirements, at the time being, for the routing are:

  1. to be able to serve both api and static assets.

  2. return index.html if the static asset is not found

  3. serve the App from the root

(ring/router
    [;; serve API
     ["/api" 
      ["*" handler]]
     ;; serve app from root
     ["/" index-handler]
     ;; in other case serve static
     ["/*" (ring/create-resource-handler
            {:not-found-handler
             (fn [{:keys [uri] :as r}]
               (if (str/starts-with? uri "/api")
                 ;; if the uri is an API path
                 ;; serve NOT FOUND
                 {:status 404}
                 ;; every other case serve the app
                 ;; so that FE routing can try to 
                 ;; resolve the path
                 (index-handler r)))})]]
    {:conflicts (constantly nil)})

Let's add a dev/clj/user.clj namespace to start the server via REPL

(ns user
  (:require [api.main :as main]))

(def server (atom nil))

(defn start! []
  (reset! server
          (main/start! {:port  8000
                        :join? false})))

(defn stop! []
  (.stop @server))

(comment
  (start!)
  (stop!))

And create an alias to load the user namespace by default.

modified   api/deps.edn
- :aliases {:run
+ :aliases {:dev
+           {:extra-paths ["env/clj"]
+            :ns-default user}
❯ clj -M:dev
2024-01-02 13:41:03.950:INFO::main: Logging initialized @670ms
Clojure 1.11.0

user=> (start!)

Jan 02, 2024 1:41:07 PM clojure.tools.logging$eval2887$fn__2890 invoke
INFO: Starting migrations
Jan 02, 2024 1:41:07 PM clojure.tools.logging$eval2887$fn__2890 invoke
INFO: Ending migrations
2024-01-02 13:41:07.296:INFO:oejs.Server:main: jetty-9.2.21.v20170120
2024-01-02 13:41:07.305:INFO:oejs.ServerConnector:main: Started ServerConnector@2c6f022d{HTTP/1.1}{0.0.0.0:8000}
2024-01-02 13:41:07.306:INFO:oejs.Server:main: Started @4026ms
#object[org.eclipse.jetty.server.Server 0x2e7e4480 "org.eclipse.jetty.server.Server@2e7e4480"]

Now, we should be able to fetch the static resources

❯ http :8000/
HTTP/1.1 200 OK
Content-Length: 307
Content-Type: text/html
Date: Tue, 02 Jan 2024 11:44:41 GMT
Server: Jetty(9.2.21.v20170120)

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
  </head>
  <body>
    <div id="app" />
    <script src="/js/main.js" type="text/javascript"></script>
    <script>
      app.core.init();
    </script>
  </body>
</html>

and access the API endpoints via HTTP.

❯ http :8000/api/
HTTP/1.1 200 OK
Content-Type: application/edn
Date: Tue, 02 Jan 2024 11:46:00 GMT
Server: Jetty(9.2.21.v20170120)
Transfer-Encoding: chunked

[{:migrations/id 20231208151434, 
  :migrations/applied #inst "2024-01-01T09:17:26.936000000-00:00", 
  :migrations/description "test"}]

And that's a wrap on the backend side.

Frontend Code

Finally, we can set up the React side of the application. Let's start by adding a utils namespace and define some utilities for rendering the application.

(ns app.utils
  (:require [helix.core :refer [$]]
            ["react" :as react]
            ["react-dom/client" :as rdom]))

(defn app-container []
  (js/document.getElementById "app"))

(defonce root (atom nil))

(defn react-root []
  (when-not @root
    (reset! root (rdom/createRoot (app-container))))
  @root)

(defn render
  [App]
  (.render (react-root)
           ($ react/StrictMode
              ($ App))))

Next, use the utils to render an App that renders the API response in the DOM.

(ns app.main
  (:require [app.utils :as utils]
            [cljs.pprint :refer [pprint]]
            [clojure.edn :as edn]
            [helix.core :refer [defnc]]
            [helix.dom :as d]
            [helix.hooks :as hooks]))

(defnc App []
  (let [[data set-data] (hooks/use-state {})]
    (hooks/use-effect
     :once
     (-> (js/fetch "/api")
         (.then (fn [res] (. res text)))
         (.then (fn [response-string]
                  (set-data (edn/read-string response-string))))))
    (d/div
     (d/div "App Here")
     (d/pre (with-out-str
              (pprint data))))))

(defn init []
  (utils/render App))

Development Setup

For development time, we can start the CLJS compiler in watch mode to get the updated code in the browser whenever the source code changes.

npx shadow-cljs watch app

After this step, start the API with the :dev profile.

❯ clj -M:dev
2024-01-02 13:41:03.950:INFO::main: Logging initialized @670ms
Clojure 1.11.0
user=> (start!)

This should be enough to get us started on writing code for the frontend. Shadow CLJS also provides a CLJS REPL in the browser for us to connect to, but that's out of the scope of this post. Read how to do this from the docs, or let me know if that'd be something you're interested in learning.

Deployment

We'll package the CLJS build into our Dockerfile so that the deployment process will be the same as previously. The only change is that we use the new Dockerfile for the build.

Let's create a new api/Dockerfile to build the frontend code as a separate step,

FROM clojure:openjdk-17-tools-deps-alpine AS frontend-build
RUN apk add --update nodejs npm
WORKDIR /app
COPY  . .
RUN npm install
RUN npx shadow-cljs release app

# Use a base without node dependencies for serving
FROM clojure:openjdk-17-tools-deps-alpine
WORKDIR /app
COPY . .
RUN ls /app
COPY --from=frontend-build /app/compiled-resources /app/compiled-resources
RUN clojure -P
CMD clojure -X:run

update the api/docker-compose.yml,

modified   api/docker-compose.yml
@@ -5,7 +5,7 @@ services:
   api:
     build:
       context: .
-      dockerfile: docker/dev.Dockerfile
+      dockerfile: Dockerfile
       tags:
         - "registry.digitalocean.com/clojure-sample-app/dev"
     environment:

and then build, start the service, and confirm the build works as expected.

docker-compose up --build

Navigate to localhost:8000 to ensure the app is rendering. You can also see the migrations vector rendered on the screen.

If you haven't followed along with the previous posts, you can find the deployment instructions here.

Conclusion

There are multiple ways to deploy frontend applications. You could bake the static files into a container with a web server like NGINX, serve the files from a bucket with CDN, use the DigitalOcean static App, use a service like Netlify, or host the static files from your GitHub pages. But if you have an API component in your App. It makes sense to pair the frontend code tightly with the API to make the deployment phase more robust. What do I mean by that in this context? If we have the exact version of the frontend shipped with the API itself, there's no room for version mismatch.

Once again, thanks for reading; I hope you found this helpful. Here's the accompanying GitHub repository.

1
Subscribe to my newsletter

Read articles from Toni Väisänen directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Toni Väisänen
Toni Väisänen

Software engineer @ Metosin Ltd Need help with a project, contact: first.last@metosin.com As a 𝐜𝐨𝐧𝐬𝐮𝐥𝐭𝐚𝐧𝐭, I help clients find technical solutions to their business problems and facilitate communication between the stakeholders and the technical team. As a 𝐟𝐮𝐥𝐥-𝐬𝐭𝐚𝐜𝐤 𝐝𝐞𝐯𝐞𝐥𝐨𝐩𝐞𝐫, I build technical solutions for client's problems from user interfaces, and backend services to infrastructure-as-code solutions. As a 𝐦𝐚𝐜𝐡𝐢𝐧𝐞 𝐥𝐞𝐚𝐫𝐧𝐢𝐧𝐠 𝐞𝐧𝐠𝐢𝐧𝐞𝐞𝐫, I create, validate and deploy predictive models.