Building an Awesome Magazine App with i18n in React (2023)

When building React single-page applications with i18n and l10n, a few concerns come into play: routing and links, locale switching, i18n-ized UI, and of course localized content. Thankfully, React's component modules, Redux's Flux architecture, and a handful of other libraries can help us quickly whip up i18n-ized prototypes—which we can turn into full-on production apps.

The movie magazine app

Let's assume we've been commissioned to build a clean prototype for μveez, a new i18n-ized movie magazine app. Our client has asked that we build it as an SPA with the React view framework since React came highly recommended by her colleagues for performant SPAs. We've been asked to build two prototype SPAs, actually: one for the admin panel and one for the front-facing website. The agreed feature list is as follows.


  • μveez will initially support Arabic, English, and French


  • Film director index with names in supported locales
  • Adding a director with translations in supported locales
  • Movie index with titles in supported locales
  • Adding a movie with translated titles and synopses in supported locales


  • Language switching between our supported locales, covering RTL directionality for Arabic
  • Home page with featured directors, quote of the day, and featured movies
  • Movie index
  • Single (show) movie


Note » I'll assume that you have a basic working knowledge of React and Redux.

Alright, we've worked with React before, so we know that we'll likely want to adopt a Flux architecture. Flux's uni-directional data flow makes it easy to reason about our app state, and places this state in one DRY store. Redux is a well-supported Flux implementation, so we'll use that. We'll also need to handle routing and basic i18n UI. To get going quickly, we can pull in Bootstrap for a CSS framework.

Our entire framework (with versions at time of writing) can, then, look like this.

A Little Organization: Directory Structure

We'll adopt the common differentiation between React state-awarecontainers and presentational components. We'll also want to place our Redux actions and reducers in logical locations. And we may well need a place for our apps' services. Given that we bootstrap our apps with create-react-app, our directory structure can be this beauty:

/├── public/│ ├── api/│ ├── img/│ ├── styles/│ ├── translations/│ └── index.html└── src/ ├── actions/ ├── components/ ├── config/ ├── containers/ ├── reducers/ ├── services/ ├── styles/ ├── index.js └── routes.js

We'll mock our server back-end with JSON files that we place in the public/apidirectory, and we'll explore these files in detail later. For now, let's get to to building! We'll start with the admin panel.

Note» If you're a React / Redux guru and you just want to get to the juicy i18n config, UI, and routing bits, you may want to skip ahead to our building of the front-facing magazine app.

The Admin Panel

Note» You can see a live demo of the admin panel on Heroku. You can also find all of the panel's source codeon GitHub.


Our Store

Let's setup our Redux store.


import React from 'react'import ReactDOM from 'react-dom'import { Provider } from 'react-redux'import App from './components/App'import store from './services/store'ReactDOM.render( <Provider store={store}> <App/> </Provider>, document.getElementById('root'))

If you've used React and Redux before, this is pretty standard stuff. We're simply wrapping our whole app in the Redux store Providerso that our store is available to any Appsubcomponent that needs it. To keep things clean, we've housed our store in its own file. Let's take a quick look at it.


import { createStore, applyMiddleware, compose } from 'redux'import thunk from 'redux-thunk'import reducer from '../reducers'let composeEnhancers = nullif (process.env.NODE_ENV === 'development' && window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__) { composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__} else { composeEnhancers = compose}const store = createStore(reducer, composeEnhancers(applyMiddleware(thunk)))export default store

We bring in Redux Thunk as middleware to handle asynchronous Flux actions. The handy Redux Devtools browser extensionis tied in to help us debug our state in our development environment. Our reducerswill be explored as we dive into each of our view models. Now let's get to routing.


We'll assume that our admin panel UI can be in English only, so we won't worry about i18n-ized routes until we get to our front-facing app. For now, we can configure our admin's routes as per our requirements.


import Home from './components/Home'import Movies from './components/Movies'import AddMovie from './containers/AddMovie'import Directors from './components/Directors'const routes = [ { path: "/", exact: true, component: Home }, { path: "/directors", component: Directors }, { path: "/movies", exact: true, component: Movies }, { path: "/movies/new", exact: true, component: AddMovie }]export default routes

We'll have a home page, a directors index (which will include a simpleAdd Director form), and a movies index. The form for adding a movie will be relatively large, so it's broken out into its component. Again, we'll get to each of these components, as well as their respective reducers and actions, a bit later. For now, let's round out our scaffolding by implementing our routing and creating our basic app layout.


import React from 'react'import { Switch, Route, BrowserRouter as Router } from 'react-router-dom'import routes from '../routes'import AppNavbar from '../components/AppNavbar'import AppFooter from '../components/AppFooter'export default () => ( <div style={{paddingTop: "80px"}}> <Router> <div> <AppNavbar /> <div className="container"> <main id="main" role="main"> <Switch> {, index) => ( <Route key={index} path={route.path} exact={route.exact} component={route.component} /> ))} </Switch> </main> </div> <AppFooter /> </div> </Router> </div>)

We spin over our configured routes and render a Routecomponent for each one. We wrap the majority of our app in the requisite BrowserRoutercomponent (aliased as Router), and use Bootstrap's .containerfor layout.

Note » If you're not familiar with React Router, check out its excellent documentation.

Our AppNavbarand AppFooterare pretty much presentational here, so we'll skip their dissection for brevity. You can check them out in the GitHub repo if you're curious about how they're coded.

