Software localization
Localized Server-Side Rendering with React
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.
🔗 Resource » Check out our Ultimate Guide to JavaScript Localization for everything you need to make your JS apps ready for a global userbase.
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.
Razzle
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
.
npx create-razzle-app boardaki
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.
import React from "react"; import { hydrate } from "react-dom"; import { BrowserRouter } from "react-router-dom"; import App from "./common/App"; hydrate( <BrowserRouter> <App /> </BrowserRouter>, document.getElementById("root"), ); if (module.hot) { module.hot.accept(); }
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.
import React from "react"; import express from "express"; import { StaticRouter } from "react-router-dom"; import { renderToString } from "react-dom/server"; import App from "./common/App"; import render from "./server/render"; const server = express(); server .disable("x-powered-by") .use(express.static(process.env.RAZZLE_PUBLIC_DIR)) .get("/*", (req, res) => { const context = {}; const markup = renderToString( <StaticRouter context={context} location={req.url}> <App /> </StaticRouter>, ); if (context.url) { res.redirect(context.url); } else { const html = render(markup); res.status(200).send(html); } }); export default server;
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.
<!DOCTYPE html> <html lang=""> <head> <meta http-equiv="X-UA-Compatible" content="IE=edge" /> <meta charset="utf-8" /> <title>Boardaki</title> <meta name="viewport" content="width=device-width, initial-scale=1" /> <script defer src="https://use.fontawesome.com/releases/v5.3.1/js/all.js" ></script> <!--__HEAD__--> </head> <body> <div id="root"><!--__APP__--></div> </body> </html>
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.
const fs = require("fs"); const path = require("path"); // We'll be in the /build dir when this runs, so we find the template // file relative to /build. const htmlTemplateFilename = path.resolve( __dirname, "..", "src", "server", "index.html", ); const assets = require(process.env.RAZZLE_ASSETS_MANIFEST); function render(markup) { let html = fs.readFileSync(htmlTemplateFilename, "utf8"); const css = assets.client.css ? `<link rel="stylesheet" href="${assets.client.css}">` : ""; const js = process.env.NODE_ENV === "production" ? `<script src="${assets.client.js}" defer></script>` : `<script src="${assets.client.js}" defer crossorigin></script>`; html = html.replace("<!--__HEAD__-->", css + "\n" + js); html = html.replace("<!--__APP__-->", markup); return html; } export default render;
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.
import React from "react"; import { Route, Switch } from "react-router-dom"; import Navbar from "./Navbar"; import Home from "./pages/Home"; import GameIndex from "./pages/GameIndex"; import "bulma/css/bulma.css"; const App = () => ( <> <Navbar /> <section className="section"> <div className="container"> <Switch> <Route exact path="/" component={Home} /> <Route path="/games" component={GameIndex} /> </Switch> </div> </section> </> ); export default App;
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.
import React, { useState } from "react"; import { Link } from "react-router-dom"; import LangSwitcher from "./LangSwitcher"; function Navbar() { const [isActive, setActive] = useState(false); return ( <nav role="navigation" aria-label="main navigation" className="navbar is-primary has-shadow" > <div className="container"> <div className="navbar-brand"> <Link to="/" className="navbar-item"> <strong>Boardaki</strong> </Link> <a role="button" aria-label="menu" aria-expanded="false" className="navbar-burger burger" onClick={() => setActive(!isActive)} > <span aria-hidden="true"></span> <span aria-hidden="true"></span> <span aria-hidden="true"></span> </a> </div> <div className={`navbar-menu ${isActive ? "is-active" : ""}`}> <div className="navbar-start"> <Link to="/" className="navbar-item"> Home </Link> <Link to="/games" className="navbar-item"> Games </Link> </div> <div className="navbar-end"> <LangSwitcher /> </div> </div> </div> </nav> ); } export default Navbar;
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 />
.
import React from "react"; export default function LangSwitcher() { return ( <div className="navbar-item has-dropdown is-hoverable"> <a className="navbar-link">Language</a> <div className="navbar-dropdown is-right"> <a className="navbar-item">English</a> <a className="navbar-item">Arabic (عربي)</a> </div> </div> ); }
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!
import React from "react"; import { Link } from "react-router-dom"; function Home() { return ( <div className="Home"> <div className="columns"> <div className="column"> <figure className="image"> <img src="/img/home.jpg" /> </figure> </div> <div className="column"> <h2 className="is-size-3">Find Your Next Game</h2> <p> Vote with thousands of other players on games and find your favourite! </p> <p style={{ marginTop: "1rem" }}> <Link to="/games" className="button is-link"> Check out the games! </Link> </p> </div> </div> </div> ); } export default Home;
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
import React from "react"; import games from "../data"; import "./GameIndex.css"; import Voting from "../components/Voting"; function GameIndex(props) { return ( <div className="columns" style={{ flexWrap: "wrap" }}> {games["en"].map(game => ( <div className="column is-one-third" key={game.id}> <div className="card"> <div className="card-image"> <figure className="image GameIndex__ImageContainer"> <img src={game.imageUrl} className="GameIndex__Image" /> </figure> <div className="card-content"> <h3 className="title is-6" style={{ marginBottom: "0.5rem" }}> {game.title} </h3> <p style={{ marginBottom: "0.5rem" }}> <small>Added {game.addedOn}</small> </p> <Voting initialVoteCount={game.initialVoteCount} /> </div> </div> </div> </div> ))} </div> ); } export default GameIndex;
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.
export default { en: [ { id: 1, title: "Catan", addedOn: "2020-01-15", imageUrl: "/img/games/catan.jpg", initialVoteCount: 12, }, { id: 2, title: "Dominion", addedOn: "2020-01-20", imageUrl: "/img/games/dominion.jpg", initialVoteCount: 22, }, // ... ], };
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.
import React, { useState } from "react"; function Voting(props) { const [voteCount, setVoteCount] = useState(props.initialVoteCount); const voteCountTextClass = voteCount < 0 ? "has-text-danger" : "has-text-success"; return ( <p style={{ display: "flex", alignItems: "center" }}> <strong className={voteCountTextClass} style={{ marginRight: "1rem", width: "1.5rem" }} > {voteCount} </strong> <button className="button" style={{ marginRight: "0.25rem" }} onClick={() => setVoteCount(voteCount + 1)} > <span className="icon is-small"> <i className="far fa-thumbs-up"></i> </span> </button> <button className="button" onClick={() => setVoteCount(voteCount - 1)}> <span className="icon is-small"> <i className="far fa-thumbs-down"></i> </span> </button> </p> ); } export default Voting;
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.
npm install --save react-intl
Once the library is installed, we can wrap the contents of our <App />
with its <IntlProvider>
component.
import React from "react"; import { IntlProvider } from "react-intl"; import { Route, Switch } from "react-router-dom"; import Navbar from "./Navbar"; import Home from "./pages/Home"; import messages from "./lang/messages"; import GameIndex from "./pages/GameIndex"; import "bulma/css/bulma.css"; const App = () => ( <IntlProvider locale="en" messages={messages["en"]}> <Navbar /> <section className="section"> <div className="container"> <Switch> <Route exact path="/" component={Home} /> <Route path="/games" component={GameIndex} /> </Switch> </div> </section> </IntlProvider> ); export default App;
<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.
export default { en: { "app.title": "Boardaki", "nav.home": "Home", "nav.games": "Games", "home.title": "Find Your Next Game", "home.lead": "Vote with thousands of other players on games and find your favourite!", "home.c2a": "Check out the games!", "games.addedOn": "Added {addedOn, date, medium}", }, ar: { "app.title": "بورداكي", "nav.home": "الرئيسية", "nav.games": "الألعاب", "home.title": "أجد لعبتك التالية", "home.lead": "قم بالتصويت مع الآلاف من اللاعبين الآخرين على الألعاب وابحث عن المفضلة لديك!", "home.c2a": "تحقق من الألعاب!", "games.addedOn": "أضيف {addedOn, date, medium}", }, };
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:
import React from "react"; import { Link } from "react-router-dom"; import { FormattedMessage } from "react-intl"; function Home() { return ( <div className="Home"> <div className="columns"> <div className="column"> <figure className="image"> <img src="/img/home.jpg" /> </figure> </div> <div className="column"> <h2 className="is-size-3"> <FormattedMessage id="home.title" /> </h2> <p> <FormattedMessage id="home.lead" /> </p> <p style={{ marginTop: "1rem" }}> <Link to="/games" className="button is-link"> <FormattedMessage id="home.c2a" /> </Link> </p> </div> </div> </div> ); } export default Home;
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.
import React from "react"; import { useIntl, FormattedMessage } from "react-intl"; import games from "../data"; import "./GameIndex.css"; import Voting from "../components/Voting"; function GameIndex(props) { const intl = useIntl(); return ( <div className="columns" style={{ flexWrap: "wrap" }}> {games[intl.locale].map(game => ( <div className="column is-one-third" key={game.id}> <div className="card"> <div className="card-image"> <figure className="image GameIndex__ImageContainer"> <img src={game.imageUrl} className="GameIndex__Image" /> </figure> <div className="card-content"> <h3 className="title is-6" style={{ marginBottom: "0.5rem" }}> {game.title} </h3> <p style={{ marginBottom: "0.5rem" }}> <small> <FormattedMessage id="games.addedOn" values={{ addedOn: new Date(game.addedOn) }} /> </small> </p> <Voting initialVoteCount={game.initialVoteCount} /> </div> </div> </div> </div> ))} </div> ); } export default GameIndex;
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.
export const defaultLang = "en"; export const supportedLangs = { en: "English", ar: "Arabic (عربي)", }; export function determineUserLang(acceptedLangs) { const acceptedLangCodes = acceptedLangs.map(stripCountry); const supportedLangCodes = Object.keys(supportedLangs); const matchingLangCode = acceptedLangCodes.find(code => supportedLangCodes.includes(code), ); return matchingLangCode || defaultLang; } function stripCountry(lang) { return lang .trim() .replace("_", "-") .split("-")[0]; }
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()
.
import React from "react"; import express from "express"; import { StaticRouter } from "react-router-dom"; import { renderToString } from "react-dom/server"; import App from "./common/App"; import render from "./server/render"; import { determineUserLang } from "./common/i18n"; const server = express(); server .disable("x-powered-by") .use(express.static(process.env.RAZZLE_PUBLIC_DIR)) .get("/*", (req, res) => { const context = {}; const lang = determineUserLang(req.acceptsLanguages()); const markup = renderToString( <StaticRouter context={context} location={req.url}> <App lang={lang} /> </StaticRouter>, ); if (context.url) { res.redirect(context.url); } else { const html = render(markup); res.status(200).send(html); } }); export default server;
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.
import React from "react"; import { hydrate } from "react-dom"; import { BrowserRouter } from "react-router-dom"; import App from "./common/App"; import { determineUserLang } from "./common/i18n"; const lang = determineUserLang(navigator.languages || []); hydrate( <BrowserRouter> <App lang={lang} /> </BrowserRouter>, document.getElementById("root"), ); if (module.hot) { module.hot.accept(); }
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.
import React from "react"; import { IntlProvider } from "react-intl"; import { Route, Switch } from "react-router-dom"; import Navbar from "./Navbar"; import Home from "./pages/Home"; import { defaultLang } from "./i18n"; import messages from "./i18n/messages"; import GameIndex from "./pages/GameIndex"; import "bulma/css/bulma.css"; const App = ({ lang }) => ( <IntlProvider locale={lang} messages={messages[lang]} defaultLocale={defaultLang} > <Navbar /> <section className="section"> <div className="container"> <Switch> <Route exact path="/" component={Home} /> <Route path="/games" component={GameIndex} /> </Switch> </div> </section> </IntlProvider> ); export default App;
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
.
import React from "react"; import { hydrate } from "react-dom"; import { BrowserRouter } from "react-router-dom"; import App from "./common/App"; import { determineUserLang } from "./common/i18n"; const lang = determineUserLang( navigator.languages || [], window.location.pathname, ); hydrate( <BrowserRouter basename={`/${lang}`}> <App lang={lang} /> </BrowserRouter>, document.getElementById("root"), ); if (module.hot) { module.hot.accept(); }
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 itswindow.location.pathname
string. We'll get to how we're using this URI for locale determination shortly.
Localized Server Routes
import React from "react"; import express from "express"; import { StaticRouter } from "react-router-dom"; import { renderToString } from "react-dom/server"; import App from "./common/App"; import render from "./server/render"; import { determineUserLang } from "./common/i18n"; const server = express(); server .disable("x-powered-by") .use(express.static(process.env.RAZZLE_PUBLIC_DIR)) .get("/*", (req, res) => { const context = {}; const lang = determineUserLang(req.acceptsLanguages(), req.path); if (req.path.trim() === "/") { res.redirect(`/${lang}`); } const markup = renderToString( <StaticRouter context={context} location={req.url} basename={`/${lang}`}> <App lang={lang} /> </StaticRouter>, ); if (context.url) { res.redirect(context.url); } else { const html = render(markup); res.status(200).send(html); } }); export default server;
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.
export const defaultLang = "en"; export const supportedLangs = { en: "English", ar: "Arabic (عربي)", }; export function determineUserLang(acceptedLangs, path = null) { // check url for /en/foo where en is a supported language code if (path !== null) { const urlLang = path.trim().split("/")[1]; const matchingUrlLang = findFirstSupported([stripCountry(urlLang)]); if (matchingUrlLang) { return matchingUrlLang; } } // check browser-set accepted langs const matchingAcceptedLang = findFirstSupported( acceptedLangs.map(stripCountry), ); return matchingAcceptedLang || defaultLang; } function findFirstSupported(langs) { const supported = Object.keys(supportedLangs); return langs.find(code => supported.includes(code)); } function stripCountry(lang) { return lang .trim() .replace("_", "-") .split("-")[0]; }
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.
import React from "react"; import { useIntl } from "react-intl"; import { supportedLangs } from "./i18n"; export default function LangSwitcher() { const { locale: lang } = useIntl(); return ( <div className="navbar-item has-dropdown is-hoverable"> <a className="navbar-link">{supportedLangs[lang]}</a> <div className="navbar-dropdown is-right"> {Object.keys(supportedLangs).map(code => ( // We want absolute URLs here, e.g. /en, so that our app // will reload and switch to the selected language. So we // use <a> instead of React Router's <Link>, since the // latter will always prefix its URLs with the basename // (the current language). <a key={code} href={`/${code}`} className={`navbar-item ${code === lang ? "is-active" : ""}`} > {supportedLangs[code]} </a> ))} </div> </div> ); }
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 products, and sign up for a free 14-day trial.
Last updated on October 27, 2022.