UIx + ShadowCLJS: Revolutionizing Clojure SSR for Interactive Landing Pages
Table of Contents
- Results on my project
- Requirements
- Server side rendering a landing page
- Building the release
- Further optimizations
- File based routing
- Closing thoughts
ClojureScript + React is great for building rich UI experiences but it is awful at building landing pages because the big bundle size of all of clojurescript.core
and libraries that you can include. This is why many devs choose to go with simple static pages generated through hiccup or htmx or other templating libraries.
But what happens if you have a full UI library for your react clojurescript app already defined that you want to use on your landing page?
Or if you want to do complex logic on landing pages like issuing requests, popping modals or use cool animation libraries? Can't you just make the landing page part of your application?
Yes, you can do that, however, the performance will be terrible:
- You will first serve an empty html with the classic
<div id="root" />
that react will take over - You will fetch a (at least) 1-1.5 MB javascript file containing the logic for your entire SPA just for the landing
This will downgrade your SEO score since web crawlers look at these metrics to rank you higher. In this blog, we will look at a solution to use your existing clojurescript code to build interactive and high performance landing pages.
Results on my project
I have a website built with UIx and ClojureScript, here's the lighthouse score for the landing page which is simply part of the SPA:
Lighthouse score before server side rendering:
Here's the result after the optimisation:
Note: In all honesty, this score is not just because SSR, I did some tweaks, and we'll cover those later
Requirements
We will need:
- A clojurescript react wrapper that supports server side rendering. We will use UIx because currently it's the fastest and supports SSR. You can also use rum but I haven't tested SSR with it and it doesn't use the latest react versions.
- A clojurescript selective compiler - shadow-cljs for compiling only the code you need for server rendered pages
- A backend server to serve the rendered html. I'm using reitit but any server that can return html will do. A nice convenience for reitit is you can define your frontend (SPA handled) routes and your backend routes in the same
routes.cljc
file
Setup
We will work with this file structure to better separate concerns:
.
├── clj # backend
│ └── saas
│ ├── layout.clj
│ ├── routes.clj
│
├── cljc # common pages
│ └── saas
│ └── common
│ ├── ui
│ ├── pages
│ └── landing.cljc
├── shadow-cljs.edn # compiler
└── cljs # Pure UI
└── saas
├── core.cljs # entry point for single page app
└── ui
├── pages # pure SPA
└── dashboard.cljs
├── ssr_pages # SSR pages that are compiled separately
└── landing.cljs
Server side rendering a landing page
Here are the steps to make a landing page server side rendered and interactive afterward:
1. Define your landing page in cljc
Note: UIx supports both clj & cljs. I advise you write your UI library as much as possible in
.cljc
so you can use them when server rendering static pages. All of shipclojure's UI library is written in cljc to solve this.
(ns saas.common.ui.ssr-pages.landing
(defui landing-page []
($ :div
($ nav)
($ hero)
($ problem-statement)
($ features-listicle)
($ pricing-section)
($ faq-section)
($ cta)
($ footer)))
2. Render html on the backend:
Let's write a layout.clj
file where we define how to render html on the backend:
(ns saas.layout
(:require
[hiccup2.core :as h]
[hiccup.util :refer [raw-string]]
[uix.core :refer [$]]
[ring.util.http-response :refer [content-type ok]]
[uix.dom.server :as dom.server])
(:gen-class))
(defn page-script
"Returns script tag for specific application page or the SPA
Usage:
(page-script :app) ;; used for the SPA
(page-script :landing) ;; used for server rendered landing page
"
[page-name]
[:script {:src (str "/assets/js/" (name page-name) ".js")}])
(defn root-template
[{:keys [title description inner script-name]}]
(str
"<!DOCTYPE html>"
(h/html
[:html.scroll-smooth
[:head
[:meta {:charset "UTF-8"}]
[:meta {:name "viewport" :content "width=device-width,initial-scale=1"}]
[:link {:rel "stylesheet" :href "/assets/css/compiled.css"}]
[:title title]
[:meta {:name "description" :content description}]]
[:body.font-body.text-base
[:noscript "This is a JavaScript app. Please enable JavaScript to continue."]
[:div#root (raw-string inner)] ;; here's where actual react code will run
(when script-name
(page-script script-name))]])))
(defn render-page
"Render a html page
params - map with following keys:
- `:inner` - inner string representing page content. Usually obtained through `uix/render-to-string`
- `:title` - title of the html page
- `:description` - description of html page
- `:page-script` - name of js script to be required. Used to hydrate server side rendered react
pages"
[params]
(-> params
(root-template)
(ok)
(content-type "text/html; charset=utf-8")))
(defn app-page
"HTML page returned from the server when rendering the SPA.
The SPA is not server side rendered."
([]
(app-page {}))
([{:keys [title description]
:or {title (:html/title config)
description (:html/description config)}}]
(render-page
{:title title
:description description
:inner nil ;; We don't server side render the single page app
:script-name :app})))
(defn landing-page
"Server rendered landing page for high SEO which is hydrated on the frontend"
[_]
(render-page
{:title (:html/title config)
:description (:html/description config)
:script-name :landing ;; the html file will require the <script src="landing.js" /> for hydration
:inner (dom.server/render-to-string ($ landing/landing-page))}))
3. Tell your router what to do
Define the reitit routes and what to serve on each of them:
routes.clj
(ns routes
(:require [saas.layout :as layout] ))
(defn routes
[]
[""
["/" {:get (fn [req] (layout/landing-page))}] ;; server side rendered landing
["/app" {:get (fn [req] (layout/app-page))}] ;; pure client single page app
["/api"
...]
]
)
4. Write your frontend that hydrates the landing page
We write a clojurescript file that defines code which will hydrate the server rendered html. Full steps will be:
- server renders static html
- serves html to browser
- browser requires
landing.js
- script hydrates react and makes page interactive
cljs/../landing.cljs
(ns saas.ui.ssr-pages.landing
(:require [uix.core :refer [$]]
[uix.dom :as dom.client]
;; cljc
[saas.common.ui.pages.landing :refer [landing-page]]))
;; Hydrate the page once the compiled javascript is loaded.
;; This can be checked with a `useEffect`
(defn render []
(dom.client/hydrate-root (js/document.getElementById "root") ($ landing-page)))
4. Write shadow-cljs config for only this landing.cljs
We can define the SSR pages as code split bundles
shadow-cljs.edn
{:deps true
:dev-http {8081 "resources/public"}
:nrepl {:port 7002}
:builds {:app {:target :browser
:modules
{;; shared can be react, uix, and the ui components
:shared {:entries [saas.common.ui.core]}
;; module containing just the code needed for SSR
:landing {:init-fn saas.ui.ssr-pages.landing/render
:depends-on #{:shared}}
;; module for the SPA code
:app {:init-fn saas.core/init
;; assuming the SPA also renders the "pages" above
:depends-on #{:shared :landing}}}
:output-dir "resources/public/assets/js"
:asset-path "/assets/js"}}}
This will generate in resources/public/assets/js
the following files:
app.js
shared.js
landing.js
This file, will be loaded into our html, it will take our server generated html landing page and hydrate it, i.e make it interactive so you can use useState
(uix/use-state
) and any other react good stuff.
5. Rinse & repeat
Say you want your /about
page to have the same principle. No problem:
Common (cljc) :
(ns saas.common.ui.pages.about)
;; about page written in .cljc
(defui about-page
[]
(let [[count set-count] (uix/use-state 0)]
($ :div
"This is a cool about page"
($ ui/button {:on-click #(set-times inc)} "+"))))
As you can see, we define each page in it's own file so that we only load the minimum required dependencies to be compiled with shadow-cljs. This is exactly why React SSR frameworks adopted a file-based routing system. You only include the required dependencies in javascript land.
Backend (clj):
layout.clj
;; layout.clj
(ns saas.layout
(:require [saas.common.ui.pages.about :as about]))
...
(defn about-page
"Server rendered about page for high SEO which is hydrated on the frontend"
[_]
(render-page
{:title "About us"
:description "Our cool story"
:script-name :about ;; name of the compiled script to load in html (about.js)
:inner (dom/render-to-string ($ about/about-page))}))
routes.clj
(ns routes
(:require [saas.layout :as layout] ))
(defn routes
[]
[""
["/" {:get (fn [req] (layout/landing-page))}] ;; server side rendered landing
["/about" {:get (fn [req] (layout/about-page))}] ;; server side rendered about page
..
["/api"
...]
]
)
Frontend (cljs):
ssr_pages/about.cljs
(ns saas.ui.ssr-pages.about
(:require [uix.core :refer [$]]
[uix.dom :as dom.client]
;; cljc
[saas.common.ui.pages.about :refer [about-page]]))
;; Hydrate the page once the compiled javascript is loaded.
;; This can be checked with a `useEffect`
(defn render []
(dom.client/hydrate-root (js/document.getElementById "root") ($ about-page)))
shadow-cljs.edn
{:deps true
:dev-http {8081 "resources/public"}
:nrepl {:port 7002}
:builds {:app {:target :browser
:modules
{;; shared can be react, uix, and the ui components
:shared {:entries [saas.common.ui.core]}
;;landing page code
:landing {:init-fn saas.ui.ssr-pages.landing/render
:depends-on #{:shared}}
;; about page code
:about {:init-fn saas.ui.ssr-pages.about/render
:depends-on #{:shared}}
:app {:init-fn saas.core/init
;; assuming the SPA also renders the "pages" above
:depends-on #{:shared :landing :about}}}
:output-dir "resources/public/assets/js"
:asset-path "/assets/js"}}}
And we're done!
Building the release
Running
npx shadow-cljs release app
Will compile all of the assets for the SSR pages and the SPA. What's even better, we can add a specific cache for the shared.js
file and if somebody visits the landing page, they will have to download considerably less code when visiting the SPA. (Thank you Thomas Heller for helping me with a better config for compiling these separate files)
After this, it's just about making the clojure backend serve the static files. See reitit/create-resource-handler for details on adding this capability.
Further optimizations
As I mentioned above, it wasn't just the SSR that boosted my score so hight. I also added gzip middleware to the static assets so load times are further reduced. It had an impressive effect: 600kb of javascript -> 154 kb.
shadow-cljs
We can further optimize the build by adding for page a script like this:
[script "saas.ui.ssr_pages.landing.render();"]
and change (defn render [] ..)
to (defn ^:export render [] ...)
so the name is preserved after :advanced
optimization
This is because if we use :init-fn
in the shadow-cljs config, the :init-fn
function is always called no matter what. Changing the code this way, we control when to call render and we allow sharing code from those modules in the future.
Credit Thomas Heller in his response to this blog post
File based routing
After writing this document, I realized all of this manual work can be put in a library that takes a folder with .cljc
uix pages, a layout config and gives back the correct config for reitit and builds an internal shadow-cljs config a.k.a NextJS for clojure(script).
Send me an email if this is interesting for you and I'll continue to work on it.
Resources
Here are some resources you can look to further understand this:
- React server rendering docs
- UIx SSR examples + docs
- uix-ssr-demo template by elken - This repo has been a major help for this endeavor
Closing thoughts
Is this approach for everybody? Of course no!
This is a great approach if:
- You already have UIx or a UI library in your code
- You don't want to use htmx for the interactivity
- You think it's cool (I do)
Thank you for reading!