The Complete Guide to Build a Full Blown Multilanguage Website with Next.js
This guide is for the beginners and the professionals who want to build a full-blown multilanguage website using Next.js and Storyblok. With this step by step guide, you will get a dynamic Next.js website running on Vercel, using a Storyblok API for the multilanguage content.
If you are in a hurry you can download the whole source code of the project from GitHub https://github.com/storyblok/nuxtjs-multilanguage-website or jump into one of the following chapters.
The project in this article was developed using the following versions of these technologies:
- Next.js v9.5.3
- Nodejs v12.3.1
- Npm v6.9.0
Keep in mind that these versions may be slightly behind the latest ones.
Initialization of the Next.js Website
We will start by initializing the project with the Next.js starter template. Create or open a folder, where you want to store your project and run the following command in the terminal.
yarn create next-app my-next-website // npm init next-app my-next-website
cd my-next-website
yarn dev // npm run dev
Next.js will start your local development environment at http://localhost:3000 and you should see the introduction screen of the Next.js app similar to the following picture.

Building a Skelton
We are going to use TailwindCSS to style the website in this project. Install tailwindcss and postcss-preset-env packages.
Bash
yarn add tailwindcss postcss-preset-env -D
Create postcss.config.js file with the following content.
./postcss.config.js
module.exports = {
plugins: [
'tailwindcss',
'postcss-preset-env'
],
}
Create tailwind.config.js file with the following content.
./tailwind.config.js
module.exports = {
purge: [],
theme: {
fontFamily: {
'sans': 'Roboto, Arial, sans-serif',
'serif': 'Merriweather, Georgia, serif'
},
extend: {
},
},
variants: {},
plugins: [],
}
You may have noticed that we are using special fonts. Download the fonts from fonts.google.com and add them to the public folder. You will need the following fonts - Merriweather-Bold, Roboto-Medium & Roboto-Regular. After downloading add them in the folder ./public/fonts in your project.
Next we will have to create a file named fonts.css in the fonts folder.
./public/fonts/fonts.css
@font-face {
font-family: 'Merriweather';
font-weight: 700;
font-style: normal;
src: url('Merriweather-Bold.ttf');
}
@font-face {
font-family: 'Roboto';
font-weight: 400;
font-style: normal;
src: url('Roboto-Regular.ttf');
}
@font-face {
font-family: 'Roboto';
font-weight: 500;
font-style: normal;
src: url('Roboto-Medium.ttf');
}
Create tailwind.css file in the styles folder with the following imports.
./styles/tailwind.css
@import 'tailwindcss/base';
@import 'tailwindcss/components';
@import 'tailwindcss/utilities';
Finally, edit the _app.js file and remove the import of the globals.css and import the tailwind.css instead.
./pages/_app.js
import '../styles/tailwind.css'
function MyApp({ Component, pageProps }) {
return <Component {...pageProps} />
}
export default MyApp
You should see a change of fonts at http://localhost:3000/ after these changes.
To configure Purge CSS to remove unused parts of tailwindCSS check this medium article.
Defining the Layout
Next we will create a folder named components for all of our components. As the first component we create the file components/Layout.js with the following content in our newly created components folder.
./components/Layout.js
import Head from '../components/Head'
import Navigation from '../components/Navigation'
import Footer from '../components/Footer'
const Layout = ({ children }) => (
<div className="bg-gray-300">
<Head />
<Navigation />
{children}
<Footer />
</div>
)
export default Layout
Creating Header & Footer
In Layout.js you can see that we have to create also Header (Navigation) and Footer for our website. We will keep them as dummy component for now and configure them with links later in this guide. Create Head.js, Navigation.js and Footer.js in the components folder.
./components/Head.js
import React from 'react'
import NextHead from 'next/head'
const Head = ({ title, description }) => (
<NextHead>
<meta charSet="UTF-8" />
<title>{title || ''}</title>
<meta name="description" content={description || ''} />
<meta name="viewport" content="width=device-width, initial-scale=1" />
</NextHead>
)
export default Head
./components/Navigation.js
const Navigation = ({settings}) => (
<header className="w-full bg-white">
<nav className="" role="navigation">
<div className="container mx-auto p-4 flex flex-wrap items-center md:flex-no-wrap">
<div className="mr-4 md:mr-8">
<a href="/">
<svg width="69" height="66" xmlns="http://www.w3.org/2000/svg"><g fill="none" fillRule="evenodd"><path fill="#FFF" d="M-149-98h1440v938H-149z"/><path d="M37.555 66c17.765 0 27.051-16.38 30.24-33.415C70.986 15.549 52.892 4.373 35.632.52 18.37-3.332 0 14.876 0 32.585 0 50.293 19.791 66 37.555 66z" fill="#000"/><path d="M46.366 42.146a5.55 5.55 0 01-1.948 2.043c-.86.557-1.811 1.068-2.898 1.3-1.087.279-2.265.511-3.487.511H22V20h18.207c.905 0 1.675.186 2.4.604a6.27 6.27 0 011.811 1.485 7.074 7.074 0 011.54 4.504c0 1.207-.317 2.368-.905 3.482a5.713 5.713 0 01-2.718 2.507c1.45.418 2.582 1.16 3.442 2.229.815 1.114 1.223 2.553 1.223 4.364 0 1.16-.226 2.136-.68 2.971h.046z" fill="#FFF"/></g></svg>
</a>
</div>
<div className="text-black">
<p className="text-lg">Storyblok</p>
<p>NextJS Demo</p>
</div>
<div className="ml-auto md:hidden">
<button
className="flex items-center px-3 py-2 border rounded"
type="button"
>
<svg
className="h-3 w-3"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<title>Menu</title>
<path d="M0 3h20v2H0V3zm0 6h20v2H0V9zm0 6h20v2H0v-2z" />
</svg>
</button>
</div>
<div className="w-full md:w-auto md:flex-grow md:flex md:items-center">
<ul className="flex flex-col mt-4 -mx-4 pt-4 border-t md:flex-row md:items-center md:mx-0 md:mt-0 md:pt-0 md:mr-4 md:ml-auto lg:mr-8 md:border-0">
<li>
<a href="/" className="block px-4 py-1 md:p-2 lg:px-8">Home</a>
</li>
<li>
<a href="/" className="block px-4 py-1 md:p-2 lg:px-8">About</a>
</li>
</ul>
<ul className="flex flex-col mt-4 -mx-4 pt-4 border-t md:flex-row md:items-center md:mx-0 md:mt-0 md:pt-0 md:border-0">
<li>
<a href="" className="block px-4 py-1 md:p-2 rounded-lg lg:px-4 bg-black text-white">EN</a>
</li>
<li>
<a href="/de" className="block px-4 py-1 md:p-2 rounded-lg lg:px-4">DE</a>
</li>
</ul>
</div>
</div>
</nav>
</header>
)
export default Navigation
./components/Footer.js
const Footer = () => {
return (
<footer className="text-center flex flex-col items-center py-20 container mx-auto">
<p>Next.js Demo Website created with Storyblok</p>
<div className="flex items-center my-8">
<img
src="https://a.storyblok.com/f/51376/3856x824/fea44d52a9/colored-full.png"
alt="Storyblok Logo"
className="w-48 m-4"
/>
<svg className="w-32 m-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 207 124"><defs/><g fill="#000" fillRule="nonzero"><path d="M48.942 32.632h38.96v3.082H52.512v23.193h33.278v3.082H52.513v25.464h35.794v3.081H48.942V32.632zm42.45 0h4.139l18.343 25.464 18.749-25.464L158.124.287l-41.896 60.485 21.59 29.762h-4.302l-19.642-27.086L94.15 90.534h-4.22l21.751-29.762-20.29-28.14zm47.967 3.082v-3.082h44.397v3.082h-20.453v54.82h-3.571v-54.82h-20.373zM.203 32.632h4.464l61.557 91.671-25.439-33.769L3.936 37.011l-.162 53.523H.203zM183.397 86.523c.738 0 1.276-.563 1.276-1.29 0-.727-.538-1.29-1.276-1.29-.73 0-1.277.563-1.277 1.29 0 .727.547 1.29 1.277 1.29zm3.509-3.393c0 2.146 1.555 3.549 3.822 3.549 2.414 0 3.874-1.446 3.874-3.956v-8.837h-1.946v8.828c0 1.394-.704 2.138-1.946 2.138-1.112 0-1.867-.692-1.893-1.722h-1.911zm10.24-.113c.14 2.233 2.007 3.662 4.787 3.662 2.97 0 4.83-1.498 4.83-3.887 0-1.878-1.06-2.917-3.632-3.514l-1.38-.338c-1.634-.38-2.294-.891-2.294-1.783 0-1.125 1.025-1.86 2.563-1.86 1.459 0 2.466.718 2.649 1.869h1.893c-.113-2.103-1.971-3.583-4.516-3.583-2.737 0-4.56 1.48-4.56 3.704 0 1.835 1.033 2.926 3.3 3.454l1.616.39c1.659.389 2.388.96 2.388 1.912 0 1.108-1.146 1.913-2.71 1.913-1.676 0-2.84-.753-3.005-1.939h-1.928z"/></g></svg>
</div>
<p className="underline">
<a href="https://www.storyblok.com/tp/next-js-react-guide">View tutorial on Storyblok</a>
</p>
</footer>
)
}
export default Footer
Next we need to replace the default welcome HTML code in the index.js file. Replace it with the following code:
./pages/index.js
import Layout from '../components/Layout'
export default function Home() {
return (
<Layout>
<div className="container mx-auto p-4 text-center">The content from Storyblok will follow soon...</div>
</Layout>
)
}
If you did everything correct you should see a result similar to the following picture.

