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: