Localized Server-Side Rendering with React

The speed and SEO benefits of SSR can outweigh its complexity. In this article, we explore how to localize a React app made with SSR.

Anyone who’s tackled server-side rendering (SSR) before knows that it can be a bit tricky. Some would even say that SSR should be avoided until absolutely necessary. Outside of sheer masochistic delight, however, SSR does solve some real problems with modern web apps. Our client-side, single-page apps (SPAs) can idle, while our JavaScript bundle downloads, parses, and runs, showing nothing useful to our users. And while search engine crawlers are becoming more sophisticated and adapting to modern JS-heavy web apps, Google and other search engines will at least be faster at crawling our websites if they can be served traditionally (HTML from the server). For these reasons, SSR is gaining popularity among web devs.

So what happens when we want to internationalize and localize our server-side rendered apps? There are a few considerations when mixing i18n and SSR, and we’ll tackle some of them here. We’ll build a server-side rendered app with React and Razzle, and localize it with the help of React Intl.

πŸ—’ Note Β» I’m assuming you have some basic experience with SSR and React here. If you’ve never built an SSR app with React, I highly recommend Stephen Grider’s Udemy course, Server Side Rendering with React and Redux.

Our Demo App

Our little demo app, Boardaki, is a niche voting platform for board game lovers. Our users can browse through various board games and up- or down-vote them. The app will have a home route and a games index route. We’ll keep it as basic as possible and focus on the SSR and i18n aspects of the app as much as possible. Here’s what our app will look like when we’re done.

Our SSR + i18n demo app in all its glory

Let’s get started.

Libraries Used

We’ll use the following libraries (with versions at the time of writing) to build our demo app.

  • Express (4.17) β€” server used to render our app
  • Razzle (3.0.0) β€” lightweight SSR framework
  • React (16.13) β€” UI framework
  • React Intl (3.12) β€” i18n library
  • React Router (5.1) β€” React routing library

On Node and Intl Support

We’ll be using the massively popular React Intl i18n library. At the time of writing, React Intl delegates as much as it can to the standard JavaScript Intl i18n API. The Intl API enjoys good support on most modern browsers. However, since we’re building an SSR app, we will serve our app’s rendered HTML from an Express server on Node. So we’ll need Intl support on Node as well. On Node <13, however, Intl support is either spotty or missing. You may have to bring in additional packages to fill the gap.

βœ‹πŸ½ Heads Up Β» For the reasons outlined above, I strongly recommend that you use Node 13+ to build on if you’re developing an SSR app with i18n that relies on the Intl API.

πŸ”— Resource Β» See all the details regarding Intl support on modern browsers and Node in the Runtime Requirements section of the React Intl documentation.


We’ll use Razzle for our SSR framework here. Unlike other SSR solutions, Jared Palmer’s Razzle is thin and largely unopinionated: it’s almost like create-react-app for SSR. If you’ve never used Razzle before, don’t worry: it’s pretty straightforward.

πŸ—’ Note Β» Jared Palmer, author of Razzle, has also written Formik. The latter is a very popular form library for React. So Palmer knows what he’s doing with the React-y sauce.

Let’s create a new Razzle app using npx.

Once the command installs all the modules, we can navigate into the new boardaki directory and run npm start. If all went well, we’ll see a message saying the server started. We can then navigate to http://localhost:3000 in our browser to see the boilerplate app.

The roaring render of the Razzle

The Client

OK, we’ve got our base down. Let’s build our app. We’ll leave our browser entry point as it is for now.

This is Razzle’s client.js untouched. It uses ReactDOM’s hydrate(), rather than render(), since we want React to simply connect JavaScript event listeners when it loads in the browser. In traditional browser-only React apps, we build our entire DOM hierarchy right in the browser itself. In an SSR app, however, our DOM hierarchy will already be rendered on the server when we first load our app.

We’re also using React Router‘s <BrowserRouter> on the client, which will take over when our client JS loads and use the HTML5 history API to allow for quick, client-side navigation of our app. The <App /> component itself is shared between the client and the server parts of our solution. We’ll get to the <App /> component in a moment.

