You're two sprints from launch when Product pings Slack: "Can we ship French and German?" Suddenly you're staring at hundreds of hard-coded strings, duplicated pages, and a REST layer that assumes en everywhere. Rebuilding content models, rewriting API calls, and patching slugs feel like a full rewrite—and the deadline hasn't moved.
Multilingual support becomes trivial when you architect for it upfront. By pairing Strapi's battle-tested i18n plugin with Next.js internationalization routing, you model, store, and deliver every language through the same endpoints you already use.
In brief:
- Strapi's i18n plugin transforms regular content types into language-aware stores without requiring database restructuring.
- Next.js internationalized routing automatically handles URL patterns like
/fr/aboutwhile preserving your existing page structure. - API queries use a simple
?locale=frparameter to fetch translated content through the same endpoints. - Production-ready performance comes from locale-aware caching strategies that keep your multilingual site as fast as a single-language version.
Prerequisites and Setup for a Multilanguage Strapi Website
Getting your multilingual architecture running requires specific versions of Node.js, package managers, and frameworks that work harmoniously together. The setup process involves creating both a Strapi backend with i18n capabilities and a Next.js frontend configured for internationalized routing, so ensuring compatibility upfront prevents migration headaches later.
Environment Requirements
Before diving into configuration, confirm your development environment meets these requirements. You need Node.js 18 LTS (node -v) since Strapi relies on the current LTS for native module compatibility. Use NPM 9 or Yarn 1.22 (npm -v, yarn -v) for package management.
Your project requires Strapi v4 or later for full i18n plugin support and Next.js v12 or later for built-in internationalized routing. PostgreSQL 14 works best for production environments, though SQLite handles local development.
Upgrade any outdated versions now to avoid compatibility issues during development.
Initial Project Setup
Create both servers with these commands:
1# 1. Create the Strapi backend
2npx create-strapi@latest my-cms --quickstart
3
4# 2. Create the Next.js frontend
5npx create-next-app@latest my-siteStrapi CLI prints ✔ Your application was created at ./my-cms and opens on http://localhost:1337. Next.js starts on http://localhost:3000 with the default welcome page. Your workspace structure:
1my-project/
2├─ my-cms/ # Strapi backend
3└─ my-site/ # Next.js frontendAdd multilingual support to your Strapi instance:
1cd my-cms
2yarn strapi install i18n # enables locale managementBoth servers can now fetch and render localized data without requiring structural changes later.
Environment Configuration
Store sensitive values in environment files:
1# my-cms/.env
2ADMIN_JWT_SECRET=<generated>1# my-site/.env.local
2STRAPI_URL=http://localhost:1337
3STRAPI_API_TOKEN=Bearer <copy-from-admin>
4NEXT_PUBLIC_SITE_URL=http://localhost:3000Next.js exposes only variables prefixed with NEXT_PUBLIC_ to the browser, keeping your Strapi token server-side. Create an API token in the Strapi Admin Panel and add it to your environment file. Commit a .env.example file to guide collaborators without exposing actual secrets.
Configuring Strapi for Internationalization
Before you fetch your first localized record from Next.js, Strapi needs to understand what "French" or "German" means. The i18n plugin transforms regular collection types into language-aware content stores through the Admin Panel.
Enabling i18n in Content-Type Builder
Open Content-Type Builder → create a new Collection Type or edit an existing one → Advanced Settings → flip the "Enable localization for this Content-Type" switch. Enable it during creation; adding it later triggers a migration that forces every existing entry to pick a default locale.
The toggle applies to the entire schema—individual fields can't opt out. Reserve it for content that truly needs translation: pages, articles, product descriptions. Once enabled, you can't roll back without losing localized data. Treat the switch like a schema migration, not a UI preference.
Locale Configuration in Settings
Navigate to Settings → Internationalization → Locales to add languages. Strapi supports over 500 ISO codes, from en to ar-EG. Click "Add new locale", choose en, es, fr, or de, and pick one as default.
The default locale handles requests that omit ?locale=xx and powers the "Fill in from another locale" helper in the editor. Set the first locale via environment variable in production:
1STRAPI_PLUGIN_I18N_INIT_LOCALE_CODE=enChanging the default later doesn't rewrite existing content but changes which language surfaces when editors create new entries. The locale list is global—every localized collection shares the same pool.
Content Management Features
In localized collection types, the language dropdown appears next to the entry title. You edit one locale at a time, but the sidebar shows which translations exist, making missing languages obvious.
Need a head start? Click "Fill in from another locale", pick the source language, and Strapi clones every translatable field—perfect for placeholders while translators work. Components and Dynamic Zones honor the locale too, letting you store a French hero banner with different copy while keeping the structure identical.
Each translation is a distinct record linked to its siblings. /api/pages?locale=fr returns only French data while /api/pages falls back to the default locale. This linkage keeps queries fast and migration scripts predictable as your language list grows.
Strapi API Configuration for Multilanguage
Once the i18n plugin is active and your content is translated, you need to expose that data through Strapi's REST API. Everything works out of the box with the right query parameters, permissions, and a few performance tweaks.
REST API Locale Implementation
Every localized request revolves around the locale parameter:
1curl -X GET "http://localhost:1337/api/pages?locale=fr"Strapi returns the French record or falls back to the default locale when the translation is missing:
1{
2 "data": {
3 "id": 7,
4 "documentId": "gd0nwvx6lzh663ekw4415uc4",
5 "title": "À propos",
6 "locale": "fr",
7 "localizations": {
8 "data": [
9 { "id": 1, "attributes": { "locale": "en" } }
10 ]
11 }
12 }
13 }
14}Omit the parameter and you'll get the default locale. Strapi 4 removed the locale=all shortcut—request each language separately or loop through your locale list on the frontend.
To preview content in Postman, duplicate the request and swap fr for en, de, or any configured locale. This makes smoke-testing translations straightforward.
Permission Configuration
By default, the Public role can't access localized data. Navigate to Settings → Users & Permissions → Roles → Public → Permissions → Select your collection → find, findOne. Save, then repeat for any custom roles.
For private APIs, generate a short-lived JWT:
1// /scripts/getToken.js
2const res = await fetch('http://localhost:1337/api/auth/local', {
3 method: 'POST',
4 headers: { 'Content-Type': 'application/json' },
5 body: JSON.stringify({ identifier: email, password })
6});
7const { jwt } = await res.json();Attach the token in an Authorization: Bearer <jwt> header and Strapi honors the same locale rules server-side. Keep the Public role locked down in production; expose only the locales and endpoints your frontend needs.
Advanced Query Parameters
Locale combines with any standard Strapi query strings:
1# Pull the French page with all relations
2curl "http://localhost:1337/api/pages?locale=fr&[populate=*](https://docs.strapi.io/cms/api/rest/populate-select)"1# Narrow to title and hero image only
2curl "http://localhost:1337/api/pages?locale=fr&populate[hero][populate]=*&fields[0]=title"Using populate=* is convenient but expensive—response sizes grow quickly with more locales. For complex filters, pipe everything through the qs library:
1import qs from 'qs';
2
3const query = qs.stringify(
4 {
5 locale: 'de',
6 filters: {
7 seo: {
8 slug: { $eq: 'contact' }
9 }
10 },
11 populate: ['hero', 'seo.metaImage']
12 },
13 { encodeValuesOnly: true }
14);
15const url = `${process.env.STRAPI_URL}/api/pages?${query}`;Filter by locale-specific fields (like title_containsi=Bienvenue) or sort content chronologically with sort[0]=publishedAt:desc. Keep queries lean and cacheable, and your multilingual API stays as fast as a single-language setup.
Building the Next.js Frontend
You already have Strapi serving localized content. Now it's time to pull those translations into a fast, SEO-friendly React frontend that automatically handles routing and content display for each language.
Next.js Project Configuration
Configure Next.js for your supported languages in next.config.js:
1// next.config.js
2module.exports = {
3 i18n: {
4 locales: ['en', 'fr', 'es'],
5 defaultLocale: 'en',
6 },
7};This enables locale-aware routing like /fr/about automatically.
Type safety keeps Strapi responses predictable:
1// types/strapi.d.ts
2export interface StrapiImage {
3 url: string;
4 alternativeText: string | null;
5}
6
7export interface PageAttributes {
8 title: string;
9 content: string;
10 locale: string;
11 cover: {
12 data: StrapiImage
13 };
14}
15
16export interface StrapiResponse<T> {
17 data: {
18 id: number;
19 data: T
20 }[];
21}Centralize API calls with an axios instance that automatically carries locale and auth tokens:
1// lib/strapi.ts
2import axios from 'axios';
3
4export const strapi = axios.create({
5 baseURL: process.env.STRAPI_URL || 'http://localhost:1337/api',
6 headers: {
7 Authorization: INLINECODE_1,
8 },
9});
10
11export function fetchPage(slug: string, locale: string) {
12 return strapi.get('/pages', {
13 params: {
14 'filters[slug][$eq]': slug,
15 locale,
16 populate: '*',
17 },
18 });
19}Whitelist Strapi's domain for images in next.config.js:
1images: {
2 domains: ['localhost'],
3},When a translation is missing, implement fallback logic: if Strapi returns no data for the requested locale, re-query using the default locale.
Implementing i18n in Next.js
Data fetching works identically in both App Router and Pages Router—only hook names differ.
1// app/[locale]/[slug]/page.tsx (App Router)
2import { notFound } from 'next/navigation';
3import { fetchPage } from '@/lib/strapi';
4
5export async function generateStaticParams() {
6 return ['en', 'fr', 'es'].flatMap((locale) => [{ locale }]);
7}
8
9export default async function Page({ params: { locale, slug }, }: { params: { locale: string; slug: string }; }) {
10 const { data } = await fetchPage(slug, locale);
11 if (!data.length) notFound();
12 const page = data[0];
13 return <h1>{page.title}</h1>;
14}Pages Router follows the same pattern:
1// pages/[locale]/[slug].tsx
2export async function getStaticPaths() {
3 const locales = ['en', 'fr', 'es'];
4 return {
5 paths: locales.map((l) => ({ params: { locale: l, slug: 'home' } })),
6 fallback: 'blocking',
7 };
8}
9
10export async function getStaticProps({ params }) {
11 const res = await fetchPage(params.slug, params.locale);
12 if (!res.data.length) {
13 return {
14 redirect: {
15 destination: INLINECODE_2,
16 permanent: false
17 }
18 };
19 }
20 return { props: { page: res.data[0] } };
21}Locale context is available through params.locale in App Router or router.locale in Pages Router.
Implement error-tolerant routing for missing translations: if a page doesn't exist in the requested language, redirect to the same slug in the default locale. This maintains content availability and prevents search engine dead links.
Building UI Components
Build a language switcher using locale-aware links:
1// components/LanguageSwitcher.tsx
2'use client';
3import Link from 'next/link';
4import { usePathname } from 'next/navigation';
5
6export default function LanguageSwitcher({ locales }: { locales: string[] }) {
7 const pathname = usePathname(); // e.g. /fr/about
8 const parts = pathname.split('/');
9 const currentLocale = parts[1];
10 return (
11 <nav aria-label="Language selector">
12 {locales.map((l) => {
13 if (l === currentLocale) return null;
14 return (
15 <Link key={l} href={'/' + [l, ...parts.slice(2)].join('/')}>
16 {l.toUpperCase()}
17 </Link>
18 );
19 })}
20 </nav>
21 );
22}Handle Strapi images with absolute URLs:
1import Image from 'next/image';
2
3<Image
4 src={page.cover.data.url}
5 alt={page.cover.data.alternativeText ?? ''}
6 width={800}
7 height={400}
8/>Format dates with locale awareness using the browser's Intl API:
1const formatted = new Intl.DateTimeFormat(locale, {
2 day: 'numeric',
3 month: 'long',
4 year: 'numeric',
5}).format(new Date(page.publishedAt));Handle missing translations gracefully:
1{page.content ? (
2 <Markdown>{page.content}</Markdown>
3) : (
4 <p role="note">Translation coming soon.</p>
5)}Currency, number, and plural formats follow the same pattern. Centralize these utilities in a shared file to avoid repetition.
Test components with alternate text lengths—French titles can be 30% longer, Arabic is RTL. Automated visual regression tools catch layout breaks early.
Advanced Features
You already have localized content flowing from Strapi to Next.js; the next step is polishing the editor → user loop. Three additions—preview mode, locale-aware dynamic zones, and production-ready middleware—close the gap between "working" and "editor-friendly."
Preview Mode Configuration
Editors expect to see unpublished translations in context. Create an API route that validates a secret, forwards the current locale, and enters draft mode:
1// src/pages/api/preview.js
2export default async function handler(req, res) {
3 const { secret, slug = '/', locale = 'en' } = req.query;
4
5 if (secret !== process.env.PREVIEW_SECRET) {
6 return res.status(401).json({ message: 'Invalid token' });
7 }
8
9 res.setPreviewData({ locale }, { maxAge: 60 * 30 }); // 30-minute window
10 res.writeHead(307, { Location: `/${locale}${slug}` });
11 res.end();
12}Point a Strapi webhook to /api/preview so editors can trigger instant previews from the Admin Panel. When fetching data, add publicationState=preview and pass the locale:
1await fetch(`${process.env.STRAPI_URL}/api/pages?slug=${slug}&locale=${locale}&publicationState=preview`);Draft mode banners in the UI help editors verify they're in preview. The locale travels in the cookie, so switching languages inside preview mode works across all configured locales.
Dynamic Content Structures
Strapi Dynamic Zones let editors rearrange blocks such as "Hero," "Quote," or "Gallery." Localize each component by enabling localization at the component level; Strapi stores every translation as a sibling record, keeping layouts identical across languages.
On the frontend, map Dynamic Zone types to React components:
1const blocks = {
2 'shared.hero': HeroBlock,
3 'marketing.quote': QuoteBlock,
4};
5
6return zone.map((item) => {
7 const Component = blocks[item.__component];
8 return <Component key={item.id} data={item} locale={router.locale} />;
9});Use locale-specific media—Strapi's i18n system allows you to associate different media assets with each locale in your content—to avoid showing an English screenshot on a French page. This maintains design parity while letting editors adjust copy, imagery, or entire sections per language.
Custom Middleware Implementation
Automatic language detection eliminates the awkward "/en" default redirect. In middleware.js, read the Accept-Language header, fall back to 'en' if no supported language is matched, and preserve authenticated sessions:
1import { NextResponse } from 'next/server';
2import Negotiator from 'negotiator';
3
4export function middleware(req) {
5 const negotiator = new Negotiator({ headers: req.headers });
6 const supported = ['en', 'fr', 'es'];
7 const detected = negotiator.language(supported) || 'en';
8
9 // Skip detection on static assets and API routes
10 if (req.nextUrl.pathname.startsWith('/_next') || req.nextUrl.pathname.startsWith('/api')) {
11 return;
12 }
13
14 const localeInPath = supported.find((l) => req.nextUrl.pathname.startsWith(`/${l}`));
15 if (!localeInPath) {
16 const url = req.nextUrl.clone();
17 url.pathname = INLINECODE_6;
18 return NextResponse.redirect(url);
19 }
20}Route-specific configuration keeps the middleware lightweight, and storing the chosen language in a NEXT_LOCALE cookie maintains consistency between visits.
Performance Optimization
Multilingual sites can crawl if every page rebuilds and every request drags extra data. Here's how to keep builds lean, responses small, and assets cached globally.
Static Generation Strategies
Generating every language path upfront kills build times. Generate only what you need and let Incremental Static Regeneration handle the rest:
1// pages/[locale]/[slug].tsx
2export async function getStaticPaths() {
3 const locales = ['en', 'fr', 'es'];
4 const slugs = await fetchAllSlugs(); // one Strapi hit
5 return {
6 paths: locales.flatMap((locale) =>
7 slugs.map((slug) => ({ params: { slug }, locale }))
8 ),
9 fallback: 'blocking', // ISR handles misses
10 };
11}
12
13export async function getStaticProps({ params, locale }) {
14 const page = await fetchPage(locale, params.slug);
15 return {
16 props: { page },
17 revalidate: 60
18 }; // 60-second ISR window
19}This keeps initial builds small and pushes long-tail pages to on-demand regeneration.
API Query Optimization
Strapi's populate=* returns everything—every field, relation, and asset. Trim the payload:
1curl "$STRAPI_URL/api/pages?locale=fr&fields[0]=title&fields[1]=slug&populate[cover][fields][0]=url"Explicit field lists cut JSON size by more than half in most projects. For complex filters, use the qs library to batch multiple locale requests in a single round-trip. If your schema grows unwieldy, Strapi's GraphQL plugin lets you request exactly the data shape you need.
Caching Strategies
Once pages and API responses are trimmed, cache aggressively with locale-aware keys at the CDN edge:
1// vercel.json excerpt
2{
3 "headers": [
4 {
5 "source": "/:locale(.*)",
6 "headers": [
7 { "key": "Cache-Control", "value": "s-maxage=31536000, stale-while-revalidate" }
8 ]
9 }
10 ]
11}Browser assets—fonts, SVGs, and localized JSON bundles—inherit the same year-long s-maxage. Server-side, a lightweight Redis layer stores Strapi responses keyed by locale|slug, so repeated SSR hits never touch the CMS. When editors publish updates, a Strapi webhook flushes only the affected locale keys.
With static generation, precise queries, and layered caching working together, your global stack delivers sub-second responses regardless of language count.
Testing and Deployment
A solid multilingual build breaks the moment one locale goes out of sync. You need predictable tests, reproducible builds, and a deployment target that respects language nuances from the first GET request to the last cache purge.
Testing Implementation
Start by unit-testing your Strapi REST endpoints with locale parameters:
1// tests/pages.fr.test.js
2import request from 'supertest';
3import app from '../src/server';
4
5test('returns French page data', async () => {
6 const res = await request(app).get('/api/pages?locale=fr');
7 expect(res.status).toBe(200);
8 expect(res.body.data.locale).toBe('fr');
9});For frontend integration, Cypress clicks the language switcher and asserts that /fr/about renders French copy. When translations are missing, mock the API to return null and expect your fallback string "—translation pending—".
To simulate user environments, pass Accept-Language headers in your test runner and spin up browsers with different language packs.
Production Build Process
A green test suite triggers the pipeline:
1npm run strapi:build # compiles admin UI
2next build # bundles all locales
3next lint && next test # final safety netEnvironment variables—STRAPI_URL, STRAPI_API_TOKEN, NEXT_PUBLIC_SITE_URL—are injected by the CI runner. Verify that /.next/server/app/fr/ and every other locale directory contain pre-rendered HTML.
For long-running Strapi instances, use pm2:
1// ecosystem.config.cjs
2module.exports = {
3 apps: [{ name: 'strapi', script: 'npm', args: 'start', env: { NODE_ENV: 'production' } }],
4};Failed builds roll back automatically; the previous artifact stays active until the fix lands.
Deployment Options
Strapi Cloud pushes your API and database in one step with auto-configured HTTPS and global CDN. Editors publish French at 3 AM and readers in Québec get updates immediately.
For your own infrastructure, use a production-ready Docker image based on Strapi's official recommendations. Employ Node.js v20 or v22, a multi-stage build process, install required dependencies like vips-dev, and use 'npm run build' to prepare your application.
Attach Vercel or Netlify to your Next.js repo, enable per-locale caching headers, and monitor 404s segmented by Accept-Language. Keep nightly backups of your Strapi database—losing translations hurts more than losing images when you're shipping six languages.
Take Your Multilingual Architecture Further with Strapi
Congratulations on building a production-ready international architecture with Strapi and Next.js. By setting up this robust system, you've not only cut down on time-consuming tasks—from days of custom middleware to just 30 minutes of configuration—but also unlocked numerous advanced capabilities.
Now that you've established a solid foundation, consider exploring region-specific content strategies that cater to audiences in distinct locales with tailored messages. You can also automate translation workflows to maintain efficiency as your content scales globally.
As you continue to be a proactive part of the Strapi community, remember the vast resources at your disposal. At Strapi.io, you'll find a marketplace rich with plugins that enhance translation capabilities and enterprise features designed to promote seamless team collaboration.
Your journey from initial setup to a sophisticated global system marks not just an immediate success but paves the way for long-term growth. Whether you intend to manage greater volumes of localized content or optimize existing processes, Strapi's ecosystem is ready to support your ambitions.
Feel free to clone the application from the GitHub repository and extend its functionality. To learn more about Strapi and its other remarkable features, please visit the official documentation.