DEV Community

Cover image for Multi-language (i18n) Gatsby app with Hooks
Dmytro Rykhlyk
Dmytro Rykhlyk

Posted on

Multi-language (i18n) Gatsby app with Hooks

Recently, working on a Gatsby.js project, I had to implement a multi-language (internationalization / i18n) support. A lot of guides are out there, using different approaches/tools, but in this article, I want to show the solution I ended up using and go through the problems that I’ve faced.

So what do we need to implement and what we want to achieve?

  • Keep configuration and translations in a centralized way.
  • Generate language-specific static pages.
  • Localize URLs.
  • Identify user locale and update components.
  • Implement language switcher buttons to alter between the languages.
  • Use no external dependencies (or as few as possible).

Take a look at the Github repo with example project and the DEMO


🌟 Generate pages for each locale

Before we jump into code, we need to think about localizing URLs. In this example, let's assume that the default language should have no prefix in the URL. We'll add English as a default language and Japanese as a second one.

╔════════════════════════════════════════════════════╗
β•‘       Languages     β•‘  index.js  β•‘   page-2.js     β•‘
╠════════════════════════════════════════════════════╣
β•‘ English (default)   β•‘    /       β•‘   /page-2       β•‘
β•‘ Japanese            β•‘    /ja     β•‘   /ja/page-2    β•‘
β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•
Enter fullscreen mode Exit fullscreen mode

Let’s create a module in the locales.js file that would contain all locales definitions. Note that onlyΒ oneΒ languageΒ shouldΒ haveΒ theΒ default:Β trueΒ key.

// i18n/locales.js

module.exports = {
  en: {
    path: 'en',
    locale: 'English',
    default: true,
  },
  ja: {
    path: 'ja',
    locale: 'ζ—₯本θͺž',
  },
};
Enter fullscreen mode Exit fullscreen mode

Instead of manually writing different components for each language, we can add the following content to our gatsby-node.js file, where onCreatePage hook will create a page for each locale and pass its locale and isDefault props to the page context:

// gatsby-node.js

const locales = require('./i18n/locales');

exports.onCreatePage = ({ page, actions }) => {
  const { createPage, deletePage } = actions;

  // For each page, we’re deleting it, than creating it again for each
  // language passing the locale to the page context
  return new Promise(resolve => {
    deletePage(page);

    Object.keys(locales).map(lang => {
      const isDefault = locales[lang].default || false;

      const localizedPath = isDefault
        ? page.path
        : locales[lang].path + page.path;

      return createPage({
        ...page,
        path: localizedPath,
        context: {
          locale: lang,
          isDefault,
        },
      });
    });

    resolve();
  });
};
Enter fullscreen mode Exit fullscreen mode

If you go to http://localhost:8000/___graphql and query all pages you should see automatically generated pages for each locale:

Alt Text

Now you can pass pageContext to the layout component to be able to use locale and isDefault later.

