Prerequisites:
Basic knowledge of Gatsby and Sanity.iotl;dr
You can find the starter source here and the demo here
Gatsby is a great tool for building blazing fast, statically generated websites. However, the solutions offered for building multilanguage websites are quite sparse, especially, when the requirements in terms of performance, SEO and user experience are high.
In my website project I needed:
- correct URLs for the languages (language in the path, translated slugs)
- multilanguage content from sanity and other sources
- a proper translation of snippets
- optimized bundle size (don't ship all translations at once)
- alternate links to other languages
- a sitemap with language information
When looking at other solutions, I didn't find gatsby-plugin-i18n to meet those requirements. They mostly rely on translated page files, which is what I wanted to avoid.
So I decided to do it manually by integrating i18next because it was already used in some other related projects.
For this example, let's assume that we want to create a website where we have a homepage and some pages for our shops in different cities.
Pages & Snippets
Our approach will be:
- Only create pages programmatically with localized paths
- In
createPages
, use i18next to load all the translations needed to render a page - Pass the language and the translation resources with the
pageContext
- Wrap the templates in a
<I18nextProvider>
ingatsby-ssr
andgatsby-browser
. The resources are loaded from thepageContext
.
So, let's start off by removing all files from the pages directory. Then, we want to create start pages for all languages, so we go to gatsby-node.js
.
First, we define all available languages (English and German for this example).
const allLanguages = ["en", "de"]
Then, we need a helper function, which creates all language pages for one or more datasets. For each dataset, it will load the given namespaces into an i18next instance (1) and then use the given callback to generate the definition to create the Gatsby page (2). The Gatsby page will receive the language and the loaded language key along with the definition returned by the callback (3).
We then map over all resulting definitions, which contain the final URL and the language, to generate the alternate links (4). Finally, we create pages for all definitions (5).
const buildI18nPages = async (
inputData,
pageDefinitionCallback,
namespaces,
createPage
) => {
if (!Array.isArray(inputData)) inputData = [inputData]
await Promise.all(
inputData.map(async ipt => {
const definitions = await Promise.all(
allLanguages.map(async language => {
const i18n = await createI18nextInstance(language, namespaces) // (1)
const res = pageDefinitionCallback(ipt, language, i18n) // (2)
res.context.language = language
res.context.i18nResources = i18n.services.resourceStore.data // (3)
return res
})
)
const alternateLinks = definitions.map(d => ({
// (4)
language: d.context.language,
path: d.path,
}))
definitions.forEach(d => {
d.context.alternateLinks = alternateLinks
createPage(d) // (5)
})
})
)
}
Let's create our first pages now!
const homeTemplate = path.resolve(`src/templates/Home.js`)
await buildI18nPages(
null,
(_, language) => ({
path: "/" + language, // (1)
component: homeTemplate,
context: {},
}),
["common", "home"],
createPage
)
const shopTemplate = path.resolve(`src/templates/Shop.js`)
const shops = await graphql(`
query Shop {
allSanityShop {
edges {
node {
id
_rawSlug
}
}
}
}
`)
await buildI18nPages(
shops.data.allSanityShop.edges,
({ node }, language, i18n) => ({
path: `/${language}/${i18n.t("common:shopSlug")}/${
node._rawSlug[language]
}`,
component: shopTemplate,
context: { shop: node.id },
}),
["common", "shop"],
createPage
)
Looking at the first part creates the home page for every language on /en
and /de
. We load the common
and the home
namespace for the page. Then, we query sanity for all our shops and use our helper function to create the language versions of each shop. This time we create a localized slug (e.g. /en/buy-stuff-in/cologne
for English and /de/sachen-kaufen-in/koeln
for German). The way the slug is built here is completely arbitrary, you could, of course, use any logic.
To use i18n.t
we need to create the namespace files src/locales/(de|en)/common.json
and insert the translation for shop-slug
, see the i18next documentation for more information.
Wrap Content with I18nextProvider
To translate snippets in the templates, we need to wrap the page in an <I18nextProvider>
. This can be done in wrapPageElement
, both in gatsby-ssr.js
and gatsby-browser.js
environment. So we create a wrapper function that pulls the data we passed in the pageContext
(1), instantiates i18next with our data and wraps the content in a provider. This also adds the alternate links and the lang attribute using Helmet (2). We also create a context for the links, so we can put a context-sensitive language switcher anywhere in the application.
export const AlternateLinksContext = React.createContext([])
export function wrapWithI18nProvider({ element, props }) {
const i18n = i18next
.createInstance({
lng: props.pageContext.language,
interpolation: { escapeValue: false },
initImmediate: false,
resources: props.pageContext.i18nResources,
})
.use(ReactI18next.initReactI18next)
// noinspection JSIgnoredPromiseFromCall
i18n.init()
return (
<ReactI18next.I18nextProvider i18n={i18n}>
<AlternateLinksContext.Provider
value={props.pageContext && props.pageContext.alternateLinks}
>
{
<Helmet htmlAttributes={{ lang: props.pageContext.language }}>
{props.pageContext &&
props.pageContext.alternateLinks &&
props.pageContext.alternateLinks.map(link => (
<link
rel="alternate"
hrefLang={link.language}
href={link.path}
/>
))}
</Helmet>
}
{element}
</AlternateLinksContext.Provider>
</ReactI18next.I18nextProvider>
)
}
In gatsby-ssr.js
and gatsby-browser.js
we simply use this function:
const wrapWithI18nProvider = require("./src/components/wrapWithI18nProvider")
.wrapWithI18nProvider
exports.wrapPageElement = wrapWithI18nProvider
Optimize Sanity I18n
When using the suggested way for Sanity I18n you end up with a graphql schema which looks like this:
type SanityShop {
_rawName: JSONObject
_rawSlug: JSONObject
name: SanityLocaleString
slug: sanityLocaleString
}
type SanityLocaleString {
en: String
de: String
}
With neither _rawName
nor name
we only fetch a language based on a variable, which unfortunately is the only way to customize the query in Gatsby. But we can extend the schema a little bit and add a resolver for that issue in gatsby-node.js
.
exports.createResolvers = ({ createResolvers }) => {
createResolvers({
SanityLocaleString: {
translate: {
type: `String!`,
args: { language: { type: "String" } },
resolve: (source, args) => {
return source[args.language] || source["en"]
},
},
},
})
}
This resolver allows us to write a query for the shop page like this, so we only get the values for the current language.
export const query = graphql`
query Shop($shop: String, $language: String) {
sanityShop(id: { eq: $shop }) {
name {
translate(language: $language)
}
}
}
`
By using both of the above tricks we can ensure to minimize the data we send to the client to the languages and namespaces which are needed.
Redirecting to the correct language
When a user visits the page, we want to forward him to a language, because right now, we don't have anything to show in the root directory. Gatsby has no server, so we can't perform any server-side redirects (at least no dynamic language detection). But Gatsby offers us a method to create redirects and push the implementation of that to plugins (the netlify plugin for example). In a real-world scenario, I would redirect different TLDs to the different languages on the master TLD (example.com
-> example.com/en
, example.de
-> example.com/de/
). So in createPages
, we can set:
createRedirect({ fromPath: "/", toPath: "/en", isPermanent: true })
We could of course also create an index page which detects the language on the client-side.
Error Pages
When something goes wrong and the user gets to a URL that doesn't exist, we can provide a 404 page. By generating 404.html's in the language directories as we did with the homepage, we can set up redirects to use those pages in case nothing else is found:
allLanguages.forEach(language =>
createRedirect({
fromPath: `/${language}/*`,
toPath: `/${language}/404`,
statusCode: 404,
})
)
You can find the code for the 404 pages on GitHub, but it's mostly the same as the index page with different content.
A Page Template
The pages we create in the templates folder do you look like regular Gatsby pages, except they have access to the language in the context.
const ShopPage = ({ data }) => {
const { t } = useTranslation("shop")
return (
<Layout>
<SEO title="Shop" />
<h1>{t("welcome", { city: data.sanityShop.name.translate })}</h1>
</Layout>
)
}
src/locales/en/shop.json
:
{
"welcome": "Welcome to our Shop in {{city}}"
}
This page prints a translated message to welcome the user to a specific shop. It uses i18next interpolation to put the message into the string.
Sitemap
gatsby-plugin-sitemap
allows us to set a custom query and transformer function, so we can query the additional pageContext
and set the sitemap data accordingly. So we add this to our plugin configuration:
{
resolve: `gatsby-plugin-sitemap`,
options: {
exclude: ["/404", "/*/404"],
query: `
{
site {
siteMetadata {
siteUrl
}
}
allSitePage {
edges {
node {
path
context {
alternateLinks {
language
path
}
}
}
}
}
}`,
serialize: ({ site, allSitePage }) =>
allSitePage.edges.map(edge => {
return {
url: site.siteMetadata.siteUrl + edge.node.path,
changefreq: `daily`,
priority: 0.7,
links:
edge.node.context.alternateLinks &&
edge.node.context.alternateLinks.map(link => ({
lang: link.language,
url: site.siteMetadata.siteUrl + link.path,
})),
}
}),
},
},
Demo
You can find a working example of all this here:
https://github.com/johannesspohr/gatsby-starter-sanity-i18next
The preview can be seen here:
https://gatsby-starter-i18next-sanity.netlify.com/
Feel free to comment on your experiences on i18n with Gatsby!
Top comments (16)
I did a similar setup but didn't know about the GraphQL resolver to filter the Sanity data. That's such a smart solution!
One thing that I realised however is that there is no real reason to separate translations in two separate data source: Sanity and json files. This complicates the setup and also editors won't be able to change whatever is in your source code json. You could just put everything in Sanity and have a single source of content and editable!
Hi Adrien, thanks! In that project I wanted to use i18next because it's used in some components I reused from a different project. It also makes the translation of snippets available with hooks / hocs and avoids prop-drilling snippets. You could of course load the i18next translations from sanity, but for me just using json files was a bit easier.
In what way did you end up putting all the content to Sanity? Using translation keys the same way as using json files and loading them to i18n-next or some other way? Did you manage translation interpolation this way?
Absolutely. I didn't need translation interpolation on that specific website but it should just work fine since Sanity API data is normal json, you can inject that as a ressource in i18n-next.
I might setup a fork of this repo soon (since I'm starting a new project really soon) so I'll try to remember and post it here as well.
But here is a snippet of how I did it:
I only used this for global "commons" data but you could probably just inject all your Sanity data in i18n-next and then your component all use the i18n-next hooks to display CMS data.
Hey Adrien could you setup up a fork or upload any example of your approach :)
Have a look there github.com/grsmto/gatsby-starter-i...
Thanks a lot I have checked it :) I think you continue updating this project?
Any idea how should resolver for SanityLocaleBlock look like? I would be thankful for any recommendation. Thanks!
Mine looks something like this:
Can you provide us with your setup plet
Hi I've been using this starter and it's working amazingly. Very simple. One thing that I am not getting is fallback - If the language I'm translating does not have a corresponding key in the json files then it just displays the key instead of falling back to my default language (english.) I tried to debug this but could not make it work. Do you know what is causing this?
I figured it out - the fall back settings need to be set in the wrapper component instead of the SSR i18next instance during createPages. That confused me. Thank you!
Is there a way to share your Gatsby + Sanity project here ?
This is so good. Wow. Thank you sir. Im trying the same, but with directus.io instead of sanity. Lets see how it goes... :D
Sir can you share your sanity sanityShop document object or schema from sanity ? I wanted to explore how you have created scheme from sanity side.
Some comments may only be visible to logged-in visitors. Sign in to view all comments.