Creating the Content Structure
Up to this point, we only used hard-coded mockup data in our Next.js project. Before we connect Storyblok with Next.js and render our first data onto the screen, we have to define the structure of our data in Storyblok. A good thing about this process is that we don't need to set up the connection right away so you can already prepare the data before you choose the front-end framework.
To better understand the content structure, we strongly recommend reading the Structures of Content chapter of the developer guide.
First signup or login at app.storyblok.com and create a new space. You can name it however you like. The first screen you see should be similar to the following image:

Above you can see that once you created a new Space in Storyblok it will ship with default content. We will use this content and the created components in our project. For your own project feel free to change, rename, or even delete those components and create your own. If you navigate to Components in the main navigation you will see a list the default components (page. teaser, feature, grid). These components are ready to be used to create new Stories in Storyblok.

By default Storyblok ships with a component called Page. The Page component has a checkmark in the Content Type column. The checkmark there tells us that the Page component can be used to create new Stories from. You would not be able to create new Stores from other components such as grid, feature, and teaser as they can only be used inside a Field of the type blocks that lives inside a Content Type. That way you can create an almost infinite number of combinations with your components, with multiple levels of nesting.
Read more about components and the difference between Content Types and Bloks (nestable components) in an essential part of the developer guide.
Now that we already have components defined in Storyblok what is missing is the implementation of these components defined in the Next.js project. Let's create them in our components folder using the following source code.
Teaser.js
./components/Teaser.js
import React from 'react'
import SbEditable from 'storyblok-react'
const Teaser = ({blok}) => {
return (
<SbEditable content={blok}>
<div className="py-10">
<h2 className="font-serif text-3xl text-center">{blok.headline}</h2>
</div>
</SbEditable>
)
}
export default Teaser
Feature.js
./components/Feature.js
import SbEditable from 'storyblok-react'
const Feature = ({blok}) => {
return (
<SbEditable content={blok}>
<div className="text-center">
<h2 className="text-xl font-medium">{blok.name}</h2>
</div>
</SbEditable>
)
}
export default Feature
Grid.js
./components/Grid.js
import DynamicComponent from './DynamicComponent'
import SbEditable from 'storyblok-react'
const Grid = ({blok}) => (
<SbEditable content={blok}>
<ul className="flex py-8 mb-6">
{blok.columns.map((nestedBlok) => (
<li key={nestedBlok._uid} className="flex-auto px-6">
<DynamicComponent blok={nestedBlok} />
</li>
)
)}
</ul>
</SbEditable>
)
export default Grid
Page.js
./components/Page.js
import DynamicComponent from './DynamicComponent'
import SbEditable from 'storyblok-react'
const Page = ({ content }) => (
<SbEditable content={content}>
<main>
{content.body.map((blok) => (
<DynamicComponent blok={blok} key={blok._uid} />
))}
</main>
</SbEditable>
)
export default Page
Explanation of blok Prop
You probably noticed the blok
prop in all of the components created by above. The prop is used to pass data down into each of the components. There are various solutions for this challenge and you don't have to call this prop blok
as we did. Keep in mind that you need to pass the data down to the nested components to render them.
Want to learn more about dynamic component rendering in React.js? We’ve prepared an article for you on that.
Explanation of DynamicComponent.js
You might have noticed a DynamicComponent.js in the code of the previous components (Grid.js, Page.js). We are using this component to decide which component should be rendered on the screen depending on the component name it has in Storyblok. In the source of DynamicComponent.js you can see that all the possible components are getting imported and will get rendered according to the the value of blok.component
. Create the DynamicComponent.js using the following code.
./components/DynamicComponent.js
import Teaser from './Teaser'
import Feature from './Feature'
import Grid from './Grid'
import Placeholder from './Placeholder'
const Components = {
'teaser': Teaser,
'grid': Grid,
'feature': Feature
}
const DynamicComponent = ({blok}) => {
if (typeof Components[blok.component] !== 'undefined') {
const Component = Components[blok.component]
return <Component blok={blok} />
}
return <Placeholder componentName={blok.component}/>
}
export default DynamicComponent
In the Placeholder.js component contained in the DynamicComponent.js, which is used to render a placeholder in case of unknown components is coming from the Storyblok API. Don't forget to create it.
./components/Placeholder.js
const Placeholder = ({componentName}) => (
<div className="py-4 border border-red-200 bg-red-100">
<p className="text-red-700 italic text-center">The component <strong>{componentName}</strong> has not been created yet.</p>
</div>
);
export default Placeholder;
You can check the current progress in this GitHub commit created default sb components.
Connecting Storyblok with Next.js
So far we have created the skeleton of our website and prepared the default components in Next.js. Before we start with the design of the pages, we need to set up the connection between Next.js and Storyblok to get the data for the components. To do that, we can use the storyblok-js-client package maintained by the Storyblok. Install it together with the following packages (storyblok-react, axios) as shown in the following example:
Bash
$ yarn add storyblok-js-client storyblok-react axios
# or
$ npm install storyblok-js-client storyblok-react axios --save
Storyblok-js-client will use axios under the hood to get the data from the Storyblok Content Delivery API and storyblok-react will bring a special component, which allows us to listen to events from the Storyblok app to make use of the real-time editing features of Storyblok Visual Editor. After you've installed the packages we have to configure the environment and add the Storyblok Access Token.
Create storyblok-service.js in the utils folder, which will be responsible for the communication with Storyblok. Replace <Paste Space Access Token (API-key)>
with the preview token from your space.
./utils/storyblok-service.js
import StoryblokClient from 'storyblok-js-client'
class StoryblokService {
constructor() {
this.devMode = true // Always loads draft
this.token = '<Paste Space Access Token (API-key)>'
this.client = new StoryblokClient({
accessToken: this.token,
cache: {
clear: 'auto',
type: 'memory'
}
})
this.query = {}
}
getCacheVersion() {
return this.client.cacheVersion
}
// ask Storyblok's Content API for content of story
get(slug, params) {
params = params || {}
if (this.getQuery('_storyblok') || this.devMode || (typeof window !== 'undefined' && window.storyblok)) {
params.version = 'draft'
}
if (typeof window !== 'undefined' && typeof window.StoryblokCacheVersion !== 'undefined') {
params.cv = window.StoryblokCacheVersion
}
return this.client.get(slug, params)
}
// initialize the connection between Storyblok & Next.js in Visual Editor
initEditor(reactComponent) {
if (window.storyblok) {
window.storyblok.init()
// reload on Next.js page on save or publish event in Storyblok Visual Editor
window.storyblok.on(['change', 'published'], () => location.reload(true))
// Update state.story on input in Visual Editor
// this will alter the state and replaces the current story with a current raw story object and resolve relations
window.storyblok.on('input', (event) => {
if (event.story.content._uid === reactComponent.state.story.content._uid) {
event.story.content = window.storyblok.addComments(event.story.content, event.story.id)
window.storyblok.resolveRelations(event.story, ['featured-articles.articles'], () => {
reactComponent.setState({
story: event.story
})
})
}
})
}
}
setQuery(query) {
this.query = query
}
getQuery(param) {
return this.query[param]
}
bridge() {
if (!this.getQuery('_storyblok') && !this.devMode) {
return ''
}
return (<script src={'//app.storyblok.com/f/storyblok-latest.js?t=' + this.token}></script>)
}
}
const storyblokInstance = new StoryblokService()
export default storyblokInstance
You can find all tokens of your space in the Settings navigation item under the API-Key (1) tab. Check the following image if you struggle to find the preview access token.

