DEV Community

Cover image for Multi-language routing in React
Vlatko Vlahek for PROTOTYP

Posted on • Edited on • Originally published at blog.prototyp.digital

Multi-language routing in React

Multi-language routing in React

One of the great things around routing in React is that its ecosystem has allowed for great and very declarative routing syntax. You can define your routing as a set of components, write an object structure from which you will render the routing logic, etc.

And it’s constantly improving and getting better and better:

But, what if you want to support a true multi-language routing, which will support route names in multiple languages and redirect your users to correct routes when you change languages?

Is that really a big deal?

Well, it is definitively possible to go on without such a feature and have a fully usable website. There are a lot of websites that have English-only routing, but multi-language content.

From a development perspective, the reasons for this vary:

  • A framework that doesn’t support it is used.
  • It’s a big effort to implement.
  • It’s not always easy to maintain.

However, having a multi-language route localization can give you and your end-users the following benefits:

  • multi-language SEO
  • users get the additional context of the page hierarchy in their own language

A solution written in React is relatively simple to implement and maintain, and this article will outline the packages and methods that will guide you to a solution.

The example is written with TypeScript, latest react-router-dom, and react-hooks.

Add a Router package

In case you aren’t using a router package, try react-router-dom out.

yarn add react-router-dom
yarn add @types/react-router-dom --dev

After adding a router, we should define a few routes and components that will be used on our website.

export const App: React.FC = () => (
  <BrowserRouter>
    <AppLayout>
      <Switch>
        <Route exact path={AppRoute.Home}>
          <views.Home />
        </Route>
        <Route exact path={AppRoute.Summary}>
          <views.Summary />
        </Route>
        <Route path="*">
          <views.GeneralError />
        </Route>
      </Switch>
    </AppLayout>
  </BrowserRouter>
);

In the latest react-router-dom version, component and render props were scrapped for children prop which is much more flexible. The only downside is that the v4 version was more concise and readable in most scenarios. Please note that the old way of doing things through component/render props is still available at this moment, but it will become deprecated soon.

We also added an AppLayout component which allows us to have a global header, navigation, and footer, and renders the routes inside the main tag as partial views.

There is also a fallback route here that renders the error component so our users know that they ended up on the error page in case they try to access a route that doesn’t exist.

Add an i18n package

First, we need to add a package that will allow us to internationalize things in our app. There are a lot of good examples, but one of the best packages around is react-intl.

It is a project by FormatJS (Yahoo! Inc) which has impressive support for localizing almost everything, including currencies, dates, etc.

    yarn add react-intl

This package was written in Typescript so it has its own types included.

Adding a base locale

It’s always easiest to start with a language that will be the primary language for the website as a baseline. You can always add more languages easily later on.

Let’s first add an enum which will be a collection of all languages used inside our app. For start, we will only add the base language.

export enum AppLanguage {
  English = 'en',
}

The value of each enum property should match a two-letter country code ISO locale.

After adding a language, we should also add some language strings for that language, which we will use to localize our routes and other content.

Create an intl folder somewhere in the app, and a file for your base language.

const baseStrings = {
  /** Routes */
  'routes.home': '/',
  'routes.summary': '/summary',

  ...
};

export type LanguageStrings = typeof baseStrings;
export const en = baseStrings;

The exported type will be used as an equality enforcer which all other languages need to support, meaning that any localization added to baseStrings will need to be added to other files in order to enforce some safety. It also works vice-versa.

If you try to add a string to a specific language which doesn’t exist in the base strings file, you will get a compilation error. This will enforce that all of the used languages have all strings at least set, if not translated, and save you from runtime errors.

We are also exporting the baseStrings as a matching iso variable for the language at hand.

Now let’s add a matching enum (or frozen object in plain JS) which we can use to reference the routes to avoid any typos.

export enum AppRoute {
  Home = 'routes.home',
  Summary = 'routes.summary'
}

Localized Switch component

In order to simplify the process of translating the route paths, we will create a custom LocalizedSwitch component that handles this logic.

It is also possible to do this on the route component level, however, swapping out the Switch component allows you to support this with the least amount of changes, as it is easier to update the parent then every route to a LocalizedRoute variant. Changing the route component is probably a more flexible solution though.

The intended suggestion for the LocalisedSwitch component is imagined as a drop-in replacement for the normal Switch one, and it is designed to work with Route components from the react-router-dom package.

export const LocalizedSwitch: React.FC = ({ children }) => {
  /**
   * inject params and formatMessage through hooks, so we can localize the route
   */
  const { formatMessage, locale } = useIntl();

  /**
   * Apply localization to all routes
   * Also checks if all children elements are <Route /> components
   */
  return (
    <Switch>
      {React.Children.map(children, child =>
        React.isValidElement<RouteProps>(child)
          ? React.cloneElement(child, {
              ...child.props,
              path: localizeRoutePath(child.props.path)
            })
          : child
      )}
    </Switch>
  );

  /**
   *
   * @param path can be string, undefined or string array
   * @returns Localized string path or path array
   */
  function localizeRoutePath(path?: string | string[]) {
    switch (typeof path) {
      case 'undefined':
        return undefined;
      case 'object':
        return path.map(key => `/${locale}` + formatMessage({ id: key }));
      default:
        const isFallbackRoute = path === '*';
        return isFallbackRoute
          ? path
          : `/${locale}` + formatMessage({ id: path });
    }
  }
};

Wiring it all up

To wire it all together, we need to add the IntlProvider component from the react-intl package, connect it to the data we defined, and add our own LocalizedSwitch component.

