React Prerendering: Next.js + Docker + Azure

Danielle Fenske
PayScale Tech
Published in
7 min readApr 11, 2019
credit: unsplash

Intro

We started with a simple goal that had multiple possible solutions. Our goal:

Improve SEO by pre-rendering webpages, while still utilizing the JavaScript framework we like: React.

The application we focused on is the Cost of Living site. The code currently lives in a legacy repo, and we wanted to pull it out of that repo, modernize the design and improve SEO.

When we started thinking about this problem, we first thought of ASP.NET Pre-rendering, an NPM package that works with C# and React. We implemented a solution using this method, but after many issues and even more correspondence with AWS and Azure support, we pivoted. The next option: NextJS. This is a Node-based framework, which means we need to use Docker so that we can easily deploy it to Azure. In this post, we describe the steps we took to reach our final solution, as well as many of the gotchas we encountered.

NextJS

NextJS claims out-of-the-box server-side rendering, as well as fast page loads using code splitting and simple and flexible configuration. NextJS is also being used by many companies today, including NPM, TicketMaster, Docker, and apparently Elton John ¯\_(ツ)_/¯.

NextJS is a node-based framework that allows us to set up a React app with automatic server-side rendering. The basic structure of a NextJS app is the following:

next-app
│ node_modules/
│ pages/
└─── _document.js
│ homepage.js
| anotherPage.js
│ styles/
└─── index.scss
│ next.config.js
│ package.json
│ server.js

/pages

A key component to making this solution a reality is getInitialProps. Inside each page, you can add Page.getInitialProps() and inside that function, you can fetch all the data you need to be pre-rendered. The data is fetched server side, before the component is rendered, and this is how SSR works in NextJS.

/next.config.js

The next.config.js file serves a similar function as webpack.config.js in other React apps. It abstracts most of the configuration and allows you to add just the configuration you need for special use cases. One of the configurations we specify is we import the @zeit/next-sass module to utilize Sass within your NextJS app. We also specify an assetPrefix so that all static files that normally live in .next/ will be looked for in a CDN instead.

Gotcha #1: Build IDs on Two Servers Don’t Match

In our next.config.js file, we also specify a BuildID and configure a chunk hashing. This is to solve a problem we ran into, where we build our project on the TeamCity build machine so we can upload the built files to our CDN, as well as building it inside the Docker image. The problem we encountered was that when building in two different places, the build IDs and the chunk hashes used in some file names were different. This meant our Docker image was looking for a specific file named chunk-1234.js (for example), while the CDN files only had a file named chunk-5678.js. This resulted in 403s and missing code from our deployed website. With the configuration we included, we were able to ensure the build IDs and chunk hashes matched between builds.

module.exports = withSass({
useFileSystemPublicRoutes: false,
assetPrefix: isProd ? settings.cdn : ‘’,
generateBuildId: async () => {
return ‘app’
},
webpack(config, options) {
config.output.filename = ({chunk}) => {
// Use `[name]-[id].js` in production
if (!options.dev
&& (chunk.name === CLIENT_STATIC_FILES_RUNTIME_MAIN
|| chunk.name === CLIENT_STATIC_FILES_RUNTIME_WEBPACK))
{
return chunk.name.replace(/\.js$/, ‘-[id].js’)
}
return ‘[name]’
};
config.output.chunkFilename = options.isServer
? `${options.dev ? ‘[name]’ : ‘[name].[id]’}.js`
: `static/chunks/${options.dev ? ‘[name]’ : ‘[name].[id]’}.js`;
return config;
}
});

/server.js

server.js sets up the Express routing for the app. We also initialize Azure Application Insights in this file so that we can track requests in Azure.

Gotcha #2 — Exposing a Client Secret

Most of our API calls to the Cost of Living Service were made in the getInitialProps for each page, which happens on the server side. However, one special case requires us to make API calls client-side: a Cost of Living Calculator allows users to enter two cities and a salary and dynamically see a comparison between those two cities. So as the user interacts with the page, we have to make multiple calls to the API in the browser.

This poses a problem, since we use secret-based authentication. If we make client-side authentication calls, we risk exposing our secret in the browser. So we needed a way to make these client-side calls become server-side calls.