You should save sensitive data and your API tokens,even through the Storyblok Content Delivery API token is read only, in .env and not directly in the storyblok-service.js file as we did.
Requesting the First Data
Let's use the storyblok-sevice.js in the index.js and load the first Story from Storyblok using the slug home
to retrieve the homepages' content. Update index.js with the following code.
./pages/index.js
import Page from '../components/Page'
import Layout from '../components/Layout'
import StoryblokService from '../utils/storyblok-service'
export default class extends React.Component {
constructor(props) {
super(props)
this.state = {
story: props.res.data.story
}
}
static async getInitialProps({ query }) {
StoryblokService.setQuery(query)
let res = await StoryblokService.get('cdn/stories/home', {})
return {
res
}
}
componentDidMount() {
StoryblokService.initEditor(this)
}
render() {
const contentOfStory = this.state.story.content
return (
<Layout>
<Page content={contentOfStory} />
</Layout>
)
}
}
In the Layout.js we added the storyblok-bridge initialization to enable the real-time editing of the Storyblok Visual Editor so make sure to update the file as shown below.
./components/Layout.js
import Head from '../components/Head'
import Navigation from '../components/Navigation'
import Footer from '../components/Footer'
import StoryblokService from '../utils/storyblok-service'
const Layout = ({ children }) => (
<div className="bg-gray-300">
<Head />
<Navigation />
{children}
<Footer />
{StoryblokService.bridge()}
</div>
)
export default Layout
After running yarn dev
in the terminal you already should be greeted with a screen as shown below on your own local environment (localhost:3000). Reach out to us on Discord if you have any problems with the tutorial, we're more than happy to give you a helping hand.