The Server

We’ll modify Razzle’s server.js a bit to move the HTML template to its own file.

If you’re familiar with SSR, server.js shouldn’t look too crazy to you. Our Express server builds our React app and renders it to a string. Instead of using a <BrowserRouter>, which wouldn’t work on the server, it uses a <StaticRouter> to wrap our common <App /> component.

We’ve moved Razzle’s HTML template to its own file and written a render() function that server.js uses to build that HTML. Let’s take a look at all that.

Our index.html contains a bread-and-butter HTML template. We’ve added special <!--__HEAD__--> and <!--__APP__--> comments that we replace with our head content and rendered app, respectively.

render() reads our HTML file template and injects into it special Razzle CSS and JS strings, which change depending on whether we’re in development or production mode. The CSS will include any CSS files we import into our React components. The JS will be the standard Webpack bundle with React and our app logic.

OK, with that scaffolding out of the way, let’s get to our demo app’s meat and potatoes (or potatoes and chivesΒ  if you’re vegetarian – maybe with some almond oil πŸ₯™).

Our Pages

Our app will have two routes/pages: a home page and a game index page.

We’re using the Bulma CSS framework for styling. We’re also using React Router’s standard <Switch> component, which renders only the first <Route> whose path matches the current URI. What’s interesting here, of course, is that Razzle makes sure that these <Route>s work in an isomorphic fashion: <BrowserRouter> will handle them on the client, and <StaticRouter> on the server. This means our <App /> can be rendered on the server side, and then hydrated on the client side to become interactable. One <App />, two worlds.

The Navigation Bar

Our <App /> is using a common <Navbar />. Let’s take a look at it.

A standard Bulma .navbar, our component has a little state for showing/hiding its links on mobile devices. The <Navbar /> also has React Router <Link>s to our two pages. It’s important that we use <Link> components and not HTML <a> tags here, since the behaviour of the <Link>s will change when we localize our routes a bit later. At the end of our component we nest a <LangSwitcher />.

A simple drop-down menu, our <LangSwitcher /> is currently not doing much. It will enable our users to change our app’s language when we localize a bit later. Note that here we want to use <a> tags and not React Router <Link> components, as opposed to our regular navigation links above. This is because we want our language links to be static, the reason for which will become apparent when we get to localized routes (all coming soon, promise).

The Home Page

A simple static home page welcomes our users and links to the games index page.

Game night is the best!

Note, again, the <Link> component for internal links within our app. Also note the horror of hard-coded English text. We’ll localize this text soon.

Our Games Index Page

True fact: The Mansion of Happiness is ancient

Our <GameIndex /> is displaying mock data from a simple object that we keep in a data.js file in our src directory. For simplicity, we’re not loading our data from the network in this demo.

We have our data under an en key here, so we can add other locales to this file when we localize. Note that our <GameIndex /> nests a <Voting /> component for each game. <Voting /> is responsible for displaying the current number of votes, as well as buttons for up- and down-voting, per game.

Again, we’re not really syncing vote updates with the server here. The main reason <Voting /> exists is to showcase what happens when our app hydrates on the browser. In fact, try loading the /games route right now after disabling JavaScript in your browser. You’ll see the all the content of the <GameIndex /> page, except the voting buttons won’t work. This is because, with JavaScript disabled, the <Voting /> component’s button onClick handlers won’t be connected by client-side React (we never hydrate the app on the browser).

Alright, that’s basically our app’s logic. Let’s internationalize this puppy.

πŸ”— Resource Β» If you want the code of the app exactly at this point, before i18n and l10n, check out the start branch in the app’s repo on Github.

Internationalizing with React Intl

We won’t dive deep with React Intl here. That’s the subject of its own article. Instead, we’ll set up the library, use it to translate our app, and then look at some of the considerations that SSR brings to i18n.

Setting up React Intl

Installing React Intl is the usual drill.