To fix this problem, we created a proxy route to handle these requests. We call this proxy endpoint from the calculator client-side, but then the actual authentication can be done server-side. This way, all our API calls are made server-side and we don’t have any security risks. To set up this route, we include this line in our server.js file:

server.get(‘/api/compareCities/:toCity/:fromCity’, async (req, res) => {
await api.CompareCities(req.params.toCity, req.params.fromCity)
.then(data => {
res.json(data);
});
});

Performance Tweaks

At first, when we deployed our app, we had a Lighthouse performance score of nearly 0. To improve the performance, we added the following:

* server.use(compression()); to our server.js file, which compresses the output files from the build.

* setAssetPrefix to our next.config.js to serve static files from a CDN.

* We made sure we were setting the NODE_ENV to production during the Docker image build.

These tweaks increased our Lighthouse score from under 10 to in the 80s.

Gotcha #3–500 Errors and the Magic of web.config

We had to add a web.config file to be able to deploy our app to Azure. The web.config file directs the web app to where to look for the application code. Without this, the app returns a 500 error.

<! — Indicates that the server.js file is a node.js site to be handled by the iisnode module --><add name=”iisnode” path=”/.next/static/runtime/main.js” verb=”*” modules=”iisnode”/>

Docker

The next step in the process was to figure out how and where to deploy our NextJS app. After quickly trying and failing to deploy a simple Node app to a basic Azure web app, we realized the necessity of Docker. Docker works well with Node apps. We created a simple Dockerfile which just directs Docker to install packages using yarn and then runs yarn start. We got that to run locally. Now to deploy it to the cloud.

Azure Containers

Our team has mostly worked with Azure web apps and we thought it would be cool to still be able to utilize the ease of Azure management and App Insights to manage our app. With a little digging, we found a thing called Azure App Services for Containers. This service is like a normal App Service but with a way to configure a container for the app service to look at to get its source code. The Azure portal has a great UI for managing this, as well as a display of the Docker logs.

Gotcha #4 — Linux must be separate

When trying to create the app services in Azure, we ran into limitations for where you’re allowed to create them. We quickly found what was limiting us: quoted from the Azure Docs:

You cannot create Web App for Containers in an App Service plan already hosting non-Linux Web Apps. There is a current limitation in regards to not mixing Windows and Linux apps in the same resource group as well.

We easily side-stepped this problem by creating our own resource groups and app service plans, separate from non-Linux apps.

TeamCity + Octopus setup

TeamCity

Finally, it is time to set up the deployment process. We’ve got the build setup from Docker, we’ve got the app services initialized, and now we just need to set up the flow from BitBucket to the cloud. This step took lots of iterating, as we tried to optimize performance and solve issues we ran into.

The TeamCity build configuration we settled on includes the following steps:

1. Determine a version number for the build that can be saved and used in multiple steps (eg. 2018.12.18.11.23). This is used as the image tag when we push our image to Docker.

2. Update a config file (appsettings.json) with TeamCity parameters, based on the environment. This includes settings like the Identity endpoint and secret.

3. Run yarn and yarn build

4. Copy the compiled, static Next files to S3, so that we can serve them from our CDN

5. Build and push the Docker image to DockerHub

6. Create an Octopus release

This step is where we ran into the mismatched Build IDs between steps 3 and 5.

Octopus

The Octopus deploy consists of a PowerShell script that just configures our Azure Web App to look at the Docker image that we just created in our TeamCity build. We pass the ImageTag from TeamCity to the Octopus deploy, and then just set the Azure Web app to point to that specific tag. We also restart the web app and run a warm-up step, for good measure.

Conclusion

This blog post aimed to detail the steps that we took and the problems we ran into during our attempts to create a React-based app that is also server-side rendered. Hopefully you can now use it as a reference when you need to do something similar, and avoid some of the problems we listed here.

Resources

* NextJS Docs

* Docker Docs

* Azure App Service for Containers

--

--

Danielle Fenske
PayScale Tech

I am a Software Engineer at PayScale, a crowd-sourced compensation software company. I focus on creating attractive and reliable front-end web apps.