{
  allSitePage {
    edges {
      node {
        path
        context {
          isDefault
          locale
        }
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

🌟 Add translations in JSON format

Let’s place some data in the i18n/translations/ folder using JSON format following the defined language conventionon - adding suffix to the filename that contain the language code [name].[language].json, like data.en.json or data.js.json.

First, we need to be sure that our project includes these dependencies:

yarn add gatsby-source-filesystem gatsby-transformer-json
Enter fullscreen mode Exit fullscreen mode

and then update gatsby-config.js configuration:

// gatsby-config.js

module.exports = {
  // ...
  plugins: [
    'gatsby-transformer-json',
    {
      resolve: `gatsby-source-filesystem`,
      options: {
        name: `i18n`,
        path: `${__dirname}/i18n`,
      },
    },
  // ...
  ],
};
Enter fullscreen mode Exit fullscreen mode

Let’s create some translations for both languages:

// i18n/translations/en.json
{
  "title": "Gatsby Default Starter",
  "greeting": "Hi people",
  // ...
}

// i18n/translations/ja.json
{
  "title": "Gatsbyγƒ‡γƒ•γ‚©γƒ«γƒˆγ‚Ήγ‚ΏγƒΌγ‚ΏγƒΌ",
  "greeting": "ηš†γ•γ‚“γ€γ“γ‚“γ«γ‘γ―",
  // ...
}
Enter fullscreen mode Exit fullscreen mode

🌟 useLocale hook for keeping the current language

Gatsby uses @reach/router under the hood, so we can get access to the current pathname to be able to extract a language name from the URL and use it on app initialization, so we don't need to store any initial data in the localStorage or elsewhere.

We can take advantage of react's Context API to share current language value between components.

// src/hooks/useLocale.js

import React, { createContext, useState, useContext } from 'react';
import { useLocation } from '@reach/router';

import allLocales from '../../i18n/locales';

const LocaleContext = createContext('');

const LocaleProvider = ({ children }) => {
  const { pathname } = useLocation();

  // Find a default language
  const defaultLang = Object.keys(allLocales)
    .filter(lang => allLocales[lang].default)[0];

  // Get language prefix from the URL
  const urlLang = pathname.split('/')[1];

  // Search if locale matches defined, if not set 'en' as default
  const currentLang = Object.keys(allLocales)
    .map(lang => allLocales[lang].path)
    .includes(urlLang)
    ? urlLang
    : defaultLang;

  const [locale, setLocale] = useState(currentLang);

  const changeLocale = lang => {
    if (lang) {
      setLocale(lang);
    }
  };

  return (
    <LocaleContext.Provider value={{ locale, changeLocale }}>
      {children}
    </LocaleContext.Provider>
  );
};

const useLocale = () => {
  const context = useContext(LocaleContext);

  if (!context) {throw new Error('useLocale must be used within an LocaleProvider');}

  return context;
};

export { LocaleProvider, useLocale };
Enter fullscreen mode Exit fullscreen mode

Our hook returns LocaleProvider, so we can add it to wrap our layout component:

// src/components/app.js

import { LocaleProvider } from '../hooks/useLocale';
import Layout from './layout';

const App = ({ children, pageContext: { locale, isDefault } }) => (
  <LocaleProvider>
    <Layout locale={locale} isDefault={isDefault}>
      {children}
    </Layout>
  </LocaleProvider>
);
// ...
Enter fullscreen mode Exit fullscreen mode

And then, we can use changeLocale function to provide a way to change the language using buttons. Adding next code we can be sure that a new value will be stored every time the locale value changes:

// src/components/layout.js

import { useLocale } from '../hooks/useLocale';

const Layout = ({ children, pageContext: { locale, isDefault } }) => {
  const { changeLocale } = useLocale();

  // Every time url changes we update our context store
  useEffect(() => {
    changeLocale(locale);
  }, [locale]);
  // ...
};
Enter fullscreen mode Exit fullscreen mode

🌟 useTranslation hook for querying data

There’s a drawback with the approach of using useStaticQuery - static queries do not take variables, so we need to manually write all query nodes.
We'll create a helper function to first simplify query response and then filter by current language.

// src/hooks/useTranslation.js

import { useStaticQuery, graphql } from 'gatsby';
import { useLocale } from './useLocale';

const query = graphql`
  query useTranslations {
    allFile(filter: {relativeDirectory: {eq: "translations"}}) {
      edges {
        node {
          name
          childrenTranslationsJson {
            greeting
            mainPageContent
            secondPageLink
            title
            goHomeLink
            secodPageContent
            secondGreeting
            indexPageTitle
            secondPageTitle
            NotFoundPageTitle
            NotFoundPageContent
          }
        }
      }
    }
  }
`;

// This hook simplifies query response for current language.
const useTranslation = () => {
  const { locale } = useLocale();
  const { allFile } = useStaticQuery(query);

  // Extract all lists from GraphQL query response
  const queryList = allFile.edges.map(item => {
    const currentFileTitle = Object.keys(item.node).filter(
      item => item !== 'name',
    )[0];

    return {
      name: item.node.name,
      ...item.node[currentFileTitle][0],
    };
  });

  // Return translation for the current locale
  return queryList.filter(lang => lang.name === locale)[0];
};

export default useTranslation;
Enter fullscreen mode Exit fullscreen mode

Now we can use our translations from the i18n/translations/ data in our components:

// src/pages/page-2.js

// ...
import SEO from '../components/seo';
import useTranslation from '../hooks/useTranslation';

const SecondPage = ({ pageContext }) => {
  const {
    secodPageContent,
    goHomeLink,
    secondGreeting,
    secondPageTitle,
  } = useTranslation();

  return (
    <>
      <SEO title={secondPageTitle} />
      <h1>{secondGreeting}</h1>
      <p>{secodPageContent}</p>

      {/* ... */}
    </>
  );
};
// ...
Enter fullscreen mode Exit fullscreen mode

🌟 Closing Notes

Finally, we'll need to allow the user to correctly browse the site and alternate between both languages. That should be done using localized links and button switcher. You can view them all in this Github repo.

Our published project using Gatsby's default starter

Alt Text

The i18n implementation we end up with uses no external dependencies (or as few as possible): Context API, hooks, Gatsby’s createPage() in the gatsby-node.js to generate pages for each locale, gatsby-transformer-json plugin to manage translation data.


Take a look at the Github repo with example project and the DEMO


Links

πŸŽ‰ Thanks for reading :)

Top comments (0)