You may have noticed that our root component is Home, which means that we'll render that component when we hit our /route. Home is a simple, stateless functional component. Let's take a look at it.


import React from 'react'import { Row, Col, Card, CardHeader, ListGroup, ListGroupItem,} from 'reactstrap'import { Link } from 'react-router-dom'export default () => ( <Row className="justify-content-center"> <Col sm="10" md="7" lg="5" xl="4"> <h2>Welcome!</h2> <Card> <CardHeader tag="h3" className="h4"> Manage </CardHeader> <ListGroup flush> <ListGroupItem> <Link to="/directors">Directors</Link> </ListGroupItem> <ListGroupItem> <Link to="/movies">Movies</Link> </ListGroupItem> </ListGroup> </Card> </Col> </Row>)

We use reactstrap's Bootstrap components to style, responsively size, and centre our list of Links. That's it really.

Here's a look at our app so far.

Building an Awesome Magazine App with i18n in React (1)

Not bad for a prototype. Thank you, Twitter Bootstrap 🙏🏽.

Alright, that's our admin panel scaffolded. Let's to get to our model CRUD. We'll start with directors.

Director CRUD

We can start with a Directors component that will contain our AddDirectorsform and DirectorListindex.

Note » As per as our client's requirements, we're skipping updating and deleting director functionality. This is a proof of concept prototype after all.


import React from 'react'import AddDirector from '../containers/AddDirector'import DirectorList from '../containers/DirectorList'export default () => ( <div> <h2 style={{marginBottom: "20px"}}>Directors</h2> <AddDirector style={{marginBottom: "20px"}}/> <DirectorList /> </div>)

Our DirectorListwill need to load data from our mock API. Let's take a look at some of this JSON.

/src/public/api/directors.json (excerpt)

[ { "id": 1, "name_ar": "كرستوفر نولان", "name_en": "Christopher Nolan", "name_fr": "Christopher Nolan" }, { "id": 2, "name_ar": "ميشيل جوندري", "name_en": "Michael Gondry", "name_fr": "Michael Gondry" }, // ...]

This is how we would expect a request like GET /admin/api/directorsto respond. We can consume this "API" and present it in our views. Let's take a look at how our director list would look like.

Building an Awesome Magazine App with i18n in React (2)

A simple <table>should be good to get us started. We just need to pull in the data from our JSON file and load it into this table, minding our separation of concerns. If you know the ways of Reacty Reduxy Fluxy kung-fu, you know what's next: a reducer, young grasshopper.


import _ from 'lodash'const INITIAL_STATE = { directors: [],}export default (state = INITIAL_STATE, action) => { let directors = [] switch(action.type) { case 'ADD_DIRECTORS': directors = _.unionBy(action.directors, state.directors, 'id') return { ...state, directors } default: return state }}

Our ADD_DIRECTORSaction will reduce our state to the given list of directors, merging it in with whatever directors are currently loaded. We use the popular utility library, Lodash, and its handy unionBy function, to help us with the merge.

Next, we'll need to write a couple of actions that fetch our existing directors and add them to our app state.


export const fetchDirectors = () => dispatch => ( fetch('/api/directors.json') .then(response => response.json()) .then(directors => dispatch(addDirectors(directors))) .catch(err => console.error(err)))export const addDirectors = directors => ({ type: 'ADD_DIRECTORS', directors})

The fetchDirectorsaction is asynchronous. The Redux Thunk middleware will notice that we're returning a function from fetchDirectorsand step in to handle the action. It will also provide the returned function with a dispatcher to allow us to call other actions.

We use the standardfetchAPI to make an async request that asks for our mock JSON. Once we get that JSON, we call our addDirectors action with the directors we've received. Of course, our directors reducer is already set up to handle this action and update our app state.

Note »fetchis widely supported, but not 100%. Some older browsers do not support the relatively new API. If you want something closer to complete browser coverage, you may want to add a polyfill or use a library like axios for your XHR calls.

We can now build out our view.


import { Table } from 'reactstrap'import { connect } from 'react-redux'import React, { Component } from 'react'import { fetchDirectors } from '../actions'class DirectorList extends Component { componentDidMount() { this.props.fetchDirectors() } render() { return ( <Table> <thead className="thead-dark"> <tr> <th>id</th> <th className="text-right" style={{paddingRight: "5rem"}} > Name (Arabic) </th> <th>Name (English)</th> <th>Name (French)</th> </tr> </thead> <tbody> { => ( <tr key={}> <td>{}</td> <td className="text-right" style={{paddingRight: "5rem", maxWidth: "8rem"}} > {director.name_ar} </td> <td>{director.name_en}</td> <td>{director.name_fr}</td> </tr> ))} </tbody> </Table> ) }}export default connect( state => ({ directors: state.directors.directors }), { fetchDirectors })(DirectorList)

We simply map state.directors.directorsto a directorsprop in our React Component, fetch the existing directors when our component has mounted, and render out our directors as table rows. Bada boom, bada bing.

Let's get to adding a director in our demo admin app. First, we'll need some new bits of state.

/src/reducers/directors.js (excerpt)

const INITIAL_STATE = { lastId: 0, directors: [], newDirector: { name_ar: '', name_en: '', name_fr: '', },}// ...

Since we don't have a real back-end, we'll track the lastId of an added director in the browser memory. We'll also keep track of the entered translations of a newDirectoras the user enters them. We can use this new state to add the new director at the appropriate time.

