Since our country is in lockdown now and I cannot (and do not want to) continue with my usual videos, here’s a “normal” blog post about some fun thing I was trying recently. I wanted to use reactjs (TypeScript) as a template engine for server-side rendering in spring boot (Kotlin) so that…

  • I can use the client-side app created by create-react-app (almost) without modification
  • I can still run the react-app in standalone mode with npm start
  • The app works exactly the same when I open it from the spring boot server
  • I can use all modern JavaScript features
  • Routing works exactly the same on the server-side and on the client side
  • REST calls are only done when the JS scripts are running on the client side, otherwise they are direct calls to Java methods

I am not there yet. But I already got the first four bullet points covered, and I am working on the fifth. So, here is what I did so far…

You can find the whole project on Github: https://github.com/dtanzer/react-graalvm-springboot. The version of the code I describe in this blog post is 0d73689f884f85e1c815f56fe71ee32c0f134145.

The ReactJS App

The react app lives in the directory client. I created it with create-react-app, using TypeScript as my implementation language.

One can run this app in standalone mode with npm start or build it and add the newest version to the spring boot app. For that, I created a small shell script that runs npm run build and then moves all the files to their correct new homes:

#!/bin/sh

rm -rf ../server/public
rm -rf ../server/src/main/resources/reactapp
mkdir -p ../server/src/main/resources/reactapp/js

npm run build
mv build ../server/public
mv ../server/public/index.html ../server/src/main/resources/reactapp
cp ../server/public/static/js/2.*.chunk.js ../server/src/main/resources/reactapp/js/2.chunk.js
cp ../server/public/static/js/main.*.chunk.js ../server/src/main/resources/reactapp/js/main.chunk.js
cp ../server/public/static/js/runtime-main.*.js ../server/src/main/resources/reactapp/js/runtime-main.js

So, the built, webpack‘d client app will mostly end up in server/public. This is a directory that spring boot will serve by default. So, everything that’s in there will be reachable by the browser.

I move index.html out from this directory because I do not want this to be accessible by the browser. This is the template for the “spring boot”-generated pages, so I move it to a directoy on the Java classpath. I also copy the generated JavaScript files to the same directoy, because I will read them from my Kotlin program too.

The Server

I am running the spring boot application in GraalVM using the Graal script engine. This allows me to use modern JavaScript features (and the older Nashorn engine is deprecated anyway).

Here are the relevant parts of my build file server/build.gradle.kts:

java.sourceCompatibility = JavaVersion.VERSION_1_8
java.targetCompatibility = JavaVersion.VERSION_1_8

...

dependencies {
    implementation("org.graalvm.sdk:graal-sdk:19.1.1")
    implementation("org.graalvm.js:js:19.1.1")
    implementation("org.graalvm.js:js-scriptengine:19.1.1")
    implementation("org.graalvm.compiler:compiler:19.1.1")
    implementation("org.graalvm.truffle:truffle-api")

    implementation("org.springframework.boot:spring-boot-starter-jersey")
    implementation("org.springframework.boot:spring-boot-starter-web")
    implementation("org.springframework.boot:spring-boot-starter-webflux")

    ...
}

Server-Side Rendering

The server-side application does the rendering in 6 steps (some of the steps are cached for later requests):

  • Initialize a GraalVM script engine
  • Set up some JavaScript objects that are missing on the server side
  • Load the generated JavaScript files and evaluate them
  • Read the JavaScript webpack initialization code from the contents of index.html and evaluate it
  • Read the JavaScript code to initialize the react app from index.html and evaluate it
  • Replace the root div with the result of the last step and return index.html

…and that’s i… Wait - there is no code to initialize the react app (step 5) in index.html when one uses create-react-app. So, I had to change client/public/index.html to contain the following code after the root div:

<script defer type="module">
    if(window.isServer) {
        window.renderAppOnServer()
    } else {
        window.renderApp()
    }
</script>

and I am setting up those two functions in client/src/index.tsx:

const anyWindow: any = window
anyWindow.renderApp = () => {
    ReactDOM.hydrate(<App />, document.getElementById('root'))
}
anyWindow.renderAppOnServer = () => {
    return ReactDOMServer.renderToString(<App />)
}
anyWindow.isServer = false

Rendering in Detail

The first few steps, which are the same for all requests, will be cached in Controller.kt:

@Controller
class HtmlController {
    val indexHtml by lazy {
        HtmlController::class.java.getResource("/reactapp/index.html").readText()
    }
    val initJs by lazy(::readInitJs)
    val engine by lazy(::initializeEngine)

    //...

    private fun initializeEngine(): GraalJSScriptEngine {
        val engine = GraalJSScriptEngine.create(null,
                Context.newBuilder("js")
                        .allowHostAccess(HostAccess.ALL)
                        .allowHostClassLookup({ s -> true }))

        engine.eval("window = { location: { hostname: 'localhost' } }")
        engine.eval("navigator = {}")
        engine.eval(runtimeMainJs)
        engine.eval(mainJs)
        engine.eval(secondJs)
        engine.eval(initJs)
        engine.eval("window.isServer = true")

        return engine
    }
}

Where readInitJs reads the webpack-initialization-code from index.html (which is contained in the only script tag without parameters):

private fun readInitJs(): String {
    val startIndex = indexHtml.indexOf("<script>")+"<script>".length
    val endIndex = indexHtml.indexOf("</script>", startIndex)

    return indexHtml.substring(startIndex, endIndex)
}

Similarily, readRenderJs reads my own rendering code that I added to index.html (see above):

private fun readRenderJs(): String {
    val startIndex = indexHtml.indexOf("<script defer=\"defer\" type=\"module\">")+"<script defer=\"defer\" type=\"module\">".length
    val endIndex = indexHtml.indexOf("</script>", startIndex)

    return indexHtml.substring(startIndex, endIndex)
}

And then, for every request, the controler executes this renderJs and returns the result:

@GetMapping("/")
@ResponseBody
fun blog(): String {
    val html = engine.eval(renderJs)
    return indexHtml.replace("<div id=\"root\"></div>", "<div id=\"root\">$html</div>")
}

Why Does That Work?

Let’s look again at the code that I added to index.html and index.tsx:

<script defer type="module">
    if(window.isServer) {
        window.renderAppOnServer()
    } else {
        window.renderApp()
    }
</script>

const anyWindow: any = window
anyWindow.renderApp = () => {
    ReactDOM.hydrate(<App />, document.getElementById('root'))
}
anyWindow.renderAppOnServer = () => {
    return ReactDOMServer.renderToString(<App />)
}
anyWindow.isServer = false

In the browser, window.isServer is always set to false before running the script from index.html. So, on the browser, I always call ReactDOM.hydrate(...), which will try to find the server-rendered HTML for all React components and hook them up with event listeners.

But on the server, we evaluate the following line after loading all generated JavaScript code:

engine.eval("window.isServer = true")

So here, when running the script from index.html, the code ends up in return ReactDOMServer.renderToString(...) and the spring boot controller can use the result to change the content from index.html.

What I got so far is…

  • A nice way to render stuff on the server side using exactly the same code as in the browser.
  • Statically generated HTML for SEO, a dynamic react app after the first load
  • Better initial load performance: <4 seconds on a simulated slow 3G network vs. >7 seconds for the app without server-side rendering

Now, I am trying to get react-router to work on the server side. If I succeed, you’ll read here about it soon, so follow me on Twitter to not miss the update!

Read / watch all parts of “Spring and Isomorphic React” here: