Prerendering a re-frame app with Chrome Headless

Hi, I’m currently developing ventas, a project to develop an ecommerce platform using an all-clojure stack (re-frame, http-kit, Datomic).

Some days ago I added prerendering capabilities to the project. I started by looking for a Webdriver client for Clojure. I found etaoin, so I added it to my project.clj :

ventas uses mount for managing application state, so I added a state for the Chrome driver:

This takes care of starting and stopping the driver when we reload the namespace from the REPL, ensuring no driver is started without ever being stopped.

As you may have noticed, in that code I’m calling chrome and not chrome-headless , because I want to see what’s going on.

After setting that up and reloading my code (with ), I got a Chrome instance running, controlled from Clojure. I did a quick test:

That worked too: my app was loading correctly in the Chrome instance, so my next task was to write some ClojureScript to change the Bidi route:

Here, routes/go-to is basically (accountant/navigate! (apply bidi/path-for app-routes args)). Notice that I’m adding :export metadata to that function: it’s important to do it if we want to call it from outside of CLJS (it will be available as ventas.seo.go_to in the JS world).

When I saved this code, it was loaded into the Chrome instance by Figwheel, so it was time to test it:

Here, route would be something like [:frontend.category :id "women"] , so I needed to pass it as a string (as it’s not valid JS). This is why I used cljs.reader/read-string in the CLJS side to parse the incoming string as CLJS data.

With that out of the way, I needed a new CLJS function to know when the page was loaded:

This will depend a lot on your app, but for ventas I needed to check:

After writing ready?, I called it from the server:

When(<! (wait-for-frontend driver)) is executed, the function will call ventas.seo.ready? every 400 ms, until it returns true . If that never happens, it times out (at 4000ms in this example).

Time to put that to use:

Step by step:

So that’s it, we have an HTML file! Now we should serve that file whenever we get a request for that route. This is going to depend a lot on your app, but this is what ventas does: there’s an index.html file that gets served on every SPA request, and it contains this inside <body> :

<div id="app">{{rendered-html}}</div>

When serving this file, the server does a simple clojure.string/replace on that tag:

Going further — messing with the DB

So, that took care of basic prerendering, but we now need more:

For the first part, we’re going to need a new function in CLJS land:

At least in ventas, that will dump the whole database as an EDN string (::events/db refers to an universal subscription that basically does a get-in on the DB).

Back in Clojure land, we need to extend prerender to call this new function and save the results to an edn file (last line in the snippet):

Now we serve it as we did with the HTML file:

Finally we mutate the db on app init:

Let’s do the other part: changing the db before prerendering. I did it like this:

Now any namespace that has something to object to the prerendering of the app, can do so:

Don’t forget to add a call before getting the innerHTML :

Docker usage

The ventas demo uses docker, so I had to do some tweaks to get this whole prerendering thing going.

The first one was adding Chrome to my docker-compose.yml :

privileged: true seems to be necessary for now because of an issue with the image.

Then I modified my driver defstate to connect to the host and port set in the production configuration file:

Lastly, I had to add a docker-host option to allow the Chrome container to visit the URL without using a domain or public IP (using the container name):

So that’s it! Thanks for reading!



Clojure developer

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store