Build a Multilingual Website using Gatsby and Contentstack
Gatsby is a blazing-fast static site generator. This example website is built using the contentstack-gatsby plugin and Contentstack. It uses Contentstack to store and deliver the content of the website.
This website also supports multilingual functionality to view content in different languages. This guide further explains the process of adding a new language and configuring the code as per your requirements.
Prerequisites
- The Gatsby Starter app already installed and working
Note: Live preview is not supported with the multi-lingual Gatsby app.
Enable Multilingual in your Gatsby Starter
- Create a language/locale
In our example, we have two locales:
- master locale: en-us (English)
- Added locale: fr (French)
- Localize all entries and publish them on the development environment for both locales.
Note: Also, translate those entries into French to differentiate the results.
App Changes
Follow the steps to configure the app for multi-lingual support:
- Follow the steps below to create a file that maintains a list of locales.
- Create a folder named Locales within the src folder.
- Create a file named locales.ts within the Locales folder.
- Copy and paste the below code to the file in the path:
src/Locales/locales.tsNote: In our example, we have used English and French as our locales.
- Code Snippet:
module.exports = { "en-us": { code: "en-us", locale: "English", defaultLocale: true, }, "fr": { code: "fr", locale: "french", }, }
- Copy and paste the below code to the gatsby-node.ts file to create pages programmatically.
Code Snippet:
const path = require("path") const locales = require("./src/Locales/locales") module.exports.createPages = async ({ graphql, actions }) > { const { createPage } = actions const blogPostTemplate = path.resolve("src/templates/blog-post.tsx") const pageTemplate = path.resolve("src/templates/page.tsx") const blogPageTemplate = path.resolve("src/templates/blog-page.tsx") const blogPostQuery = await graphql(` query { allContentstackBlogPost { nodes { title url locale publish_details { locale } } } } `); const pageQuery = await graphql(` query { allContentstackPage { nodes { title url locale publish_details { locale } } } } `); const createBlogPostTemplate = (route, componentToRender, title, data, locale) => { createPage({ path: `${route}`, component: componentToRender, context: { title: title, result: data, locale: locale }, }); }; const createBlogPageTemplate = (route, componentToRender, title, data, locale) => { createPage({ path: `${route}`, component: componentToRender, context: { title: title, result: data, locale: locale }, }); }; const createPageTemplate = (route, componentToRender, url, data, locale) => { createPage({ path: `${route}`, component: componentToRender, context: { url: url, result: data, locale: locale }, }); }; /** * Blog page template for route: "/blog" and its locales */ pageQuery.data.allContentstackPage.nodes.forEach(node => { const isDefault = locales[node.locale]; const localeList = Object.values(locales) .filter(locale => !locale.defaultLocale) .map(locale => locale.code); if (node.url === "/blog") { if (isDefault.defaultLocale) { createBlogPageTemplate(node.url, blogPageTemplate, node.title, node,node.locale); } else { localeList.forEach(code => { const localeUrl = `/${code}${node.url}`; createBlogPageTemplate(localeUrl, blogPageTemplate, node.title, node,node.locale); }); } } }); /** * Page template for route: "/", "/about-us" etc and its locales */ pageQuery.data.allContentstackPage.nodes.forEach(node => { const isDefault = locales[node.locale]; const localeList = Object.values(locales) .filter(locale => !locale.defaultLocale) .map(locale => locale.code); if (node.url !== '/blog') { if (isDefault.defaultLocale) { createPageTemplate(`${node.url}`, pageTemplate, node.url, node, node.locale); } else { localeList.forEach(code => { const localeUrl = `/${code}${node.url}`; createPageTemplate(localeUrl, pageTemplate, node.url, node, code); }); } } }); /** * Blog post template for route: "/blog/blog-post" and its locales */ blogPostQuery.data.allContentstackBlogPost.nodes.forEach(node => { const isDefault = locales[node.locale]; const localeList = Object.values(locales) .filter(locale => !locale.defaultLocale) .map(locale => locale.code); if (isDefault.defaultLocale) { createBlogPostTemplate(node.url, blogPostTemplate, node.title, node,node.locale); } else { localeList.forEach(code => { const localeUrl = `/${code}${node.url}`; createBlogPostTemplate(localeUrl, blogPostTemplate, node.title, node,node.locale); }); } }); };
- Follow the steps below to create a Context provider which handles the multi-lingual changes.
- Create a folder named hooks within the src folder.
- Create a file named useLocale.tsx within the hooks folder.
- Copy and paste the below code to the file in the path:
src/hooks/useLocale.tsx Code Snippet:import React, { createContext, useState, useContext } from "react"; import allLocales from "../Locales/locales"; const LocaleContext = createContext(""); const isBrowser = typeof window !== "undefined"; let pathname: String; const LocaleProvider = ({ children }: any) => { if (isBrowser) pathname = window?.location?.pathname; // Find a default language const defaultLang = Object.keys(allLocales).filter( lang => allLocales[lang].defaultLocale )[0]; // Get language prefix from the URL const urlLang = pathname?.split("/")[1]; // Search if locale matches defined, if not set 'en-us' as default const currentLang = Object.keys(allLocales) .map(lang => allLocales[lang].code) .includes(urlLang) ? urlLang : defaultLang; const [locale, setLocale] = useState(currentLang); const [defaultLocale, setDefaultLocale] = useState(defaultLang); const [allLocalesList] = useState(allLocales); const changeLocale = (lang: string) => { if (lang) { setLocale(lang); } }; /** * Wrapped context provider to send values below DOM tree */ return ( <LocaleContext.Provider value={{ defaultLocale, allLocalesList, 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 };
- For the context provider to work and the hook to receive values from the context, wrap the context provider (copy and paste the below code snippet) in the file wrap-with-provider.js, present in the root directory.
Code Snippet:
import React from "react" import { Provider } from "react-redux" import { LocaleProvider } from "./src/hooks/useLocale" import createStore from "./src/store/reducers/state.reducer" export default ({ element }) => { const store = createStore() return ( <LocaleProvider> <Provider store={store}>{element}</Provider> </LocaleProvider> ) }
- Add jsonRteToHtml: true flag under options in the gatsby-config.ts file for the gatsby-source-contentstack plugin if the flag is not already present.
Code Snippet:
{ resolve: "gatsby-source-contentstack", options: { api_key: CONTENTSTACK_API_KEY, delivery_token: CONTENTSTACK_DELIVERY_TOKEN, environment: CONTENTSTACK_ENVIRONMENT, cdn: `https://${cdnHost}/v3`, // Optional: expediteBuild set this to either true or false expediteBuild: true, // Optional: Specify true if you want to generate custom schema enableSchemaGeneration: true, // Optional: Specify a different prefix for types. This is useful in cases where you have multiple instances of the plugin to be connected to different stacks. type_prefix: "Contentstack", // (default), jsonRteToHtml: true , }, },
- Follow the steps below to create a custom link component that would prepend locale code to every route created except the default locale or master locale.
Note: en-us is a default locale. Thus there will be no prefix to the locale code.
- Navigate to the components folder within src and create a file named CustomLink.tsx
- Copy and paste the below code snippet to the created file:
import React, { useEffect } from "react" import { Link, navigate } from "gatsby" import { useLocale } from "../hooks/useLocale" const CustomLink = ({ to, ...props }: any) => { const { defaultLocale, locale }: any = useLocale() return ( <Link to={defaultLocale !== locale ? `/${locale + to}` : `${to}`} {...props} /> ) } export { CustomLink }
- Open Command Prompt and install the npm package react-dropdown, as given below:
npm install react-dropdown --legacy-peer-deps
- To add the dropdown in the Header for the locale/language switcher, navigate to the Header component and replace it with the below code snippet:
import React, { useState, useEffect } from "react"; import { useLocation } from "@reach/router"; import { graphql, useStaticQuery, navigate } from "gatsby"; import Dropdown from "react-dropdown"; import { CustomLink } from "./CustomLink"; import parse from "html-react-parser"; import { connect } from "react-redux"; import Tooltip from "./ToolTip"; import jsonIcon from "../images/json.svg"; import { getHeaderRes, jsonToHtmlParse, getAllEntries } from "../helper/index"; import { onEntryChange } from "../live-preview-sdk"; import { actionHeader } from "../store/actions/state.action"; import { DispatchData, Entry, HeaderProps, Menu } from "../typescript/layout"; import { useLocale } from "../hooks/useLocale"; const queryHeader = () => { const query = graphql` query { allContentstackHeader { nodes { title uid locale publish_details { locale } logo { uid url filename } navigation_menu { label page_reference { title url uid } } notification_bar { show_announcement announcement_text } } } } `; return useStaticQuery(query); }; const Header = ({ dispatch }: DispatchData) => { const { pathname } = useLocation(); const { allContentstackHeader } = queryHeader(); const { locale, allLocalesList, changeLocale, defaultLocale }: any = useLocale(); let renderValue; allContentstackHeader?.nodes.map((value, idx) => { if (value?.locale === locale) { renderValue = value; jsonToHtmlParse(value); dispatch(actionHeader(value)); } }); const [getHeader, setHeader] = useState(allContentstackHeader); function buildNavigation(ent: Entry, head: HeaderProps) { let newHeader = { ...head }; if (ent.length !== newHeader.navigation_menu.length) { ent.forEach(entry => { const hFound = newHeader?.navigation_menu.find( navLink => navLink.label === entry.title ); if (!hFound) { newHeader.navigation_menu?.push({ label: entry.title, page_reference: [ { title: entry.title, url: entry.url, $: entry.$ }, ], $: {}, }); } }); } return newHeader; } async function getHeaderData() { const headerRes = await getHeaderRes(locale); const allEntries = await getAllEntries(locale); const nHeader = buildNavigation(allEntries, headerRes); setHeader(nHeader); } async function sanitizedUrl(localeCode: string) { let urlToNavigate: string; if (localeCode !== defaultLocale) { return navigate(`/${localeCode + pathname}`); } else if (localeCode === defaultLocale) { if (pathname.split("/").includes(locale)) { urlToNavigate = pathname.replace(`/${locale}`, ""); return navigate(urlToNavigate); } } } useEffect(() => { onEntryChange(() => getHeaderData()); }, [locale]); return ( <header className="header"> <div className="note-div"> {renderValue.notification_bar.show_announcement && typeof renderValue.notification_bar.announcement_text === "string" && parse(renderValue.notification_bar.announcement_text)} </div> <div className="max-width header-div"> <div className="wrapper-logo"> <CustomLink to="/" className="logo-tag" title="Contentstack"> <img className="logo" src={renderValue.logo?.url} alt={renderValue.title} title={renderValue.title} /> </CustomLink> </div> <input className="menu-btn" type="checkbox" id="menu-btn" /> <label className="menu-icon" htmlFor="menu-btn"> <span className="navicon"></span> </label> <nav className="menu"> <ul className="nav-ul header-ul"> {renderValue.navigation_menu.map((menu: Menu, index: number) => { return ( <li className="nav-li" key={index}> {menu.label === "Home" ? ( <CustomLink to={`${menu.page_reference[0]?.url}`} activeClassName="active" > {menu.label} </CustomLink> ) : ( <CustomLink to={`${menu.page_reference[0]?.url}`} activeClassName="active" > {menu.label} </CustomLink> )} </li> ); })} </ul> </nav> {locale && ( <Dropdown options={Object.keys(allLocalesList)} onChange={option => { const { value } = option; changeLocale(value); sanitizedUrl(value); }} value={locale} placeholder="Select an option" /> )} <div className="json-preview"> <Tooltip content="JSON Preview" direction="top" dynamic={false} delay={200} status={0} > <span data-bs-toggle="modal" data-bs-target="#staticBackdrop"> <img src={jsonIcon} alt="JSON Preview icon" /> </span> </Tooltip> </div> </div> </header> ); }; export default connect()(Header);
- Navigate to the Footer component and replace it with the below code snippet:
import { Link, useStaticQuery, graphql } from "gatsby"; import React, { useState, useEffect } from "react"; import { CustomLink } from "./CustomLink"; import parser from "html-react-parser"; import { connect } from "react-redux"; import { actionFooter } from "../store/actions/state.action"; import { onEntryChange } from "../live-preview-sdk"; import { getFooterRes, getAllEntries, jsonToHtmlParse } from "../helper/index"; import { DispatchData, Entry, FooterProps, Links, Social, Menu, } from "../typescript/layout"; import { useLocale } from "../hooks/useLocale"; const queryLayout = () => { const data = useStaticQuery(graphql` query { allContentstackFooter { nodes { title locale logo { url } navigation { link { href title } } social { social_share { link { href title } icon { url } } } copyright } } } `); return data; }; const Footer = ({ dispatch }: DispatchData) => { const { allContentstackFooter } = queryLayout(); const { locale } = useLocale(); let renderValue; allContentstackFooter?.nodes.map((value, idx) => { if (value?.locale === locale) { renderValue = value; jsonToHtmlParse(value); dispatch(actionFooter(value)); } }); const [getFooter, setFooter] = useState(allContentstackFooter); function buildNavigation(ent: Entry, footer: FooterProps) { let newFooter = { ...footer }; if (ent.length !== newFooter.navigation.link.length) { ent.forEach(entry => { const fFound = newFooter?.navigation.link.find( (nlink: Links) => nlink.title === entry.title ); if (!fFound) { newFooter.navigation.link?.push({ title: entry.title, href: entry.url, $: entry.$, }); } }); } return newFooter; } async function getFooterData() { const footerRes = await getFooterRes(locale); const allEntries = await getAllEntries(locale); const nFooter = buildNavigation(allEntries, footerRes); setFooter(nFooter); } useEffect(() => { onEntryChange(() => getFooterData()); }, [locale]); return ( <footer> <div className="max-width footer-div"> <div className="col-quarter"> <CustomLink to="/" className="logo-tag"> <img src={renderValue.logo?.url} alt={renderValue.title} title={renderValue.title} className="logo footer-logo" /> </CustomLink> </div> <div className="col-half"> <nav> <ul className="nav-ul"> {renderValue.navigation.link.map((menu: Menu, index: number) => { return ( <li className="footer-nav-li" key={index} {...menu.$?.title}> <CustomLink to={menu.href}>{menu.title}</CustomLink> </li> ); })} </ul> </nav> </div> <div className="col-quarter social-link"> <div className="social-nav"> {renderValue.social.social_share.map( (social: Social, index: number) => { return ( <a href={social.link?.href} title={social.link.title.toLowerCase()} key={index} className="footer-social-links" > <img src={social.icon?.url} alt="social-icon" /> </a> ); } )} </div> </div> </div> <div className="copyright"> {typeof renderValue.copyright === "string" ? ( <div>{parser(renderValue?.copyright)}</div> ) : ( "" )} </div> </footer> ); }; export default connect()(Footer);
- Now replace all the default Gatsby Link components with our CustomLink component in all the components where we have used Link.
Example
- Here is a Gatsby Link component in the Header component:
<Link to="/" className="logo-tag" title="Contentstack"> <img className="logo" src={renderValue.logo?.url} alt={renderValue.title} title={renderValue.title} /> </Link>
- Replace it by importing the CustomLink component:
import { CustomLink } from "./CustomLink" <CustomLink to="/" className="logo-tag" title="Contentstack"> <img className="logo" src={renderValue.logo?.url} alt={renderValue.title} title={renderValue.title} /> </CustomLink>
You can see the Header component code snippet in step no.8.
To achieve a multi-lingual Gatsby site without any plugins, you must make a few changes in page creation using templates. Previously, we had home and blog page routes within the src/pages folder which were not created dynamically. This created conflicts when our route/URL with a locale-specific page was created. So to mitigate that, we have deleted the home and blog pages from the src/pages folder. After deleting, the pages folder would look like this:
Now it would just have a 404.tsx route.
- Here is a Gatsby Link component in the Header component:
- Create a file named blog-page.tsx inside the src/templates folder as given below:
- Copy and paste the below code snippet to the blog-page.tsx file:
import React, { useState, useEffect } from "react"; import { graphql } from "gatsby"; import Layout from "../components/Layout"; import SEO from "../components/SEO"; import RenderComponents from "../components/RenderComponents"; import ArchiveRelative from "../components/ArchiveRelative"; import { onEntryChange } from "../live-preview-sdk/index"; import { getPageRes, getBlogListRes, jsonToHtmlParse } from "../helper/index"; import { PageProps } from "../typescript/template"; import BlogList from "../components/BlogList"; import { useLocale } from "../hooks/useLocale"; const Blog = ({ data: { allContentstackBlogPost, contentstackPage }, }: PageProps) => { jsonToHtmlParse(allContentstackBlogPost.nodes); const [getEntry, setEntry] = useState({ banner: contentstackPage, blogList: allContentstackBlogPost.nodes, }); const { locale }: any = useLocale(); async function fetchData() { try { const banner = await getPageRes("/blog", locale); const blogList = await getBlogListRes(locale); if (!banner || !blogList) throw new Error("Error 404"); setEntry({ banner, blogList }); } catch (error) { console.error(error); } } useEffect(() => { onEntryChange(() => fetchData()); }, [contentstackPage, locale]); const newBlogList = [] as any; const newArchivedList = [] as any; getEntry.blogList?.forEach(post => { if (locale === post.locale) { if (post.is_archived) { newArchivedList.push(post) } else { newBlogList.push(post) } } }); return ( <Layout blogPost={getEntry.blogList} banner={getEntry.banner}> <SEO title={getEntry.banner.title} /> <RenderComponents components={getEntry.banner.page_components} blogPage contentTypeUid="page" entryUid={getEntry.banner.uid} locale={getEntry.banner.locale} /> <div className="blog-container"> <div className="blog-column-left"> {newBlogList?.map((blog: BlogList, index: number) => { return <BlogList blogList={blog} key={index} />; })} </div> <div className="blog-column-right"> <h2>{contentstackPage?.page_components[1]?.widget?.title_h2}</h2> <ArchiveRelative data={newArchivedList} /> </div> </div> </Layout> ); }; export const postQuery = graphql` query ($locale: String!) { contentstackPage(locale: { eq: $locale }) { title url uid locale seo { enable_search_indexing keywords meta_description meta_title } page_components { contact_details { address email phone } from_blog { title_h2 featured_blogs { title uid url is_archived featured_image { url uid } body author { title uid bio } } view_articles { title href } } hero_banner { banner_description banner_title bg_color call_to_action { title href } } our_team { title_h2 description employees { name designation image { url uid } } } section { title_h2 description image { url uid } image_alignment call_to_action { title href } } section_with_buckets { title_h2 description buckets { title_h3 description icon { url uid } call_to_action { title href } } } section_with_cards { cards { title_h3 description call_to_action { title href } } } widget { title_h2 type } } } allContentstackBlogPost { nodes { url title uid locale author { title uid } related_post { title body uid } date featured_image { url uid } is_archived body } } } `; export default Blog;
- Copy and paste the below code snippet to the blog-post.tsx file:
import React, { useState, useEffect } from "react"; import moment from "moment"; import { graphql } from "gatsby"; import SEO from "../components/SEO"; import parser from "html-react-parser"; import Layout from "../components/Layout"; import { useLocation } from "@reach/router"; import { onEntryChange } from "../live-preview-sdk/index"; import ArchiveRelative from "../components/ArchiveRelative"; import RenderComponents from "../components/RenderComponents"; import { getPageRes, getBlogPostRes, jsonToHtmlParse } from "../helper"; import { PageProps } from "../typescript/template"; import { useLocale } from "../hooks/useLocale"; const blogPost = ({ data: { contentstackBlogPost, contentstackPage }, pageContext, }: PageProps) => { const { pathname } = useLocation(); const { locale }: any = useLocale(); jsonToHtmlParse(contentstackBlogPost); const [getEntry, setEntry] = useState({ banner: contentstackPage, post: contentstackBlogPost, }); async function fetchData() { try { const { result: { url }, } = pageContext; const entryRes = await getBlogPostRes(url, locale); const bannerRes = await getPageRes("/blog", locale); if (!entryRes || !bannerRes) throw new Error("Error 404"); setEntry({ banner: bannerRes, post: entryRes }); } catch (error) { console.error(error); } } useEffect(() => { onEntryChange(() => fetchData()); }, [contentstackBlogPost, contentstackPage]); return ( <Layout blogPost={getEntry.post} banner={getEntry.banner}> <SEO title={getEntry.post.title} /> <RenderComponents components={getEntry.banner.page_components} blogPage contentTypeUid="blog_post" entryUid={getEntry.banner.uid} locale={getEntry.banner.locale} /> <div className="blog-container"> <div className="blog-detail"> <h2 {...getEntry.post.$?.title}> {getEntry.post.title ? getEntry.post.title : ""} </h2> <span> <p> {moment(getEntry.post.date).format("ddd, MMM D YYYY")},{" "} <strong {...getEntry.post.author[0]?.$?.title}> {getEntry.post.author[0]?.title} </strong> </p> </span> <span {...getEntry.post.$?.body}>{parser(getEntry.post.body)}</span> </div> <div className="blog-column-right"> <div className="related-post"> {getEntry.banner.page_components[2].widget && ( <h2 {...getEntry.banner.page_components[2]?.widget.$?.title_h2}> {getEntry.banner.page_components[2].widget.title_h2} </h2> )} <ArchiveRelative data={getEntry.post.related_post && getEntry.post.related_post} /> </div> </div> </div> </Layout> ); }; export const postQuery = graphql` query ($title: String!) { contentstackBlogPost(title: { eq: $title }) { url title body uid locale date author { title bio } related_post { body url title date } seo { enable_search_indexing keywords meta_description meta_title } } contentstackPage(url: { eq: "/blog" }) { title url uid locale seo { enable_search_indexing keywords meta_description meta_title } page_components { contact_details { address email phone } from_blog { title_h2 featured_blogs { title uid url is_archived featured_image { url uid } body author { title uid bio } } view_articles { title href } } hero_banner { banner_description banner_title bg_color call_to_action { title href } } our_team { title_h2 description employees { name designation image { url uid } } } section { title_h2 description image { url uid } image_alignment call_to_action { title href } } section_with_buckets { title_h2 description buckets { title_h3 description icon { url uid } call_to_action { title href } } } section_with_cards { cards { title_h3 description call_to_action { title href } } } widget { title_h2 type } } } } `; export default blogPost;
- Copy and paste the below code snippet to the page.tsx file:
import React, { useState, useEffect } from "react"; import { graphql } from "gatsby"; import SEO from "../components/SEO"; import Layout from "../components/Layout"; import { onEntryChange } from "../live-preview-sdk/index"; import { getPageRes, jsonToHtmlParse } from "../helper"; import RenderComponents from "../components/RenderComponents"; import { PageProps } from "../typescript/template"; import { useLocale } from "../hooks/useLocale"; const Page = ({ data: { contentstackPage }, pageContext }: PageProps) => { jsonToHtmlParse(contentstackPage); const [getEntry, setEntry] = useState(contentstackPage); const { locale }: any = useLocale(); async function fetchData() { try { const entryRes = await getPageRes(pageContext?.url, locale); if (!entryRes) throw new Error("Error 404"); setEntry(entryRes); } catch (error) { console.error(error); } } useEffect(() => { onEntryChange(() => fetchData()); }, []); return ( <Layout pageComponent={getEntry}> <SEO title={getEntry.title} /> <div className="about"> {getEntry.page_components && ( <RenderComponents components={getEntry.page_components} contentTypeUid="page" entryUid={getEntry.uid} locale={getEntry.locale} /> )} </div> </Layout> ); }; export const pageQuery = graphql` query ($url: String!, $locale: String!) { contentstackPage(url: { eq: $url }, locale: { eq: $locale }) { uid title url seo { meta_title meta_description keywords enable_search_indexing } locale page_components { contact_details { address email phone } from_blog { title_h2 featured_blogs { uid title url featured_image { url uid } author { title uid } body date } view_articles { title href } } hero_banner { banner_description banner_title banner_image { uid url } bg_color text_color call_to_action { title href } } our_team { title_h2 description employees { name designation image { uid title url } } } section { title_h2 description image_alignment image { uid title url } call_to_action { title href } } section_with_buckets { title_h2 description bucket_tabular buckets { title_h3 description icon { uid title url } call_to_action { title href } } } section_with_cards { cards { title_h3 description call_to_action { title href } } } section_with_html_code { title html_code_alignment html_code description } widget { type title_h2 } } } } `; export default Page;
- After making all the necessary changes, save the code and run gatsby develop or gatsby build in Command Prompt.
- Run gatsby serve to check the changes.
Ensure you have made the necessary changes to the stack to support Internationalization.
You have now added multi-lingual support for the Gatsby starter app.