You can check the current progress in this GitHub commit created connection to storyblok.
Setting Up a Real-Time Visual Editor
Our website is running on the localhost:3000
and there we can preview the content of the Home story. The better way to preview your content is by using the real-time preview in Visual Editor, where you and your editors can see the changes instantly in real-time.
If you open the Home Story in the Content area of Storyblok you will see that the Visual Editor is not yet configured.

You may edit your content, or even save it and publish it. You will still need to go to another browser tab and open the localhost:3000 to see the result, so let's change it.

Navigate into the Settings (1) of your Space and click on the General (2) tab to change the value of the Location (3) (default environment) to http://localhost:3000/.
You can also create a special preview URL for different environments like the preview from Vercel, Netlify, or other hosting platforms. This can be done by clicking on "Add preview url" (4) button on the screen.

If you open your Home Story using the Visual Editor right now, you will see a Next.js error message. This is good and it means that we connected Next.js with Storyblok. Next.js tells us that we are trying to reach the non-existent URL of http://localhost:3000/home
as the slug of the Home (1) story is home
.
Let's fix this by overriding the Real Path (3) property of the Home (1) story to /
as this Story represents the homepage (index.js) and shouldn't have any slug. We cannot remove the slug in Storyblok as our Content Delivery API needs an identifier, in our case the slug of this Story which can not be empty.

On the top right corner we will hit Save so we don't forget to save the changes that we just did. After that you should see the update of the preview. With that your real-time preview is up and running. Try to edit the content of the story and see the instant change in the window on the left side.

Understanding the Structure of Components
Since we already have a working Visual Editor with a preview function, we can click on different highlighted areas. The content editor will open the clicked components in the right-hand side of the Visual Editor. You can also open and collapse these components (bloks) in the right-hand side editor without using the preview.
You can see the opened Grid component and its content in the next image. The Grid in this case consists only of an array of other bloks (nested components) and in this particular case is made up of three Feature components. Feel free to add more or remove one of them.

If you inspect the source code using the React.js devtools you will see the same structure of components that we created in Storyblok since our Next.js set-up uses the DynamicComponent.js we've created to include components depending on its name in Storyblok (Grid, Feature, ...).

Extending the Schema of Bloks in Storyblok
We have loaded the demo content of Storyblok and set up the real-time visual editor. In this section we will extend existing bloks (nested components) with new fields and adjust the templates in Next.js.
Update of the Teaser
First, we will add an image field to the Teaser component. For that, navigate to the Components section in the Storyblok UI and click on the Teaser component. Create a new field named image in the opened overlay.

Click on the created image field and define its type to be Asset and as field-type and allow only Images. Save the changes.

We have to update our Teaser component ./components/Teaser.js
with the following code to render the image.
./components/Teaser.js
import React from 'react'
import SbEditable from 'storyblok-react'
const Teaser = ({blok}) => {
return (
<SbEditable content={blok}>
<div className="bg-white-half">
<div className="pb-6 pt-16 container mx-auto">
<h2 className="text-6xl font-bold font-serif text-primary">{blok.headline}</h2>
<img src={blok.image.filename} alt={blok.image.alt} className="w-full" />
</div>
</div>
</SbEditable>
)
}
export default Teaser
After adding the field and uploading an image you will see that the image is already being shown if you changed the content of the Teaser.js.

