Jan 8, 2025
Working Across 11+ Languages: Challenges and Solutions
8 min · Internationalization · i18n · Next.js · SEO · Frontend
Working Across 11+ Languages: Challenges and Solutions
Building a SaaS platform that serves clients across multiple countries and languages is no longer a nice-to-have feature - it's a requirement. At Oniyore, we serve clients in 11+ languages and markets. This isn't just about translation; it's about fundamentally rethinking how your application works.
The Scope: From 1 Language to 11
Starting with English was simple. Adding Russian, Italian, Spanish, French, and Dutch meant thinking about:
- Dynamic routing: How URLs change per language
- Content management: Where translations live and how they're versioned
- SEO implications: Canonical links, alternate links, hreflang tags
- Performance: Ensuring multilingual sites don't become slow
- User experience: Detecting language preferences, language switching
This is a journey we took from a monolingual platform to serving multiple markets simultaneously.
Foundation: next-intl Architecture
We built our internationalization foundation on next-intl, a Next.js library purpose-built for routing-based i18n:
// i18n/config.ts
export const locales = ['en', 'ru', 'it', 'es', 'fr', 'du'] as const;
export type Locale = (typeof locales)[number];
export const defaultLocale: Locale = 'en';
export const localeNames: Record<Locale, string> = {
en: 'English',
ru: 'Русский',
it: 'Italiano',
es: 'Español',
fr: 'Français',
du: 'Nederlands',
};
export const localeFullNames: Record<Locale, string> = {
en: 'English (United States)',
ru: 'Русский (Россия)',
it: 'Italiano (Italia)',
es: 'Español (España)',
fr: 'Français (France)',
du: 'Nederlands (Nederland)',
};
Dynamic Routing Structure
With Next.js 14's App Router, routing becomes language-aware:
src/app/
├── [locale]/
│ ├── layout.tsx # Language wrapper
│ ├── page.tsx # Homepage
│ ├── articles/
│ │ ├── page.tsx # Article listing
│ │ └── [id]/
│ │ └── page.tsx # Article detail
│ ├── portfolio/
│ │ └── page.tsx # Portfolio page
│ └── blog/
│ ├── page.tsx # Blog listing
│ └── [slug]/
│ └── page.tsx # Blog post
├── layout.tsx # Root layout
└── error.tsx
Each route automatically gets prefixed with the locale:
/en/articles/ru/articles/fr/articles
Middleware for Language Detection
Next.js middleware handles intelligent language detection and routing:
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { locales, defaultLocale } from './i18n/config';
export function middleware(request: NextRequest) {
const pathname = request.nextUrl.pathname;
// Check if pathname already has a locale
const pathnameHasLocale = locales.some(
(locale) =>
pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
);
if (pathnameHasLocale) {
return NextResponse.next();
}
// Detect user's preferred language from Accept-Language header
const acceptLanguage = request.headers.get('accept-language');
let preferredLocale = defaultLocale;
if (acceptLanguage) {
const preferred = acceptLanguage
.split(',')[0]
.split('-')[0]
.toLowerCase();
if (locales.includes(preferred as any)) {
preferredLocale = preferred as any;
}
}
// Redirect to localized path
return NextResponse.redirect(
new URL(`/${preferredLocale}${pathname}`, request.url)
);
}
export const config = {
matcher: ['/((?!api|_next|.*\\..*).*)'],
};
This middleware automatically:
- Detects browser language preferences
- Redirects to appropriate locale
- Preserves existing localized paths
Translation Management
With 11 languages, manual translation becomes a logistics challenge. We implemented a structured approach:
Translation File Structure
locales/
├── en.json # English (source)
├── ru.json # Russian
├── it.json # Italian
├── es.json # Spanish
├── fr.json # French
└── du.json # Dutch
Each file structure:
{
"common": {
"home": "Home",
"about": "About",
"contact": "Contact",
"language": "Language"
},
"articles": {
"title": "Articles",
"description": "Read my latest articles",
"readMore": "Read More",
"readingTime": "min read"
},
"blog": {
"title": "Blog",
"publishedAt": "Published on",
"tags": "Tags",
"previousPost": "Previous Post",
"nextPost": "Next Post"
}
}
Dynamic Translation Imports
// i18n/translations.ts
import type { Locale } from './config';
type Messages = typeof import('../locales/en.json');
export async function getMessages(locale: Locale): Promise<Messages> {
try {
const messages = await import(`../locales/${locale}.json`);
return messages.default;
} catch (error) {
console.error(`Failed to load translations for ${locale}`, error);
const englishMessages = await import('../locales/en.json');
return englishMessages.default;
}
}
Using Translations in Components
// app/[locale]/components/Header.tsx
import { useTranslations } from 'next-intl';
export function Header() {
const t = useTranslations();
return (
<header>
<nav>
<ul>
<li><a href="/">{t('common.home')}</a></li>
<li><a href="/about">{t('common.about')}</a></li>
<li><a href="/contact">{t('common.contact')}</a></li>
</ul>
</nav>
</header>
);
}
SEO Strategy for Multilingual Sites
Search engines need to understand that your content exists in multiple languages. Without proper markup, you'll lose ranking opportunities.
Canonical Links and Alternate Links
// shared/lib/structured-data.ts
export function generateAlternateLinks(slug: string, locales: Locale[]) {
return locales.map((locale) => ({
rel: 'alternate',
hrefLang: locale,
href: `https://matteokonradi.com/${locale}${slug === '/' ? '' : slug}`,
}));
}
// Usage in layout.tsx
export async function generateMetadata(): Promise<Metadata> {
const alternates = generateAlternateLinks('/blog', locales);
return {
alternates: {
canonical: 'https://matteokonradi.com/en/blog',
languages: {
en: 'https://matteokonradi.com/en/blog',
ru: 'https://matteokonradi.com/ru/blog',
it: 'https://matteokonradi.com/it/blog',
es: 'https://matteokonradi.com/es/blog',
fr: 'https://matteokonradi.com/fr/blog',
du: 'https://matteokonradi.com/du/blog',
'x-default': 'https://matteokonradi.com/blog',
},
},
};
}
Schema.org Structured Data
Search engines also understand Schema.org markup. We generate locale-specific schemas:
export function generateBlogPostSchema(
post: BlogPost,
locale: Locale
): SchemaType {
const baseUrl = getBaseUrl(locale);
return {
'@context': 'https://schema.org',
'@type': 'BlogPosting',
'@id': `${baseUrl}/blog/${post.slug}#article`,
headline: post.frontmatter.title,
description: post.frontmatter.description,
image: post.image || `${baseUrl}/static/og-image.png`,
datePublished: post.frontmatter.publishedAt,
author: {
'@type': 'Person',
name: 'Matteo Konradi',
url: baseUrl,
},
inLanguage: locale,
articleSection: 'Technology',
keywords: post.frontmatter.tags.join(', '),
};
}
hreflang Tag Generation
function generateHrefLangTags(
slug: string,
availableLocales: Locale[]
): React.ReactNode[] {
return availableLocales.map((locale) => (
<link
key={locale}
rel="alternate"
hrefLang={locale}
href={`https://matteokonradi.com/${locale}${slug}`}
/>
));
}
Content Strategy Across Markets
Different markets have different expectations. English audiences want in-depth technical posts. Russian audiences value practical applications. This informed our content strategy:
Parallel Content vs. Translated Content
Some content is universal (technical tutorials), while other content is market-specific:
// Blog metadata can have market-specific flags
interface BlogPost {
title: string;
availableIn: Locale[]; // Which markets this is translated to
isMarketSpecific?: {
locale: Locale;
relevance: number; // How relevant to that market
};
}
// When fetching blog posts for a locale
async function getBlogPostsForLocale(locale: Locale): Promise<BlogPost[]> {
const allPosts = await getBlogPosts();
return allPosts.filter((post) => {
// Include if it's available in this locale
if (post.availableIn.includes(locale)) {
return true;
}
// Include if it's in the default locale (fallback)
if (post.availableIn.includes('en')) {
return true;
}
return false;
});
}
Performance Optimization for Multilingual Sites
Having 11 language versions can slow your site down significantly if not optimized:
Build-Time Generation
Next.js pre-generates all language versions:
export async function generateStaticParams() {
const posts = await getBlogPosts();
const locales = getAvailableLocales();
return posts.flatMap((post) =>
locales.map((locale) => ({
locale,
slug: post.slug,
}))
);
}
This generates static pages for every post × every language at build time.
Selective Revalidation
With ISR (Incremental Static Regeneration), only changed content is rebuilt:
export const revalidate = 3600; // Revalidate every hour
export const dynamicParams = true; // Allow new slugs without rebuild
export default async function BlogPostPage({
params,
}: {
params: { locale: Locale; slug: string };
}) {
const post = await getBlogPostBySlug(params.slug);
if (!post) {
notFound();
}
// Content is served from cache, not regenerated every request
return <BlogPost post={post} locale={params.locale} />;
}
Code Splitting Per Language
Load only the translations needed for the current language:
// Dynamic import only needed locale translations
const messages = await import(`../locales/${locale}.json`).then(
(module) => module.default
);
User Experience: Language Switching
Visitors should easily switch between languages:
// components/LanguageSwitcher.tsx
import { useLocale } from 'next-intl';
import Link from 'next/link';
import { localeNames } from '@/i18n/config';
export function LanguageSwitcher({ pathname }: { pathname: string }) {
const locale = useLocale();
return (
<select
value={locale}
onChange={(e) => {
const newLocale = e.target.value;
const newPathname = pathname.replace(`/${locale}`, `/${newLocale}`);
window.location.href = newPathname;
}}
className="bg-transparent text-sm text-gray-300 border border-gray-600 rounded px-2 py-1"
>
{Object.entries(localeNames).map(([code, name]) => (
<option key={code} value={code} className="bg-gray-900 text-gray-300">
{name}
</option>
))}
</select>
);
}
Monitoring and Analytics Across Languages
Understanding which languages drive the most traffic helps prioritize translation efforts:
// Track page views per language
window.dataLayer.push({
event: 'page_view',
language: locale,
page_path: pathname,
page_location: location.href,
});
// Segment goals by language
gtag('config', 'GA_ID', {
language: locale,
});
Lessons Learned
1. Start with One Language, Plan for Many
Even if you only need English now, design your app for multi-language from the start. Retrofitting i18n is painful.
2. next-intl Is Worth It
Using next-intl instead of building custom routing saved us months of work and potential bugs.
3. Not All Content Needs All Languages
Focus translations on high-value content. Not every blog post needs to be in 11 languages.
4. SEO Markup is Critical
Without proper canonical and hreflang tags, search engines get confused about which version to rank.
5. Performance Can Suffer
Generating 11 versions of every page can create large builds. Use ISR and selective regeneration.
6. User Experience Matters
Language switching should be frictionless. One clunky switcher and users will leave.
Conclusion
Supporting 11+ languages isn't just a feature - it's a complete paradigm shift in how you think about your application. From architecture to content strategy to SEO, everything changes.
The good news? With modern tools like Next.js and next-intl, building truly multilingual applications is more achievable than ever.
The challenge? Maintaining consistency, ensuring quality translations, and keeping performance intact across all those languages.
But when you get it right, you unlock access to markets and audiences that monolingual competitors can't serve.
That's the power of multilingual architecture.