Once the library is installed, we can wrap the contents of our <App /> with its <IntlProvider> component.

<IntlProvider> will ensure that, when we use React Intl’s formatting components within our app hierarchy, they will have the current locale and translation messages available to them. Speaking of translation messages, let’s set them up.

πŸ—’ Note Β» The fastidious reader will have noticed that we are currently hard-coding our active locale to English. We’ll make this is more dynamic in later sections, when we detect and set the user’s locale.

Translating Messages

React Intl messages can be simple key-value pairs.

For each supported locale, Arabic and English in our case, we have a mirrored set of messages, largely simple strings. The games.addedOn message has an interpolated parameter, addedOn, which we tell React Intl to format as a medium-length date.

Let’s start replacing our app’s hard-coded strings with our translated messages. Here’s a sample of what that looks like:

For simple messages, we simply use React Intl’s <FormattedMessage /> component and provide it with an id param that corresponds to the key of the message we want to display.

The game.addedOn message has a param, which it expects to be a Date. So we convert our data’s date string to a Date object and pass it in the respective <FormattedMessage />‘s value prop.

Getting the Active Locale

In our updated <GameIndex /> component above, we use React Intl’s useIntl() react hook to get the intl object, which has a useful API. One thing we can do with the intl object is ask it for the active locale via the intl.locale attribute. Since we set <IntlProvider locale="en" />, intl.locale will have a value of "en".

Special i18n Considerations in an SSR App

So far, all our i18n work has been what we would normally do if we were building a traditional browser-only React app. Things start to get a little trickier when we try to determine the user’s locale without her input. We do this a bit differently on the server then we do on the client. Let’s start with the logic that’s common to the client and the server and take it from there.

Supported and Default Locales

React Intl, as far as I know, doesn’t have a way to configure supported locales, ie. languages that our app guarantees translations for. We set up our own in a little i18n util module, in a supportedLangs object. We need this, along with our defaultLang to help us determine the user’s locale (since his locale might not be supported by our app).

Determining the User’s Locale

The logic for determining the user’s locale is, of course, in the determineUserLang() function. The function takes an acceptedLangs array which corresponds to the locales the user set in her browser (something like ["en-CA", "ar-EG"]). To keep things simple, we don’t deal with language variants: we support Arabic, not Egyptian Arabic, for example. So determineUserLang() strips the country parts of the language codes in acceptedLangs, yielding something like ["en", "ar"]. The function then checks to see if any of the languages it was given is in our supportedLangs. It returns the first accepted language that is supported by us, or falls back to our defaultLang if no accepted languages are supported.

This is all well and good, but where do we get the accepted languages from in the first place? This depends on whether we’re on the client or the server.

πŸ“– Go Deeper Β» If you want to dive into user locale detection, check out our article dedicated to the subject, Detecting a User’s Locale in a Web App.

Getting Accepted Languages on the Server

When handling a request, our Express server will often be given an Accept-Language HTTP header. This header contains a list of the locales the user has set in her browser’s preferences, and they indicate the languages she wants to see web content in. Express exposes this list as a string array through its request object, via req.acceptsLanguages().

Revisiting our server.js, we can access the req.acceptsLanguages() in our route handler, and pass it along to determineUserLang() to get either (a) a language that the user wants and that we support or (b) our fallback default language. Either way, determineUserLang() won’t let us down and will give us a language code to work with. We can then pass this language code to our <App /> component, as a lang prop, before we render it on the server. We’ll see how we’re using the lang prop in a moment. Let’s take a look at the analogue of our current logic on the client first.

Getting Accepted Languages on the Client

In most modern browsers, the exact same list of user-preferred languages, which the browser provides to servers via the Accept-Language HTTP header, is available to JavaScript client-side via the navigator.languages array.

In our client.js, we add the call to determineUserLang(), and pass the function the value of navigator.languages, falling back to an empty array if the former property is undefined. Again, we pass the determined user lang to our <App /> component before we hydrate our app.

Setting the User Locale in our IntlProvider

