International and multilingual sites with Storyblok and Next.js. Part 2
This is the second part of “Storyblok localization with NextJS” series

This is the second part of “Storyblok localization with NextJS” series. If you missed the first part you can find it here.
This part will cover all technical details, including how to set up the latest Next.js project and connect it to Storyblok. You will learn how to add necessary SEO tags, and how to make sure users land on correct pages with preferable language.
Project initialization
You can find an official Storyblok guide on how to set up a Next.js project and learn more about all the required steps in documentation.
While this guide provides a great overview of general steps to set up space for dev work, it lacks up-to-date examples. Particularly the latest Next.js version. So we created a basic example that includes latest Next.js and Storyblok packages.
We encourage developers to use the latest versions of the packages to leverage new features and minimize technical debt in the future.
After completing these steps, we will be able to see the pages we created earlier.
Local Next.js project preview inside Storyblok
Due to the fact that we create pages using the Dimensions application, our pages are already automatically linked, this can be seen in the page settings.
Page alternates already exist
!Attention If you do not want to use the Dimensions app, you must create the connection manually. This is a very important and mandatory part of the process.
Alternative pages called alternates, and they come in corresponding field. All alternates available in page story object.
Example of alternate link:
<link rel="alternate" href="https://example.com/en-gb" hreflang="en-gb" />
Follow Google documentation if you want to have a complete understanding of how alternates mechanism works.
What we need to do now is link each page to its alternative language version, and each page should link to itself. The last step is necessary to protect your pages from being linked to third party resources without approval. Also this will make sure bots understand which page served in which region.
Let's take page data and create SEO tags for every alternative page related to currently visited page.
// src/components/Seo.tsx
export default function Seo({ alternates }) {
return (
<>
<link
rel="alternate"
hrefLang="x-default"
href={process.env.NEXT_PUBLIC_BASE_URL}
/>
{alternates.map(({ id, full_slug }) => {
const lang = full_slug.split("/")[0];
return (
<link
key={id}
rel="alternate"
hrefLang={lang}
href={`${process.env.NEXT_PUBLIC_BASE_URL}/${full_slug}`}
/>
);
})}
</>
);
}
Now we need to add this component to the page.
// src/app/[[...slug]]/page.tsx
import { StoryblokServerComponent } from "@storyblok/react/rsc";
import { fetchStory } from "@/lib/storyblok";
import Seo from "@/components/Seo";
export default async function Page(props) {
const { slug } = await props.params;
const { data } = await fetchStory(slug);
return (
<div>
<Seo alternates={data.story.alternates} />
<StoryblokServerComponent blok={data.story.content} />
</div>
);
}
Open page in a new tab and you should be able to see all SEO tags in head tag in the HTML. Including default value for the page we are currently on.
After adding Seo component to the page, we need to check that all the links are there. As a result, you should get a set with all copies of the page, including the one we are currently on.
Alternate SEO tags are in HTML
Upon completion of this step, search bots will perfectly understand the website structure and current localization strategy. Bots will know what pages serve to which person.
This is required part if you need to handle localization. Other meta tags that help bots better understand the page check here you can find here. But they are not the subject now.
Home page
We did everything we needed to separate the content and teach bots to understand our resources. The only thing remaining is to make sure that a person visiting the home page of the site, for instance https://example.com was directed to the version he needed.
If a person searches for our site in google search, he probably won't even get to it, because there will be correct localization versions for him. But if a person goes to the page directly, we want to show a suitable page, based on person's preferences.
There are several solutions. One of them is just to set up a redirect to the default country and language. The other is to use logic that, based on the geo position of the user and the languages preference, will show corresponding page.
In this example, we will consider using Next.js middleware, but you can use any other tool that will allow you to execute JS before serving page to the end user. It can be Cloudflare worker or AWS Lambda/Edge functions.
Next.js middleware
To start using it, you need Next.js versions higher than 12.1. You can find detailed documentation following the documentation, but we will consider only the part that we need for implementation.
Let’s start with creating a new file called middleware.ts, and put code example from documentation in it.
// src/middleware.ts
import { NextResponse, type NextRequest } from 'next/server'
export function middleware(request: NextRequest) {
return NextResponse.redirect(new URL('/home', request.url))
}
export const config = {
matcher: '/about/:path*',
}
Now let’s make sure we do all the steps, to solve the problem:
We will conduct the following steps:
- Make sure middleware runs only on necessary routes
- Define the user’s country
- Determine preferable languages of the user
- Define the most suitable homepage for the user
- Direct the user to the correct page version
Filtering routes
We need to make sure that we are calling our middleware only for the home page. We don't want other requests to go through this logic, because they simply don't need it.
Let's update the middleware code to reflect that.
// src/middleware.ts
import { NextResponse, type NextRequest } from "next/server";
export async function middleware(request: NextRequest) {
return NextResponse.next();
}
export const config = {
matcher: "/",
};
After this action, our middleware will only work with the root query, which is exactly what we wanted.
User's country
To determine the user's country, we will use the information from the request object of our middleware. Learn more about middleware in Next.js documentation.
To determine user's country, we will use the information from the middleware request object. In the latest Next.js version to do that you need to install package called @vercel/functions. It includes helpers to extract specific data from the request. Lets also add a fallback value for country code, for example DE (Germany).
In previous Next.js version, this data was accessible directly from the request object.
Here is how middleware should look like after all the changes.
// src/middleware.ts
import { NextResponse, type NextRequest } from "next/server";
import { geolocation } from "@vercel/functions";
const DEFAULT_COUNTRY_CODE = "DE";
export async function middleware(request: NextRequest) {
const { country } = geolocation(request);
const countryCode = country || DEFAULT_COUNTRY_CODE;
return NextResponse.next();
}
export const config = {
matcher: "/",
};
User’s languages
To determine the appropriate languages, the Accept-Language header is best suited. In this header you will be able to find information about languages user prefers. Learn more in documentation.
Accept-Language header example:
Accept-Language: fr-CH, fr;q=0.9, en;q=0.8, de;q=0.7, *;q=0.5
Now we have all the necessary information. The last thing we need to do is to compare this information with the content we have in the CMS.
Determining the correct home page version
To begin with, we need to determine default home page. Since DE is the default country code, the default page is going to have /de/home URL.
To fetch page in middleware we can reuse the same function that we use to fetch page for the dynamic page.
Here is fetchStory function implementation:
// src/lib/storyblok.ts
export async function fetchStory(slug?: string[]) {
getStoryblokApi();
const correctSlug = `/${slug ? slug.join("/") : "home"}`;
const searchParams = new URLSearchParams({
version: "published",
token: process.env.NEXT_PUBLIC_STORYBLOK_TOKEN || "",
});
try {
const response = await fetch(
`https://api.storyblok.com/v2/cdn/stories${correctSlug}?${searchParams.toString()}`,
{
method: "GET",
cache: "default",
}
);
return {
data: await response.json(),
};
} catch (error) {
console.error("Error in fetchStory:", error);
}
}
Now let’s fetch home page in the middleware.
// src/middleware.ts
import { NextResponse, type NextRequest } from "next/server";
import { geolocation } from "@vercel/functions";
import { fetchStory } from "@/lib/storyblok";
const DEFAULT_COUNTRY_CODE = "DE";
const DEFAULT_HOME_PAGE_SLUG = ["de", "home"];
export async function middleware(request: NextRequest) {
const { country } = geolocation(request);
const countryCode = country || DEFAULT_COUNTRY_CODE;
const { data } = await fetchStory(DEFAULT_HOME_PAGE_SLUG);
return NextResponse.next();
}
export const config = {
matcher: "/",
};
!Attention
Make sure that the page you are trying to get is published. You can also fetch draft version, but you need to be careful and clearly understand what you are doing.
When we get the default homepage, we will be able to see that it already includes the alternates field, which we used to create SEO tags in the beginning.
Based on the information from this section, we can understand what we need to give to the user.
We will make an utility function that will select the most appropriate version based on the data obtained above. The goal is to compare user's country data, language, and our content. We will make only a basic version of this function. But you can update it any time, making it more unique and optimized.
The basic version of getRelevantHomeVersion function can look like this:
// src/lib/getRelevantHomeVersion.ts
export function getRelevantHomeVersion({
homePageStory,
userCountryCode,
userLanguageHeader = "",
}) {
// format languages
const userLanguages = userLanguageHeader
.split(",")
.map((language) => language.split(";")[0].toLowerCase());
const { alternates } = homePageStory;
// add default version
alternates.push({
id: homePageStory.id,
name: homePageStory.name,
slug: homePageStory.slug,
full_slug: homePageStory.full_slug,
});
// filter pages based on user's country
const pagesForUserCountry = alternates.filter((page) => {
const countryLanguageSegment = page.full_slug.split("/")[0];
const countryCode =
countryLanguageSegment.split("-")?.[1] || countryLanguageSegment;
return countryCode === userCountryCode.toLowerCase();
});
// find page based on user's language
let resultPage;
for (const language of userLanguages) {
if (!resultPage) {
resultPage = pagesForUserCountry.find((page) => {
const countryLanguageSegment = page.full_slug.split("/")[0];
return countryLanguageSegment.startsWith(language);
});
}
}
return resultPage || homePageStory;
}
When function is created, we just need to call it and return the most suitable version to the user.
So the final middleware should look like the following:
// src/middleware.ts
import { NextResponse, type NextRequest } from "next/server";
import { geolocation } from "@vercel/functions";
import { fetchStory } from "@/lib/storyblok";
import { getRelevantHomeVersion } from "@/lib/getRelevantHomeVersion";
const DEFAULT_COUNTRY_CODE = "DE";
const DEFAULT_HOME_PAGE_SLUG = ["de", "home"];
export async function middleware(request: NextRequest) {
const { country } = geolocation(request);
const countryCode = country || DEFAULT_COUNTRY_CODE;
const { data } = await fetchStory(DEFAULT_HOME_PAGE_SLUG);
const relevantHomeVersion = getRelevantHomeVersion({
userCountryCode: countryCode,
homePageStory: data.story,
userLanguageHeader: request.headers.get("Accept-Language") || "",
});
return NextResponse.redirect(
new URL(relevantHomeVersion.full_slug, request.url)
);
}
export const config = {
matcher: "/",
};
Given that we have no information about the country during local development, the default value will be taken.
In my case, the language header has only 2 values: en-US, en. Therefore, under these conditions, when opening the home page, we will get a DE market page, as well as the EN language, which fully meets expectations.
User is redirected to preferable page
The next optimization step would be to set a cookie with the user's preferred page version. This will persist calculated value for the user and will help to serve pages faster next time. We will not consider this in this article. Here is a documentation where you can learn how to do this.
Conclusion
Final project we created includes the following:
- Integrated easy-to-use and intuitive Storyblok CMS
- Ability to manage multiple countries using single 1 account
- Clear URL structure that will help prevent mistakes while editing content
- Simple and minimalistic frontend project with separated logical parts
- You have static pages that work work with Storyblok API
- Dynamic middleware running from edge server, which does not have a strong impact on speed, to deliver content correctly
Final project is fully optimized
Ready to explore your Headless opportunities? Contact our experienced team to shift your business to the tech of the future.