export const App: React.FC = () => (
  <LocalizedRouter
    RouterComponent={BrowserRouter}
    languages={AppLanguage}
    appStrings={appStrings}
  >
    <AppLayout>
      <LocalizedSwitch>
        <Route exact path={AppRoute.Home}>
          <views.Home />
        </Route>
        <Route exact path={AppRoute.Summary}>
          <views.Summary />
        </Route>
        <Route path="*">
          <views.GeneralError />
        </Route>
      </LocalizedSwitch>
    </AppLayout>
  </LocalizedRouter>
);

Supporting multiple languages

Now that we have covered the basics of setting up the logic that allows us to internationalize our application and localize the application routes, we need to add support for other languages and add their route definitions.

For the purpose of this example, let’s add support for Deutch, French and Croatian languages, all inside the intl folder that we already have.

Disclaimer: I don’t personally speak Deutch or French, so if the translations are not perfect, I do apologize for mangling your language, but please blame Google Translate :)

Adding translations for a new language

Just add a new language file inside the intl folder:

export const de: LanguageStrings = {
  /** Routes */
  'routes.home': '/',
  'routes.summary': '/zusammenfassung',

  ...
};

If you are wondering why this was done in .ts file in this scenario, and not another format like JSON, the sole purpose is to enforce safety that comes with using TypeScript.

You can, of course, write these in JSON, JS or another preferred format in case you don’t want or need the type-safety.

For every language file you add, extend the AppLanguage enum.

Updating the router

We first need to update the router to support redirecting to other languages, reading the current language from the pathname, and setting the locale accordingly.

Expected behaviour:

/summary -> Redirect to base language
/en/summary -> English language summary page
/de/zusammenfassung -> German language summary page

We will swap out the default router component with the one that supports pathname detection and returns react-intl provider.

interface Props {
  RouterComponent: React.ComponentClass<any>;
  languages: { [k: number]: string };
  appStrings: { [prop: string]: LanguageStrings };
  defaultLanguage?: AppLanguage;
}

export const LocalizedRouter: React.FC<Props> = ({
  children,
  RouterComponent,
  appStrings,
  defaultLanguage
}) => (
  <RouterComponent>
    <Route path="/:lang([a-z]{2})">
      {({ match, location }) => {
        /**
         * Get current language
         * Set default locale to en if base path is used without a language
         */
        const params = match ? match.params : {};
        const { lang = defaultLanguage || AppLanguage.English } = params;

        /**
         * If language is not in route path, redirect to language root
         */
        const { pathname } = location;
        if (!pathname.includes(`/${lang}/`)) {
          return <Redirect to={`/${lang}/`} />;
        }

        /**
         * Return Intl provider with default language set
         */
        return (
          <IntlProvider locale={lang} messages={appStrings[lang]}>
            {children}
          </IntlProvider>
        );
      }}
    </Route>
  </RouterComponent>
);

Wrapping everything in a route, allows us to use regex to determine the language from the pathname, and use that match to inject the current language into the provider.

Also, our new router component will enforce that a language is always a part of the pathname.

The regex used in this example will only support lowercase language, but you can modify it to [a-zA-z]{2} and use String.toLowercase() method when pathname matching if you want to support uppercase routes as well.

Language switcher

We also need to add a language switcher component that will allow us to change the active language and show the currently activated language based on the pathname.

Apart from the styling, we need a helper function that checks for matching route inside the strings object for other languages if we want to support navigating to the same page in another language directly.

export const LanguageSwitcher: React.FC = () => {
  const { pathname } = useLocation();
  const { locale, messages } = useIntl();

  return (
    <ul className={css(list.container)}>
      {Object.keys(AppLanguage).map(lang => (
        <li key={lang} className={css(list.item)}>
          <NavLink
            className={css(link.primary)}
            activeClassName={css(link.active)}
            to={getMatchingRoute(AppLanguage[lang])}
          >
            {AppLanguage[lang]}
          </NavLink>
        </li>
      ))}
    </ul>
  );

  function getMatchingRoute(language: string) {
    /**
     * Get the key of the route the user is currently on
     */
    const [, route] = pathname.split(locale);
    const routeKey = Object.keys(messages).find(key => messages[key] === route);

    /**
     * Find the matching route for the new language
     */
    const matchingRoute = appStrings[language][routeKey];

    /**
     * Return localized route
     */
    return `/${language}` + matchingRoute;
  }
};

Navigation

The last thing to do is updating the Navigation component itself, to also support switching to other routes in all languages.

We simply use the formatMessage function from the react-intl hook for this purpose.

export const Navigation: React.FC = () => {
  const { formatMessage, locale } = useIntl();

  return (
    <ul className={css(list.container)}>
      {Object.keys(AppRoute).map(elem => (
        <li key={elem} className={css(list.item)}>
          <NavLink
            exact
            className={css(link.primary)}
            activeClassName={css(link.active)}
            to={localizeRouteKey(AppRoute[elem])}
          >
            {formatMessage({ id: AppRouteTitles.get(AppRoute[elem]) || '' })}
          </NavLink>
        </li>
      ))}
    </ul>
  );

  function localizeRouteKey(path: string) {
    return `/${locale}` + formatMessage({ id: path });
  }
};

In order to allow for easier route name resolution, since TS enums don’t allow for reverse mapping on string enums, you can create an ES6 map.

export const AppRouteTitles = new Map([
  [AppRoute.Home, 'home.title'],
  [AppRoute.Summary, 'summary.title']
]);

Summary

As you can see, localizing the routes of the website is not a hard task in React. It requires a few components and little thinking on the side of the project architecture, so you don’t overcomplicate things. The result is easy to understand the solution that will easily scale regardless of the language count that you might add later on.

A fully working example can be found on:
vlaja/multilanguage-routing-react

Top comments (0)