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.
Note: Live preview is not supported with the multi-lingual Gatsby app.
In our example, we have two locales:
Note: Also, translate those entries into French to differentiate the results.
Follow the steps to configure the app for multi-lingual support:
Note: In our example, we have used English and French as our locales.
module.exports = {
"en-us": {
code: "en-us",
locale: "English",
defaultLocale: true,
},
"fr": {
code: "fr",
locale: "french",
},
}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);
});
}
});
};
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 };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>
)
}
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 ,
},
},
Note: en-us is a default locale. Thus there will be no prefix to the locale code.
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 }
npm install react-dropdown --legacy-peer-deps
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);
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);
Example
<Link to="/" className="logo-tag" title="Contentstack">
<img className="logo" src={renderValue.logo?.url} alt={renderValue.title} title={renderValue.title} />
</Link>
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.

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;
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;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;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.