Let’s see what this <App lang={lang}> business is all about.

Whether we’re rendering on the server, or hydrating on the client, we pass our user’s preferred locale to the <IntlProvider> as its locale prop, which sets the active locale for our app. Our <FormattedMessages /> will take their translations from the object that corresponds to the active locale, effectively showing our app in that language. We also make sure to refine the messages we pass to <IntlProvider> to the ones matching the active locale. The defaultLocale prop is given our configured defaultLang, which ensures that React Intl falls back to this locale if it can’t find a translation for a message in the active locale.

Localizing Routes

When localizing our apps, we often want routes that look like /en/foo or /ar/foo. Routes like this make switching the locale of our app pretty straightforward. Thankfully, React Router’s basename props make implementing this kind of route a breeze.

Localized Client Routes

We have to set the basename prop on the BrowserRouter on the client-side. This prop will make all our app’s defined <Route>s namespaced underneath the basename. So, if we declare <BrowserRouter basename="en">, then our <Route path="/games"> will match the URI /en/games.

We use determineUserLang() to get the user’s preferred locale, and we pass it as the basename to our <BrowserRouter>. As far as route-matching, React Router takes care of the rest.

πŸ—’ Note Β» We’ve updated our determineUserLang() to take a second param. This param is used to determine the user’s locale from the current route. The browser provides the current route, or URI, in its window.location.pathname string. We’ll get to how we’re using this URI for locale determination shortly.

Localized Server Routes

Our server logic is very similar to our client’s. We again use determineUserLang() to get the user’s preferred locale. We pass the function req.path this time, however, since that’s how the Express server exposes the current request’s URI. We also redirect requests to the root route (/), to a localized root route, e.g. (/en), which will depend on the result of determineUserLang(). This is so we can ensure that we’re always on a localized route, which simplifies our app’s logic. We provide the result of determineUserLang() to our <StaticRouter> via its own basename prop, much like we did on the client with <BrowserRouter>.

Determining the User’s Locale from the Current URI

Setting the locale in our app’s URIs means that we need to use the URIs when we determine our app’s active locale. Whether we’re getting the current URI on the client or the server, the logic for extracting the locale from the URI is the same. We can update our determineUserLang() function to encapsulate that logic.

We favour the locale in the current URI of the app, e.g. /en/foo, and use that locale if we support it. Otherwise, we do our previous checks against the accepted user languages, using the first supported one if we find any. And, of course, we fall back to our default locale if other checks came out empty.

The current URI can now determine the active locale

βœ‹πŸ½ Heads Up Β» Non-localized URIs will still work if entered manually by the user into her browser. Everything will work fine: the active locale will be determined as it was before we had localized URIs. An error will appear in the JavaScript console in the browser, but the app should work ok nonetheless.

A Note on basename and Link Components

Remember how we’ve been using React Router’s <Link> components to provide internal links in our app. Well, <Link>s will work along with <BrowserRouter> and <StaticRouter>‘s basename prop, and adjust their links accordingly. So a link like <Link to="/games"> in our app will now render to <a href="/en/games"> if the active locale is English. This is exactly what we want, and why we don’t have to update our app’s links if we’ve been using <Link> components for them. The one exception to this is our <LangSwitcher />, which we can now, given that we have localized routes, make fully functional.

A Working Language Switcher

We have everything we need to make our language switcher, well…switch the active language.

We spin over our supported languages and show a link for each one that points to its respective localized root route. So, for English, for example, we show <a href="/en">English</a>.

Behold, for our language switcher does switch languages

πŸ”— Resource Β» Get all the code for this app on Github.

Caio for Now

Our localized SSR app now has the building blocks of extensible i18n, while rendering on the server and hydrating on the client. Take your i18n game to the next level withΒ Phrase. A professional localization solution for your whole team, Phrase features webhooks, multiple format support, GitHub and Bitbucket sync, a flexible API, an extensive CLI, and a whole lot more. Check out all of Phrase’s features, and sign up for a free 14-day trial.

5 (100%) 20 votes