You can check the current progress in this Github commit added teaser image.
Extending the Feature Component
Every feature component currently only has one field: title. Let's extend the Feature blok by including a description text field and an icon field.
Open up the schema of the component directly from the Visual Editor. Click on one of the Features components and in the right-hand corner you should see the Define Schema button. Click on it and the Schema overlay will open. Changes done to a component here will effect all components with the same name it is basically a quick access the the Components main navigation item.

Now we need to define two new fields in the Feature component. Add a field named description of the type textarea
, and a field named icon of the type single-select
.

For the icons we will need to create some new Icon Components. First we will create a DynamicIcon.js
file in a new components/icons
folder. This will resolve all our Icons later to the Single Select option.
./components/icons/DynamicIcon.js
import React from 'react'
import Twitter from './Twitter'
const Components = {
'twitter': Twitter,
}
const DynamicIcon = ({ type }) => {
if (typeof Components[type] !== 'undefined') {
const Component = Components[type]
return <Component />
}
return null
}
export default DynamicIcon
Then we need the actual icon, which is just a function that returns our SVG icon. You can make use of svg2Jsx to create the code for you. We're going to use three icons where one (components/icons/Twitter.js) can be copied from below. You can find the code for the other two icons in the commit.
./components/icons/Twitter.js
import React from "react";
function Icon() {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="63" height="61">
<g fill="none" fillRule="evenodd">
<path fill="#FFF" d="M-212-65h1340v372H-212z"></path>
<path
fill="#672E9B"
d="M34.71 61c16.419 0 25.002-15.139 27.95-30.884C65.607 14.371 48.884 4.042 32.93.481 16.98-3.079 0 13.75 0 30.116S18.292 61 34.71 61z"
></path>
<path
stroke="#FFF"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M45 20.012a12.935 12.935 0 01-3.71 1.79 5.33 5.33 0 00-5.884-1.457A5.244 5.244 0 0032 25.314v1.17a12.639 12.639 0 01-10.636-5.302s-4.728 10.533 5.909 15.214A13.855 13.855 0 0119 38.737c10.636 5.852 23.636 0 23.636-13.459a5.217 5.217 0 00-.094-.97A9.013 9.013 0 0045 20.011z"
></path>
</g>
</svg>
);
}
export default Icon;
Open the Feature component in components/Feature.js
and extend it with the new fields as well.
./components/Feature.js
import React from 'react'
import SbEditable from 'storyblok-react'
import DynamicIcon from './icons/DynamicIcon'
const Feature = ({blok}) => {
return (
<SbEditable content={blok} key={blok._uid}>
<div className="py-16 max-w-sm p-2 sm:p-10 text-center flex flex-col items-center">
<DynamicIcon type={blok.icon} />
<div className="px-6 py-4">
<div className="font-bold text-xl my-4">{blok.name}</div>
<p className="text-base text-gray-600">
{blok.description}
</p>
</div>
</div>
</SbEditable>
)
}
export default Feature
Finally, add new content to your features and you should see results similar to the following image:

You can check the current progress in this Github commit added icons & description to feature.
Creating a Blog Section
A common task when creating a website is to develop an overview page of collections like News, Reviews, Posts or Products. In our example, we will create a simple blog with blog posts.
Creating the Post Content Type in Storyblok
To create new blog posts, we will go over to Content and create a new folder called blog
with a default content type of Post
by making use of Storyblok blueprints.

If we take a look at the Content Type after we created it, we can see that it has a couple of different fields: title
, image
, intro
, long_text
and author
.

Creating the Post Component in Next
Now we need to create the component for our blog posts. Let's create a BlogPost.js
file.
components/BlogPost.js
import React from "react"
import SbEditable from "storyblok-react"
import { render } from "storyblok-rich-text-react-renderer"
const BlogPost = ({ blok }) => {
return (
<SbEditable content={blok} key={blok._uid}>
<div className="bg-white-half w-full">
<div className="max-w-3xl mx-auto text-center pt-20 flex flex-col items-center">
<h1 className="text-5xl font-bold font-serif text-primary tracking-wide">
{blok.title}
</h1>
<p className="text-gray-500 text-lg max-w-lg">{blok.intro}</p>
<img className="w-full bg-gray-300 my-16" src={blok.image} />
</div>
</div>
<div className="max-w-3xl mx-auto text-center pt-20 flex flex-col items-center">
<div className="leading-relaxed text-xl text-left text-gray-800 drop-cap">
{render(blok.long_text)}
</div>
</div>
</SbEditable>
)
}
export default BlogPost
As you can see here, we used storyblok-rich-text-react-renderer
to render our Richtext content. To make this component work correctly we need to install the package from npm.
yarn add storyblok-rich-text-react-renderer
Let's move on to creating our first post in Storyblok. Go to Content (1), navigate into the blog folder (2) and create a new Entry (3) with a distinct Name (4) for this Story.

If we open our new post, we will see that nothing is rendered yet (1), because we haven't created that route in Next.js yet. You can go ahead and add some content to our first post (2). In the bottom you will find the Richtext field (3), which we rendered with our storyblok storyblok-rich-text-react-renderer
.