Let's actually track our lastIdwhen we add directors.

/src/reducers/directors.js (excerpt)

// ... case 'ADD_DIRECTORS': directors = _.unionBy(action.directors, state.directors, 'id') lastId = _.maxBy(directors, 'id').id return { ...state, lastId, directors }// ...

Whenever we add directors in bulk, we get the lastIdadded to the "back-end" by getting the largest idin our current set of our directors.

Alright, let's get to our view on adding directors. While we're in the directors reducer, let's add a new action handler for tracking translation user input.

/src/reducers/directors.js (excerpt)

// ...import { defaultOnUndefinedOrNull } from '../services/util'//... case 'SET_NEW_DIRECTOR_NAME': return { ...state, newDirector: { name_ar: defaultOnUndefinedOrNull(action.name_ar, state.newDirector.name_ar), name_en: defaultOnUndefinedOrNull(action.name_en, state.newDirector.name_en), name_fr: defaultOnUndefinedOrNull(action.name_fr, state.newDirector.name_fr), } }// ...

To avoid undefinedand null values—which will cause React to throw an error when our AddDirector component is populating its text fields—we default our translations values to current state using the defaultOnUndefinedOrNullutility function. This function checks if its first parameter is undefinedor null, and if it is returns the second parameter. Otherwise it simply returns the first parameter.

When the user starts typing her Arabic translation, for example, the English and French translations will cycle through our app state, remaining as''(empty strings). When she moves on to writing her English translation, the Arabic translation will be maintained as she entered it.

A setNewDirectoraction will be dispatched to track our new director translations state.

/src/actions/index.js (excerpt)

// ...export const setNewDirector = ({ name_ar, name_en, name_fr }) => ({ type: 'SET_NEW_DIRECTOR_NAME', name_ar, name_en, name_fr,})// ...

Of course, ourAddDirectorview will be the sheer epitome of UX design.

Building an Awesome Magazine App with i18n in React (3)

Alan Cooper would be proud. Ok, ok. Let's get to the code.


import { connect } from 'react-redux'import React, { Component } from 'react'import { Card, Form, Button, CardBody, CardTitle,} from 'reactstrap'import { setNewDirector } from '../actions'import AddDirectorTranslation from '../components/AddDirectorTranslation'class AddDirector extends Component { _updateTranslation(key, value) { this.props.setNewDirector({ [key]: value }) } render() { return ( <Card style={}> <CardBody> <CardTitle>Add Director with Name</CardTitle> <Form inline> <AddDirectorTranslation dir="rtl" name="name_ar" label="Arabic" value={this.props.name_ar} onChange={value => this._updateTranslation("name_ar", value)} /> <AddDirectorTranslation name="name_en" label="English" value={this.props.name_en} onChange={value => this._updateTranslation("name_en", value)} /> <AddDirectorTranslation name="name_fr" label="French" value={this.props.name_fr} onChange={value => this._updateTranslation("name_fr", value)} /> <Button>Add</Button> </Form> </CardBody> </Card> ) }}export default connect( state => { const { name_ar, name_en, name_fr } = state.directors.newDirector return { name_ar, name_en, name_fr } }, { setNewDirector, })(AddDirector)

Reactstrap's presentational components are pulled in for styling. We also wire up our setNewDirectoraction to be dispatched whenever our AddDirectorTranslations are changed ie. whenever the user enters text. And, since AddDirectorTranslation’s internal text input is controlled, we make sure to pass it the relevant part of our newDirectorstate. This way we ensure uni-directional data flow. Our state is always the single source of truth about the newDirector’s translations. This keeps things nice and easy to reason about.

Let's dive into the AddDirectorTranslationcomponent just to see what it's composed of.