Creating the blog route in Next
Now the last thing that is missing is our blog route. We can do that with Next.js dynamic route feature. First we have to create a new folder in Pages called blog. Then we have to create a new file called pages/blog/[slug].js
. Here we will load our BlogPost component and display it depending on what route was opened with the correct content.
pages/blog/[slug].js
import React from 'react'
import Layout from '../../../components/Layout'
import BlogPost from '../../../components/BlogPost'
import StoryblokService from '../../../utils/storyblok-service'
export default class extends React.Component {
constructor(props) {
super(props)
this.state = {
story: props.res.data.story,
language: props.language,
}
}
static async getInitialProps({ asPath, query }) {
StoryblokService.setQuery(query)
let language = query.language || "en"
let trimDefault = asPath.replace("/en/blog", "/blog")
let res = await StoryblokService.get(`cdn/stories${trimDefault}`)
return {
res,
language,
}
}
componentDidMount() {
StoryblokService.initEditor(this)
}
render() {
const contentOfStory = this.state.story.content
return (
<Layout language={this.state.language}>
<BlogPost blok={contentOfStory} />
</Layout>
)
}
}
Now if you open the first post entry, it should look similar to the image below.

You can check the current progress in this GitHub commit added blog posts and routes.
Showing Featured Articles on the Homepage
At this point in our project, we are missing any listing showing the articles. To do this, we'll create a Featured Articles section on the homepage.
Preparing Storyblok's Structure
We will create a new blok (nested component) called featured-posts containing the field named title
of the type Text and the field named posts
of the type Multi-option. Set the Source of posts field to Stories
and in the field named Path to folder of stories write blog/
.
Schema of featured-posts:
- title (type: Text)
- posts (type: Multi-option) (source: Stories), (restrict to: Post)

Adding the Next.js template
Now that we have our content type, let's go ahead and add a component FeaturedPosts.js in Next.js.
components/FeaturedPosts.js
import React from "react";
import SbEditable from "storyblok-react";
const FeaturedPosts = ({ blok }) => {
return (
<SbEditable content={blok} key={blok._uid}>
<div className="py-8 mb-6 container mx-auto text-left" key={blok._uid}>
<div className="relative">
<h2 className="relative font-serif text-4xl z-10 text-primary">
{blok.title}
</h2>
<div className="absolute top-0 w-64 h-10 mt-6 -ml-4 bg-yellow-300 opacity-50" />
</div>
<ul className="flex">
{blok.posts.map((post) => {
const lang = post.lang === "default" ? "en" : post.lang;
return (
<li key={post.content._uid} className="pr-8 w-1/3">
<a
href={`/${lang}/blog/${post.slug}`}
className="py-16 block transition hover:opacity-50"
>
<img src={post.content.image} className="pb-10 w-full" />
<h2 className="pb-6 text-lg font-bold">
{post.content.title}
</h2>
<p className="pb-6 text-gray-700 leading-loose">
{post.content.intro}
</p>
</a>
</li>
);
})}
</ul>
</div>
</SbEditable>
);
};
export default FeaturedPosts;
We also have to add this component to our DynamicComponents.js
file.
import Teaser from './Teaser'
import Feature from './Feature'
import FeaturedPosts from './FeaturedPosts'
import Grid from './Grid'
import Placeholder from './Placeholder'
const Components = {
'teaser': Teaser,
'grid': Grid,
'feature': Feature,
'featured-posts': FeaturedPosts
}
const DynamicComponent = ({blok}) => {
if (typeof Components[blok.component] !== 'undefined') {
const Component = Components[blok.component]
return <Component blok={blok} />
}
return <Placeholder componentName={blok.component}/>
}
export default DynamicComponent
Now the last step is to load the associated blog posts in our Home entry. We will do this by adapting our request with the Storyblok Service in pages/index.js
. You will need to add a resolve_relations to the request.
import Page from '../components/Page'
import Layout from '../components/Layout'
import StoryblokService from '../utils/storyblok-service'
export default class extends React.Component {
constructor(props) {
super(props)
this.state = {
story: props.res.data.story
}
}
static async getInitialProps({ query }) {
StoryblokService.setQuery(query)
let res = await StoryblokService.get('cdn/stories/home',
{
"resolve_relations": "featured-posts.posts"
})
return {
res
}
}
componentDidMount() {
StoryblokService.initEditor(this)
}
render() {
const contentOfStory = this.state.story.content
return (
<Layout>
<Page content={contentOfStory} />
</Layout>
)
}
}
With the new content type and the component in Next.js set up, we will go back to the Home entry and create a new block of the type featured-posts
.


Then we will go ahead and add our first entry as a featured post to this blok. If all went correctly you should see the featured post on the bottom of the home page.

You can check the current progress in this Github commit added featured post to home page.
Creating a Post Overview Page
Let's quickly create an overview page of our articles before we implement another language. Create index.js
on the path ./pages/blog/index.js with the following source code. This will load all the stories inside the blog folder (every Story that starts with blog/
) and display a list of the blog entries.
./pages/blog/index.js
import Layout from "../../components/Layout";
import StoryblokService from "../../utils/storyblok-service";
export default class extends React.Component {
constructor(props) {
super(props);
this.state = {
stories: props.res.data.stories,
};
}
static async getInitialProps({ query }) {
StoryblokService.setQuery(query);
const res = await StoryblokService.get("cdn/stories", {
starts_with: "blog/",
});
return {
res,
};
}
componentDidMount() {
StoryblokService.initEditor(this);
}
render() {
const posts = this.state.stories;
return (
<Layout>
<main className="container mx-auto">
<h1 className="text-5xl font-bold font-serif text-primary tracking-wide pt-12">
All Posts
</h1>
<ul>
{posts.map((post) => (
<li className="max-w-4xl px-10 my-4 py-6 rounded-lg shadow-md bg-white">
<div className="flex justify-between items-center">
<span className="font-light text-gray-600">
{`
${new Date(post.created_at).getDay()}.
${new Date(post.created_at).getMonth()}.
${new Date(post.created_at).getFullYear()}`}
</span>
</div>
<div className="mt-2">
<a
className="text-2xl text-gray-700 font-bold hover:text-gray-600"
href={`/${post.full_slug}`}
>
{post.content.title}
</a>
<p className="mt-2 text-gray-600">{post.content.intro}</p>
</div>
<div className="flex justify-between items-center mt-4">
<a
className="text-blue-600 hover:underline"
href={`/${post.full_slug}`}
>
Read more
</a>
</div>
</li>
))}
</ul>
</main>
</Layout>
);
}
}
We need to also update our navigation to this path. Change the About entry in line 35 to the URL blog
and name it Blog.
./components/Navigation.js
<ul className="...">
<li>
<a href="/" className="...">Home</a>
</li>
<li>
<a href="/blog" className="...">Blog</a>
</li>
</ul>
Our navigation should work now and when you click on Blog, you should see a page similar to the one below.

You can check the current progress in this Github commit added post overview page.
Adding Another Language
We can implement internationalization with two different approaches in Storyblok. It strongly depends on your use case, which you should use. Read more about the Internationalization in docs. We are going to use Field Level Translation.
All you need to do on the Storyblok side is go to the Settings of your space and define a new language in the Languages tab. Let's add German as a language and set English as the default language.

If you open any story in the Visual Editor now, you will see the language dropdown in the header.

To do that open the field headline
of the Teaser in the Define Schema and check the box translatable.

If you choose German language right now, you won't see any changes because we didn't set any of the fields in our components as translatable.
Change the language to German and you will see a Translate checkbox next to the translatable field. If you set the checkbox to true you are able to translate the value and you will see a real-time preview in the Preview. If the checkbox stays false, the default value will be used.

Adding language support to Next.js
The next step is to add the language support to Next.js. We will need to switch the Navigation and the translated fields to German if we click the German Language. Let's start by creating a dyamic start page depending on the slug. In pages create a [language].js
file, which is very similar to the index.page. What we do here is reading the current language via the query.language
paramter in the getInitialProps
function and passing the current language (e.g. en
or de
) to the Layout.js
file. Add the same code also to the index.js
file.
pages/[language].js and pages/index.js
import Page from '../components/Page'
import Layout from '../components/Layout'
import StoryblokService from '../utils/storyblok-service'
export default class extends React.Component {
constructor(props) {
super(props)
this.state = {
story: props.res.data.story,
language: props.language,
}
}
static async getInitialProps({ query }) {
StoryblokService.setQuery(query)
let language = query.language || "en"
let insertLanguage = language !== "en" ? `/${language}` : ""
let res = await StoryblokService.get(`cdn/stories${insertLanguage}/home`,
{
"resolve_relations": "featured-posts.posts"
})
return {
res,
language
}
}
componentDidMount() {
StoryblokService.initEditor(this)
}
render() {
const contentOfStory = this.state.story.content
return (
<Layout language={this.state.language}>
<Page content={contentOfStory} />
</Layout>
)
}
}
Next we have to adapt the Layou.js and Navigation.js file to display the correct language. In Layout.js we need to receive and pass the language prop to the Navigation.
components/Layout.js
import Head from '../components/Head'
import Navigation from '../components/Navigation'
import Footer from '../components/Footer'
import StoryblokService from '../utils/storyblok-service'
const Layout = ({ children, language }) => (
<div className="bg-gray-300">
<Head />
<Navigation language={language} />
{children}
<Footer />
{StoryblokService.bridge()}
</div>
)
export default Layout
And then in the Navigation we need to set some classes depending if the language is active.
components/Navigation.js
const Navigation = ({ language }) => (
<header className="w-full bg-white">
<nav className="" role="navigation">
<div>
...
</div>
<div>
<ul>
...
</ul>
<ul className="flex flex-col mt-4 -mx-4 pt-4 border-t md:flex-row md:items-center md:mx-0 md:mt-0 md:pt-0 md:border-0">
<li>
<a href="/" className={`block px-4 py-1 md:p-2 rounded-lg lg:px-4
${language === "en" ? "bg-black text-white" : ""}`}>EN</a>
</li>
<li>
<a href="/de" className={`block px-4 py-1 md:p-2 rounded-lg lg:px-4
${language === "de" ? "bg-black text-white" : ""}`}>DE</a>
</li>
</ul>
</div>
</div>
</nav>
</header>
)
export default Navigation
Now you should be able to switch between the two language and see a different headline when clicking the language buttons.

Translating the Blog Post
We can also translate our post. Let's open up our first entry and see what happens when we switch the language to German.