import React from 'react'import { FormGroup, Label, Input } from 'reactstrap'export default props => { const dir = props.dir || 'ltr' const { name, label, value, onChange } = props return ( <FormGroup className="mb-2 mr-sm-2 mb-sm-0"> <Label for={name} className="mr-sm-2" > {label} </Label> <Input dir={dir} id={name} type="text" name={name} value={value} onChange={e => onChange(} /> </FormGroup> )}

We default our input's directionality to left-to-right if none is provided by the developer. We also connect the synthetic onChangeinput event to the parent component, calling its provided onChange, delegating upwards. AddDirectorTranslationis effectively a presentational component that offers connections into its text input.

Ok, let's go back to our directors reducer. We'll update it to include the action handling logic that will add a new director to our state from user input.

/src/reducers/directors.js (excerpt)

// ... case 'ADD_DIRECTOR': lastId = state.lastId + 1 directors = [ ...state.directors, { id: lastId, name_ar: action.name_ar, name_en: action.name_en, name_fr: action.name_fr, } ] return { ...state, lastId, directors, newDirector: { name_ar: '', name_en: '', name_fr: '', }, }// ...

ADD_DIRECTORis handled by first incrementing our lastId, since we're going to be upping the count of thedirectorscollection in our state. We use this incremented value as the idof the director we're adding, and bring in the user-entered name translations of the director while we're at it. To clear out the text inputs, we make sure to reset the user-entered translation state when we reduce.

Ok, now we'll need a quick action that we can dispatch to add the new director.

/src/actions/index.js (excerpt)

// ...export const addDirector = ({ name_ar, name_en, name_fr }) => ({ type: 'ADD_DIRECTOR', name_ar, name_en, name_fr,})// ...

We can now call addDirectorfrom our view to finish up our add director functionality.

/src/containers/AddDirector.js (excerpt)

// ...import { addDirector, setNewDirector } from '../actions'class AddDirector extends Component { // ... _addDirector() { const { name_ar, name_en, name_fr } = this.props if (name_ar && name_en && name_fr) { this.props.addDirector({ name_ar, name_en, name_fr }) } } render() { return ( <Card style={}> <CardBody> <CardTitle>Add Director with Name</CardTitle> <Form inline> <AddDirectorTranslation ... /> {/* ... */} <Button onClick={() => this._addDirector()}>Add</Button> </Form> </CardBody> </Card> ) }}export default connect( state => { // ... }, { addDirector, setNewDirector, })(AddDirector)

We call _addDirector()when our Add button is clicked. The function does some rudimentary input validation, making sure all the translations have values, and then dispatches the addDirectoraction.

Building an Awesome Magazine App with i18n in React (4)

Note» It's "Michel" Gondry, not "Michael". The guy's French for God's sake.

Of course, in a production app, we would be making an API call when we add a director: something like POST /admin/api/movieswith the translated name params. We're just demoing here though, so we'll omit the server call for brevity.

Et voilà! Our add director demo is working 🚀

The movie index and add movie functionality are essentially more complex versions of the DirectorListand AddDirectorcomponents, respectively. From an i18n / l10n perspective they shed no new light on administrating models, so I won't go over movie admin here. You can play with movie admin in the demo app, and peruse all of the admin movie code inthe GitHub repo.

The Front-facing Magazine App

Alright, we show the client our admin panel prototype, and she wonders why there's no user authentication. We justify that this is just a proof of concept, and that the live app will of course have enforced SSL and best-practice auth. She squints at us, and then asks to see the front-facing, public app. We talk about PM, that we're showing her what we have as soon as we build it, and that we'll get to the public app next. She squints harder at us.

Let's roll up our sleeves and get to cooking our second dish.

Note»You can see alive demo of the front-facing appon heroku. You can also find all of the app’s source codeon GitHub.


The scaffolding for our front-facing app is largely the same as our admin panel; it will have a very similar directory structure and a Redux store. There are, however, some differences regarding i18n and routing. Remember that unlike our admin panel, our front-facing app needs to be be i18n-ized and localized. In fact, a lot of the scaffolding unique to our public app deals with just this i18n and l10n. Let's take a look.



export const defaultLocale = "en"export const locales = [ { code: "ar", name: "عربي", dir: "rtl" }, { code: "en", name: "English", dir: "ltr" }, { code: "fr", name: "Français", dir: "ltr" }]

Configuring our supported locales in one place keeps things DRY and facilitates reuse. We'll want locale names in their respective languages to use in a language switcher. Since we are supporting Arabic, we'll also want to know a locale's directionality when we switch to it.



import React from 'react'import { Redirect } from 'react-router-dom'import Home from './components/Home'import Movies from './containers/Movies'import { defaultLocale } from './config/i18n'import SingleMovie from './containers/SingleMovie'import { localizeRoutes } from './services/i18n/util'const routes = [ { path: "/", exact: true, localize: false, component: () => <Redirect to={`/${defaultLocale}`} /> }, { path: "/movies/:id", component: SingleMovie }, { path: "/movies", component: Movies }, { path: "/", component: Home }]export default localizeRoutes(routes)

Our locale determination will be based on the current URI. So /fr/movieswill respond with a French version of the movies index, for example. To make sure we always have a locale explicitly selected, we redirect the /route to our default locale. In this case it's English, so /will redirect to /en. React Router makes this quite easy with its Redirectcomponent.

Notice the localizeRoutes(routes)call above. We provide the localizeRoutes function so we don't have to include our locale parameter when we specify each of our routes. In actuality, however, we want all our routes prefixed by a segment corresponding to the current locale. So /movies/:idshould actually be /:locale/movies/:id. We can then use this :localeparameter to determine our app's current locale. Our localizeRoutes achieves this parameter prefixing, making use of our special localizeoption on our configured routes. Let's see how this simple mapper works.

/src/services/i18n/util.js (excerpt)

export function localizeRoutes (routes) { return => { // we default to localizing if (route.localize !== false) { return { ...route, path: prefixPath(route.path, ':locale') } } return { ...route } })}

We're just prefixing every route passed to us with the /:locale/route parameter and returning the prefixed routes. A dedicated l10n component will consume this parameter and use it to set our current locale. We'll see this in action a bit later.

Note »We use a simple prefixPathfunction above to separate our concerns. Check out the function in the GitHub repo if you want.

Ok, let's see how this all comes together. First let's take a look at our Appcontainer.


import React from 'react'import { connect } from 'react-redux'import { Route, Switch, BrowserRouter as Router} from 'react-router-dom'import routes from '../routes'import Localizer from './Localizer'import AppNavbar from '../components/AppNavbar'import AppFooter from '../components/AppFooter'const App = props => ( <div style={{paddingTop: "80px"}}> <Router> <Localizer> {props.uiTranslationsLoaded && <div> <AppNavbar /> <div className="container"> <main id="main" role="main"> <Switch> {, index) => ( <Route key={index} path={route.path} exact={route.exact} component={route.component} /> ))} </Switch> </main> </div> <AppFooter /> </div> } </Localizer> </Router> </div>)export default connect( state => ({ uiTranslationsLoaded: state.l10n.uiTranslationsLoaded }))(App)

Ok, most of what's up there looks quite similar to our admin panel. We do have a Switch, however, which may be new to you, and a custom Localizer.

The Switchcomponent makes routing more akin to what we're used to in server-side frameworks, meaning that it will render the first route it matches. If you remember, we had our /movies/:id route come before our /movies route in our config. Our Switchwill make sure that /movies/:idroute catches, and that we don't fall through to the /moviesroute, when we hit /movies/1/.

Note » Read more about the Switch component in the React Router documentation.

The Localizer Higher Order Component

The Localizercontainer is our own special sauce for setting the current locale based on the active URI. Notice that our Localizersits inside the Routercomponent. This is important, since Localizerwill need the /:localeroute parameter we defined in our routes to do its work.


import { Component } from 'react'import { connect } from 'react-redux'import { withRouter } from 'react-router-dom'import { locales } from '../config/i18n'import { setUiLocale } from '../services/i18n'import { switchHtmlLocale, getLocaleFromPath } from '../services/i18n/util'import { changeLocale, setUiTranslationsLoaded, setUiTranslationsLoading } from '../actions'class Localizer extends Component { constructor(props) { super(props) this.setLocale(getLocaleFromPath(this.props.location.pathname), true) this.props.history.listen(location => { this.setLocale(getLocaleFromPath(location.pathname)) }) } /** * Set the lang and dir attributes in the <html> DOM element, and * initialize our i18n UI library. * * @param {string} newLocale * @param {bool} force */ setLocale(newLocale, force = false) { if (force || newLocale !== this.props.locale) { this.props.changeLocale(newLocale) switchHtmlLocale( newLocale, locales.find(l => l.code === newLocale).dir, { withRTL: ['/styles/vendor/GhalamborM/bootstrap-rtl.css'] } ) this.props.setUiTranslationsLoading(true) setUiLocale(newLocale) .then(() => this.props.setUiTranslationsLoaded(true)) .catch(() => this.props.setUiTranslationsLoaded(false)) } } render() { return this.props.children }}export default withRouter( connect( state => ({ locale: state.l10n.locale }), { changeLocale, setUiTranslationsLoaded, setUiTranslationsLoading, } )(Localizer))

Ok, this may be a lot to take in. So let's break it up.

Setting the Locale

When we construct our Localizer, we call setLocaleto do some locale setup. By default and to be efficient, setLocalewill check to see if our locale has actually changed before doing its work. Since there will be no change on app initialization, we force setLocaleto do its setup via the second, boolean parameter. We then listen for URI changes and call setLocalewhenever we get a newly requested URI.

Note » We're using a simple utility function called getLocaleFromPathto extract the locale URI segment from the current locale. Check it out in the GitHub repo.

Alright, let's take a look at what setLocaleactually does.

/src/containers/Localizer.js (excerpt)

 setLocale(newLocale, force = false) { if (force || newLocale !== this.props.locale) { this.props.changeLocale(newLocale) switchHtmlLocale( newLocale, locales.find(l => l.code === newLocale).dir, { withRTL: ['/styles/vendor/GhalamborM/bootstrap-rtl.css'] } ) this.props.setUiTranslationsLoading(true) setUiLocale(newLocale) .then(() => this.props.setUiTranslationsLoaded(true)) .catch(() => this.props.setUiTranslationsLoaded(false)) } }

The function is responsible for a couple of things. It makes sure that our <html> element is synced correctly with our current locale. setLocale also lets our UI i18n library know what l10n the library should load, again based on the current locale. The state of UI translation file loading is tracked as our UI i18n library initializes in setUiLocale. We'll dive into switchHtmlLocale and setUiLocale a bit later. For now, let's continue working through our Localizer.

/src/containers/Localizer (excerpt)

 render() { return this.props.children }

Since our Localizer exists solely for setting the current locale, it doesn't need to render anything. It's a higher order React component that will wrap other components, and we achieve this wrapping by rendering out Localizer’s children. Now we export our module.

/src/containers/Localizer (excerpt)

export default withRouter( connect( state => ({ locale: state.l10n.locale }), { changeLocale, setUiTranslationsLoaded, setUiTranslationsLoading, } )(Localizer))

At the bottom of our file, where we normally export a connected component or a plain old React component, we're doing something a bit different. After we connect a bit of state that tracks our current locale and some locale actions, we wrap everything up in withRouter. We'll get to withRouterin a minute.

First, let's take a brief look at our locale state. It's really simple stuff. We have two bits of locale state that we track.

/src/reducer/l10n.js (excerpt)

import { defaultLocale } from '../config/i18n'const INITIAL_STATE = { locale: defaultLocale, uiTranslationsLoaded: false,}export default (state = INITIAL_STATE, action) => {//...

localeis just the current locale code e.g. "ar" for Arabic. The uiTranslationsLoadedboolean is used to track whether the UI translation files for the current locale have been loaded successfully. I'll spare you the rest of the l10n reducer and its associated actions. They really just set the localestring and flip the uiTranslationsLoadedboolean. Nothing fancy at all happening there.

Note » You can check out the l10n reducer and actions in the GitHub repo.

Let's get back to our Localizer.

/src/containers/Localizer (excerpt)

export default withRouter( connect( state => ({ locale: state.l10n.locale }), { changeLocale, setUiTranslationsLoaded, setUiTranslationsLoading, } )(Localizer))

We wrap our normal React Redux connectcall in withRouter. withRouter is a higher order component that provides routing information to its children.

If you remember, our Localizer’s constructor made use of some seemingly magical props.

/src/containers/Localizer (excerpt)

 constructor(props) { super(props) this.setLocale(getLocaleFromPath(this.props.location.pathname), true) this.props.history.listen(location => { this.setLocale(getLocaleFromPath(location.pathname)) }) }

It's the withRoutercall that gives our Localizer access to the historyprop, which we use to listen for URI changes in our app. It also gives us access to a handy locationprop, which we can use to retrieve the current URI from.

Switching the Document's Locale

When we set our current locale in the Localizer, we made a call to switchHtmlLocale.

/src/containers/Localizer.js (excerpt)

 setLocale(newLocale, force = false) { if (force || newLocale !== this.props.locale) { this.props.changeLocale(newLocale) switchHtmlLocale( newLocale, locales.find(l => l.code === newLocale).dir, { withRTL: ['/styles/vendor/GhalamborM/bootstrap-rtl.css'] } ) this.props.setUiTranslationsLoading(true) setUiLocale(newLocale) .then(() => this.props.setUiTranslationsLoaded(true)) .catch(() => this.props.setUiTranslationsLoaded(false)) } }

Let's step into this function.

/src/services/i18n/util.js (excerpt)

export function switchHtmlLocale (locale, dir, opt = {}) { const html = window.document.documentElement html.lang = locale html.dir = dir if (opt.withRTL) { if (dir === 'rtl') { opt.withRTL.forEach(stylesheetURL => loadAsset(stylesheetURL, 'css')) } else { opt.withRTL.forEach(stylesheetURL => removeAsset(stylesheetURL, 'css')) } }}

We first make sure that our <html lang="ar" dir="rtl"> reflects the current locale and directionality. Any special stylesheets that are needed when our directionality is right-to-left are loaded, and removed when our directionality is left-to-right. We use this option in our calling code to load Bootstrap RTL styles.

Note » The loadAsset and removeAsset functions do pretty much what you think they do. You can check them out in the GitHub repo.

UI i18n

Our Localizer is responsible for initializing our UI i18n library. It does this via a call to setUilocale.

/src/containers/Localizer.js (excerpt)

 setLocale(newLocale, force = false) { if (force || newLocale !== this.props.locale) { this.props.changeLocale(newLocale) switchHtmlLocale( newLocale, locales.find(l => l.code === newLocale).dir, { withRTL: ['/styles/vendor/GhalamborM/bootstrap-rtl.css'] } ) this.props.setUiTranslationsLoading(true) setUiLocale(newLocale) .then(() => this.props.setUiTranslationsLoaded(true)) .catch(() => this.props.setUiTranslationsLoaded(false)) } }

For UI i18n, we're using i18next and providing some simple wrappers around it. Let's peek in.

Note » We'll be covering the basics of i18next here. We have an article that goes into i18next in much more detail.


import i18next from 'i18next'import { formatDate } from './util'export const setUiLocale = (locale) => { return fetch(`/translations/${locale}.json`) .then(response => response.json()) .then(loadedResources => ( new Promise((resolve, reject) => { i18next.init({ lng: locale, debug: true, resources: { [locale]: loadedResources }, interpolation: { format: function (value, format, locale) { if (value instanceof Date) { return formatDate(value, format, locale) } return value } } }, (err, t) => { if (err) { reject(err) return } resolve() }) }) )) .catch(err => Promise.reject(err))}export const t = (key, opt) => i18next.t(key, opt)
Loading Translation Files

The first thing we do is pull in the UI translation file for our given locale. We're assuming that we're placing our translation files in /public/translations/. The JSON for these is pretty straightforward.

/public/translations/fr.json (excerpt)

{ "translation": { "app_name": "μveez", "a_react_demo": "une démo d'i18n React", "directors": "Réalisateurs", "movies": "Films", // ... }}

i18next namespaces its translations under a translation key by default, so we adhere to that convention. Our translations are just key / value pairs. Done like dinner.

Once our translation file is loaded, we initialize i18next with the file's JSON. From that point on we can use our t() wrapper—which you may have noticed above—to return translation values by key from the currently loaded locale file.

In our views...

import { t } from '../services/i18n'// ...{{t('app_name')}}{{t('directed_by', { director: 'Michel Gondry' })}}

We can also interpolate values using i18next. Notice that we're passing in a map with a director key in our second call to t above. Our translation copy can have a placeholder that corresponds to this key.

/public/translations/fr.json (excerpt)

"directed_by": "Réalisé par {{director}}"

The {{director}} placeholder will be replaced by "Michel Gondry" before t outputs the value of directed_by. i18next really simplifies our UI i18n and l10n.

Formatting Dates

i18next doesn't support formatting dates itself. It does, however, provide a way for us to inject a date formatting interpolator when we initialize it. Notice that our interpolation.format function checks to see if the given value is a date, and delegates to formatDate if it is.

/src/services/i18n/index.js (excerpt)

import i18next from 'i18next'import { formatDate } from './util'// ... i18next.init({ // ... interpolation: { format: function (value, format, locale) { if (value instanceof Date) { return formatDate(value, format, locale) } return value } } }// ...

We'll jump into our date formatter in a minute. First let's see how we want to use it.

/public/translations/ar.json (excerpt)

"published_on": "نشر في {{date, year:numeric;month:long}}"

i18next allows to control the parameters we pass to our format interpolator. Given the above, if we were to call t('published_on', new Date('2018-02')), interpolation.format would receive "year:numeric;month:long" as its second parameter.

It's up to us to handle this format. We could pull in a library like Moment.js for date formatting, but for a proof of concept like this Moment is overkill. Instead, we'll use the Intl API built into most modern browsers.

let format = new Intl.DateTimeFormat("en", {year: "numeric", month: "short"}).formatlet value = new Date('2018-02-12')console.log(format(value)) // → "Feb 2018"

The Intl.DateTimeFormat constructor accepts a variety of formatting options which are well-documented. We can simply pass these along in our date formats when we write our translation files.

/public/translations/fr.json (excerpt)

"published_on": "Publié le {{date, year:numeric;month:short}}","published_on_date_only": "{{date, year:numeric;month:long}}"

All we have to do now is take these format strings and convert them to objects that Intl.DateTimeFormat understands. That's exactly what our custom date interpolater, formatDate, does.

/src/services/i18n/util.js (excerpt)

export function formatDate (value, format, locale) { const options = {} format.split(';').forEach(part => { const [key, value] = part.split(':') options[key.trim()] = value.trim() }) try { return new Intl.DateTimeFormat(locale, options).format(value) } catch (err) { console.error(err) }}

We break up the format options along ; then we break each segment up further into its key and value, and we use those to build our options object. After that, we do our Intl.DateTimeFormat thing, gracefully handling any errors that could be caused by invalid user options.

Ok, that's it for our scaffolding. Let's get to our views.

UI: Our React Views

Navigation and The Language Switcher

We'll start with our main app nav.

Building an Awesome Magazine App with i18n in React (5)


import React, { Component } from 'react'import { Link } from 'react-router-dom'import { Nav, Navbar, NavItem, Collapse, DropdownMenu, NavbarToggler, DropdownToggle, UncontrolledDropdown,} from 'reactstrap'import logo from '../logo.svg'import { t } from '../services/i18n'import { locales } from '../config/i18n'import LocalizedLink from '../containers/LocalizedLink'class AppNavbar extends Component { constructor(props) { super(props) this.state = { isOpen: false } } toggle() { this.setState(prevState => ({ isOpen: !prevState.isOpen })) } render() { return ( <Navbar fixed="top" color="light" light expand="md"> <LocalizedLink to="/" className="navbar-brand"> <img src={logo} width="30" height="30" className="d-inline-block align-top" alt={t('app_name')} /> {t('app_name')} </LocalizedLink> <NavbarToggler onClick={() => this.toggle()} /> <Collapse isOpen={this.state.isOpen} navbar> <span className="navbar-text small d-inline-block pr-4"> — {t('a_react_demo')} </span> <Nav className="mr-auto" navbar> <NavItem> <LocalizedLink to="/movies" className="nav-link"> {t('movies')} </LocalizedLink> </NavItem> </Nav> <Nav className="ml-auto" navbar> <UncontrolledDropdown nav inNavbar> <DropdownToggle nav caret> <span role="img" aria-label="globe" className="globe-icon" > 🌐 </span> {t('language')} </DropdownToggle> <DropdownMenu right> { => ( <Link key={locale.code} to={`/${locale.code}`} className="dropdown-item" > {} </Link> ))} </DropdownMenu> </UncontrolledDropdown> </Nav> </Collapse> </Navbar> ) }}export default AppNavbar

Most of the components we're using here are Bootstrap presentation that Reactstrap provides for us. You'll notice that we're using our t() function instead of hard-coding any UI text. This ensures that the text is i18n-ized and pulled in from the current locale's translation file.

We're also pulling in a custom LocalizedLink along with React Router's usual Link component. Take a gander with me.


import { connect } from 'react-redux'import { Link } from 'react-router-dom'import React, { Component } from 'react'import { prefixPath } from '../services/util'class LocalizedLink extends Component { render() { const { to, locale, className, children } = this.props return ( <Link className={className} to={prefixPath(to, locale)} > {children} </Link> ) }}export default connect( state => ({ locale: state.l10n.locale }))(LocalizedLink)

Remember that prefixPath function that we used to prefix our routes with the locale param? Well now we're using it to prefix the given URI, to, with the actual current locale. We're pulling in the current locale from our single source of truth o the subject: our handy dandy Redux state.

The Language Switcher

Back to AppNavbar. This piece of JSX is of particular interest.

/src/container/AppNav.js (excerpt)

<DropdownMenu right> { => ( <Link key={locale.code} to={`/${locale.code}`} className="dropdown-item" > {} </Link> ))}</DropdownMenu>

Since our supported locales are stored in one central config, we pull them in with import { locales } from '../config/i18n near the top of our file. All we have to do then is spin over them and output links to /ar, /en, and /fr. Our routing and Localizer take care of the rest. Disco.

Now we can build out our Home component.

Home Sweet Home


import React from 'react'import Quote from '../containers/Quote'import FeaturedMovies from '../containers/FeaturedMovies'import FeaturedDirectors from '../containers/FeaturedDirectors'export default () => ( <div> <FeaturedDirectors /> <Quote /> <FeaturedMovies /> </div>)

Like good React developers, we componentize our Home sections and pull them in. Now, instead of boring you with building out all of the Home containers, we'll deep-dive into one of them so that we can get an idea of a whole vertical.

Note » You can see the rest of the Home containers, along with the rest of the app code, in the GitHub repo.

Featured Movies

We'll focus on the FeaturedMovies container. Let's take a look at our mock API first; we represent it with JSON files tucked away in /public/api/.

/public/api/en/movies.json (excerpt)

[ { "id": 1, "is_featured": true, "published_on": "2008-07", "title": "The Dark Knight", "synopsis": "When the menace known as the Joker emerges...", "thumbnail_url": "/img/movies/dark_knight_tn.jpg", "image_url": "", "director": { "id": 1, "name": "Christopher Nolan" } }, { "id": 2, "is_featured": true,// ...

We'd expect our app's API to return something like this if we made a GET /en/movies request. To round out our mock API, we have one of these JSON files for each of our supported locales. Now to our movie reducer.

Our movie state is nice and terse.

/src/reducers/movies.js (excerpt)

const INITIAL_STATE = { movies: [], featured: [],}const movies = (state = INITIAL_STATE, action) => { switch(action.type) { case 'ADD_MOVIES': return { ...state, movies: [...action.movies], featured: action.movies.filter(m => m.is_featured) } default: return state }}export default movies

We make sure to keep a featured subset of our movie collection each time we add new movies. Now, of course, we need something to act on this state.

/src/actions/index.js (excerpt)

export const fetchMovies = () => (dispatch, getState) => { const { locale } = getState().l10n return fetch(`/api/${locale}/movies.json`) .then(response => response.json()) .then(movies => dispatch(addMovies(movies))) .catch(err => console.error(err))}export const addMovies = movies => ({ type: 'ADD_MOVIES', movies,})

Pretty standard stuff here. Notice, however, that we're pulling in our current state by using Redux Thunk's getState parameter. This allows to figure out the current locale without requiring it from our calling code, so we can pull in the right movie localization. Ok, let's use this funky fluxy flow in our views.


import { connect } from 'react-redux'import { CardDeck } from 'reactstrap'import React, { Component } from 'react'import { t } from '../services/i18n'import { fetchMovies } from '../actions'import FeaturedMovie from '../components/FeaturedMovie'class FeaturedMovies extends Component { componentDidMount() { this.props.fetchMovies() } render() { return ( <div> <h2>{t('featured_movies')}</h2> <CardDeck> { => ( <FeaturedMovie key={} movie={movie} /> ))} </CardDeck> </div> ) }}export default connect( state => ({ movies: state.movies.featured }), { fetchMovies })(FeaturedMovies)

A CardDeck is a presentational Bootstrap component that helps lay out a set of Cards. Luckily, our FeatureMovie component is wrapped in just such a Card.


import React from 'react'import { Card, CardImg, CardBody, CardText, CardTitle,} from 'reactstrap'import { t } from '../services/i18n'import LocalizedLink from '../containers/LocalizedLink'function synopsis (str, length = 250) { const suffix = str.length > length ? '…' : '' return (str.substring(0, length) + suffix).split("\n\n")}export default function ({ movie }) { return ( <Card style={{ marginBottom: "20px" }}> <LocalizedLink to={`/movies/${}`}> <CardImg top src={movie.thumbnail_url} alt={movie.title} /> </LocalizedLink> <CardBody> <CardTitle> <LocalizedLink to={`/movies/${}`}> {movie.title} </LocalizedLink> </CardTitle> <CardText className="small text-muted"> {t('directed_by', { director: })} {' | '} {t('published_on_date_only', { date: new Date(movie.published_on) })} </CardText> <CardText tag="div"> {synopsis(movie.synopsis).map((para, i) => ( <p key={i}>{para}</p> ))} </CardText> </CardBody> </Card> )}

The synposis helper function truncates a movie synopsis that's too long for our index view, and returns an array of paragraphs. Other than that, we're just using our good old LocalizedLink to render links to the individual movie in the index, and t()ing up all our text, with interpolation where needed.

The rest of the views are, for our purposes, largely more of the same. So, I'll let you peer into the GitHub repo yourself to check them out.

When all is said and done, we get something that works a little something like this.

Building an Awesome Magazine App with i18n in React (6)

We quickly run to our client to show her our finished front-facing proof of concept, with routing, language switching, i18n-ized UI, and localized content. We think we glean the beginnings of a smile on her face.

Writing code to localize your app is one task, but working with translations is a completely different story. Many translations for multiple languages may quickly overwhelm you which will lead to the user’s confusion. Fortunately,the Phrase Localization Suite can make your life as a developer easier.

I hope this gets you started on the right foot when building your React SPAs with i18n and l10n, and I hope it was as fun for you to read as it was for me to write. Be sure to check out the code and the live demo of the admin and public apps. Til next time 😊 👍🏽

Authored by Mohammad Ashour.

Last updated on October 27, 2022.

Top Articles
Latest Posts
Article information

Author: Melvina Ondricka

Last Updated: 18/06/2023

Views: 6153

Rating: 4.8 / 5 (48 voted)

Reviews: 87% of readers found this page helpful

Author information

Name: Melvina Ondricka

Birthday: 2000-12-23

Address: Suite 382 139 Shaniqua Locks, Paulaborough, UT 90498

Phone: +636383657021

Job: Dynamic Government Specialist

Hobby: Kite flying, Watching movies, Knitting, Model building, Reading, Wood carving, Paintball

Introduction: My name is Melvina Ondricka, I am a helpful, fancy, friendly, innocent, outstanding, courageous, thoughtful person who loves writing and wants to share my knowledge and understanding with you.