Since Storyblok automatically prepends the language to our slug (1), we need to change our blog path in Next.js. Inside pages, create a new folder language
and move the blog
folder inside, so the path for our blog file is: pages/[language]/blog/[slug].js
.
Then we need to update the reading of the current path on every file that is importing Layout. First edit the blog entry:
pages/[language]/blog/[slug].js
import Layout from "../../../components/Layout";
import StoryblokService from "../../../utils/storyblok-service";
export default class extends React.Component {
constructor(props) {
super(props);
this.state = {
stories: props.res.data.stories,
language: props.language,
};
}
static async getInitialProps({ query }) {
StoryblokService.setQuery(query);
let language = query.language || "en";
let insertLanguage = language !== "en" ? `${language}/` : "";
const res = await StoryblokService.get(`cdn/stories`, {
starts_with: `${insertLanguage}blog/`,
});
return {
res,
language,
};
}
componentDidMount() {
StoryblokService.initEditor(this);
}
render() {
const posts = this.state.stories;
return (
<Layout language={this.state.language}>
<main className="container mx-auto">
<h1 className="text-5xl font-bold font-serif text-primary tracking-wide pt-12">
All Posts
</h1>
<ul>
{posts.map((post) => {
const lang = post.lang === "default" ? "/en" : `/${post.lang}`;
return (
<li
key={post._uid}
className="max-w-4xl px-10 my-4 py-6 rounded-lg shadow-md bg-white"
>
<div className="flex justify-between items-center">
<span className="font-light text-gray-600">
{`
${new Date(post.created_at).getDay()}.
${new Date(post.created_at).getMonth()}.
${new Date(post.created_at).getFullYear()}`}
</span>
</div>
<div className="mt-2">
<a
className="text-2xl text-gray-700 font-bold hover:text-gray-600"
href={`${lang}/blog/${post.slug}`}
>
{post.content.title}
</a>
<p className="mt-2 text-gray-600">{post.content.intro}</p>
</div>
<div className="flex justify-between items-center mt-4">
<a
className="text-blue-600 hover:underline"
href={`${lang}/blog/${post.slug}`}
>
Read more
</a>
</div>
</li>
);
})}
</ul>
</main>
</Layout>
);
}
}
And then we also need to update the overview page for multilanguage support
pages/[language]/blog/index.js
import Layout from "../../../components/Layout";
import StoryblokService from "../../../utils/storyblok-service";
export default class extends React.Component {
constructor(props) {
super(props);
this.state = {
stories: props.res.data.stories,
language: props.language,
};
}
static async getInitialProps({ query }) {
StoryblokService.setQuery(query);
let language = query.language || "en";
let insertLanguage = language !== "en" ? `${language}/` : "";
const res = await StoryblokService.get(`cdn/stories`, {
starts_with: `${insertLanguage}blog/`,
});
return {
res,
language,
};
}
componentDidMount() {
StoryblokService.initEditor(this);
}
render() {
const posts = this.state.stories;
return (
<Layout language={this.state.language}>
<main className="container mx-auto">
<h1 className="text-5xl font-bold font-serif text-primary tracking-wide pt-12">
All Posts
</h1>
<ul>
{posts.map((post) => {
const lang = post.lang === "default" ? "en" : post.lang;
return (
<li
key={post._uid}
className="max-w-4xl px-10 my-4 py-6 rounded-lg shadow-md bg-white"
>
<div className="flex justify-between items-center">
<span className="font-light text-gray-600">
{`
${new Date(post.created_at).getDay()}.
${new Date(post.created_at).getMonth()}.
${new Date(post.created_at).getFullYear()}`}
</span>
</div>
<div className="mt-2">
<a
className="text-2xl text-gray-700 font-bold hover:text-gray-600"
href={`/${lang}/blog/${post.slug}`}
>
{post.content.title}
</a>
<p className="mt-2 text-gray-600">{post.content.intro}</p>
</div>
<div className="flex justify-between items-center mt-4">
<a
className="text-blue-600 hover:underline"
href={`/${lang}/blog/${post.slug}`}
>
Read more
</a>
</div>
</li>
);
})}
</ul>
</main>
</Layout>
);
}
}
Finally we need to update the Blog Link in our Navigation.js component depending on which language is active:
components/Navigation.js
<li>
<a href={`/${language}/blog`} className="block px-4 py-1 md:p-2 lg:px-8">Blog</a>
</li>
Then we also need to update the paths to match our language in our components/FeaturePosts.js
:
components/FeaturesPosts.js
import React from "react";
import SbEditable from "storyblok-react";
const FeaturedPosts = ({ blok }) => {
return (
<SbEditable content={blok} key={blok._uid}>
<div className="py-8 mb-6 container mx-auto text-left" key={blok._uid}>
<div className="relative">
<h2 className="relative font-serif text-4xl z-10 text-primary">
{blok.title}
</h2>
<div className="absolute top-0 w-64 h-10 mt-6 -ml-4 bg-yellow-300 opacity-50" />
</div>
<ul className="flex">
{blok.posts.map((post) => {
const lang = post.lang === "default" ? "/en" : `/${post.lang}`;
return (
<li key={post.content._uid} className="pr-8 w-1/3">
<a
href={`${lang}/blog/${post.slug}`}
className="py-16 block transition hover:opacity-50"
>
<img src={post.content.image} className="pb-10 w-full" />
<h2 className="pb-6 text-lg font-bold">
{post.content.title}
</h2>
<p className="pb-6 text-gray-700 leading-loose">
{post.content.intro}
</p>
</a>
</li>
);
})}
</ul>
</div>
</SbEditable>
);
};
export default FeaturedPosts;
Now when we click on DE and then Blog, we should see the translated blog post.


You can check the current progress in this Github commit added multilanguage support.
Deploying to Vercel
You have multiple options for the deployment of your website/application to go live or to preview the environment. One of the easiest ways is to use Vercel and deploy using the command line or their outstanding GitHub Integration.
First, create an account on Vercel and install their CLI application.
npm i -g vercel
Deploy your website by running the vercel in your console.
vercel
Take a look at the deployed Demo project: nextjs-storyblok-multilanguage-website.vercel.app/
Resource | Link |
---|---|
Github repository of this Tutorial | github.com/storyblok/nextjs-multilanguage-website |
The project live | nextjs-storyblok-multilanguage-website.vercel.app |
Vercel | Vercel |
Next.js | Next.js |
React.js | React.js |
Storyblok App | Storyblok |
Next.JS Setup | Next.js Set-up |
Tailwindcss with Next.js | How to install tailwindcss with Next |
React.js: Dynamic Component Rendering | React.js: Dynamic Component Rendering |