A multilingual blog helps you reach a global audience. Here is how to implement locale-based routing, content translation, and SEO best practices.
Step 1: Project Structure
app/
[locale]/
layout.tsx
page.tsx
blog/
page.tsx
[slug]/
page.tsx
content/
blog/
en/
hello-world.md
getting-started.md
es/
hello-world.md
getting-started.md
fr/
hello-world.md
lib/
i18n.ts
dictionaries/
en.json
es.json
fr.json
Step 2: Locale Configuration
// lib/i18n.ts
export const locales = ["en", "es", "fr"] as const;
export type Locale = (typeof locales)[number];
export const defaultLocale: Locale = "en";
export const localeNames: Record<Locale, string> = {
en: "English",
es: "Espanol",
fr: "Francais",
};
export function isValidLocale(locale: string): locale is Locale {
return locales.includes(locale as Locale);
}
Step 3: Dictionary Loader
// lib/dictionaries.ts
import type { Locale } from "./i18n";
const dictionaries = {
en: () => import("./dictionaries/en.json").then((m) => m.default),
es: () => import("./dictionaries/es.json").then((m) => m.default),
fr: () => import("./dictionaries/fr.json").then((m) => m.default),
};
export async function getDictionary(locale: Locale) {
return dictionaries[locale]();
}
Dictionary Files
// lib/dictionaries/en.json
{
"nav": {
"home": "Home",
"blog": "Blog",
"about": "About"
},
"blog": {
"title": "Blog",
"readMore": "Read more",
"publishedOn": "Published on",
"backToBlog": "Back to blog",
"noPostsFound": "No posts found."
},
"common": {
"language": "Language"
}
}
// lib/dictionaries/es.json
{
"nav": {
"home": "Inicio",
"blog": "Blog",
"about": "Acerca de"
},
"blog": {
"title": "Blog",
"readMore": "Leer mas",
"publishedOn": "Publicado el",
"backToBlog": "Volver al blog",
"noPostsFound": "No se encontraron publicaciones."
},
"common": {
"language": "Idioma"
}
}
Step 4: Middleware for Locale Detection
// middleware.ts
import { NextRequest, NextResponse } from "next/server";
import { locales, defaultLocale } from "./lib/i18n";
function getLocaleFromHeaders(request: NextRequest): string {
const acceptLanguage = request.headers.get("accept-language");
if (!acceptLanguage) return defaultLocale;
const preferred = acceptLanguage
.split(",")
.map((lang) => {
const [code, q] = lang.trim().split(";q=");
return { code: code.split("-")[0], quality: q ? parseFloat(q) : 1.0 };
})
.sort((a, b) => b.quality - a.quality);
for (const { code } of preferred) {
if (locales.includes(code as (typeof locales)[number])) {
return code;
}
}
return defaultLocale;
}
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// Check if pathname has a locale
const pathnameHasLocale = locales.some(
(locale) => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
);
if (pathnameHasLocale) return NextResponse.next();
// Skip API routes and static files
if (pathname.startsWith("/api/") || pathname.includes(".")) {
return NextResponse.next();
}
// Redirect to locale-prefixed path
const locale = getLocaleFromHeaders(request);
return NextResponse.redirect(
new URL(`/${locale}${pathname}`, request.url)
);
}
export const config = {
matcher: ["/((?!_next|api|favicon.ico|images|logos).*)"],
};
Step 5: Locale Layout
// app/[locale]/layout.tsx
import { notFound } from "next/navigation";
import { locales, type Locale } from "@/lib/i18n";
import { getDictionary } from "@/lib/dictionaries";
import { LanguageSwitcher } from "@/components/LanguageSwitcher";
export function generateStaticParams() {
return locales.map((locale) => ({ locale }));
}
export default async function LocaleLayout({
children,
params,
}: {
children: React.ReactNode;
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
if (!locales.includes(locale as Locale)) {
notFound();
}
const dict = await getDictionary(locale as Locale);
return (
<html lang={locale}>
<body>
<header className="border-b">
<nav className="container flex items-center justify-between py-4">
<div className="flex gap-6">
<a href={`/${locale}`}>{dict.nav.home}</a>
<a href={`/${locale}/blog`}>{dict.nav.blog}</a>
<a href={`/${locale}/about`}>{dict.nav.about}</a>
</div>
<LanguageSwitcher currentLocale={locale as Locale} />
</nav>
</header>
{children}
</body>
</html>
);
}
Step 6: Blog Content Loader
// lib/blog.ts
import fs from "fs";
import path from "path";
import matter from "gray-matter";
import type { Locale, locales } from "./i18n";
const contentDir = path.join(process.cwd(), "content/blog");
interface BlogPost {
slug: string;
title: string;
excerpt: string;
date: string;
locale: Locale;
content: string;
translations: Locale[];
}
export function getBlogPosts(locale: Locale): BlogPost[] {
const dir = path.join(contentDir, locale);
if (!fs.existsSync(dir)) return [];
const files = fs.readdirSync(dir).filter((f) => f.endsWith(".md"));
return files
.map((file) => {
const slug = file.replace(".md", "");
const raw = fs.readFileSync(path.join(dir, file), "utf-8");
const { data, content } = matter(raw);
// Find available translations
const translations = (["en", "es", "fr"] as Locale[]).filter(
(l) => l !== locale && fs.existsSync(path.join(contentDir, l, file))
);
return {
slug,
title: data.title,
excerpt: data.excerpt,
date: data.date,
locale,
content,
translations,
};
})
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
}
export function getBlogPost(locale: Locale, slug: string): BlogPost | null {
const file = path.join(contentDir, locale, `${slug}.md`);
if (!fs.existsSync(file)) return null;
const raw = fs.readFileSync(file, "utf-8");
const { data, content } = matter(raw);
const translations = (["en", "es", "fr"] as Locale[]).filter(
(l) =>
l !== locale &&
fs.existsSync(path.join(contentDir, l, `${slug}.md`))
);
return {
slug,
title: data.title,
excerpt: data.excerpt,
date: data.date,
locale,
content,
translations,
};
}
Step 7: Blog List Page
// app/[locale]/blog/page.tsx
import { getBlogPosts } from "@/lib/blog";
import { getDictionary } from "@/lib/dictionaries";
import { type Locale } from "@/lib/i18n";
import type { Metadata } from "next";
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}): Promise<Metadata> {
const { locale } = await params;
const dict = await getDictionary(locale as Locale);
return {
title: dict.blog.title,
alternates: {
languages: {
en: "/en/blog",
es: "/es/blog",
fr: "/fr/blog",
},
},
};
}
export default async function BlogPage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
const posts = getBlogPosts(locale as Locale);
const dict = await getDictionary(locale as Locale);
return (
<main className="container py-10">
<h1 className="mb-8 text-3xl font-bold">{dict.blog.title}</h1>
{posts.length === 0 ? (
<p className="text-gray-600">{dict.blog.noPostsFound}</p>
) : (
<div className="grid gap-6 md:grid-cols-2">
{posts.map((post) => (
<article key={post.slug} className="rounded-lg border p-6">
<time className="text-sm text-gray-500">
{dict.blog.publishedOn}{" "}
{new Date(post.date).toLocaleDateString(locale)}
</time>
<h2 className="mt-2 text-xl font-semibold">
<a href={`/${locale}/blog/${post.slug}`}>{post.title}</a>
</h2>
<p className="mt-2 text-gray-600">{post.excerpt}</p>
<a
href={`/${locale}/blog/${post.slug}`}
className="mt-3 inline-block text-sm font-medium text-blue-600 hover:underline"
>
{dict.blog.readMore} →
</a>
</article>
))}
</div>
)}
</main>
);
}
Step 8: Blog Post Page with Hreflang
// app/[locale]/blog/[slug]/page.tsx
import { getBlogPost, getBlogPosts } from "@/lib/blog";
import { getDictionary } from "@/lib/dictionaries";
import { locales, type Locale } from "@/lib/i18n";
import { notFound } from "next/navigation";
import type { Metadata } from "next";
export function generateStaticParams() {
return locales.flatMap((locale) =>
getBlogPosts(locale).map((post) => ({
locale,
slug: post.slug,
}))
);
}
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string; slug: string }>;
}): Promise<Metadata> {
const { locale, slug } = await params;
const post = getBlogPost(locale as Locale, slug);
if (!post) return {};
const languages: Record<string, string> = {};
for (const l of [locale, ...post.translations]) {
languages[l] = `/${l}/blog/${slug}`;
}
return {
title: post.title,
description: post.excerpt,
alternates: { languages },
};
}
export default async function BlogPostPage({
params,
}: {
params: Promise<{ locale: string; slug: string }>;
}) {
const { locale, slug } = await params;
const post = getBlogPost(locale as Locale, slug);
const dict = await getDictionary(locale as Locale);
if (!post) notFound();
return (
<main className="container max-w-3xl py-10">
<a
href={`/${locale}/blog`}
className="text-sm text-gray-500 hover:underline"
>
← {dict.blog.backToBlog}
</a>
<article className="mt-6">
<time className="text-sm text-gray-500">
{new Date(post.date).toLocaleDateString(locale)}
</time>
<h1 className="mt-2 text-3xl font-bold">{post.title}</h1>
{/* Translation links */}
{post.translations.length > 0 && (
<div className="mt-4 flex gap-2 text-sm">
<span className="text-gray-500">Also available in:</span>
{post.translations.map((l) => (
<a
key={l}
href={`/${l}/blog/${slug}`}
className="text-blue-600 hover:underline"
>
{l.toUpperCase()}
</a>
))}
</div>
)}
<div
className="prose mt-8"
dangerouslySetInnerHTML={{ __html: post.content }}
/>
</article>
</main>
);
}
Step 9: Language Switcher
// components/LanguageSwitcher.tsx
"use client";
import { usePathname } from "next/navigation";
import { locales, localeNames, type Locale } from "@/lib/i18n";
export function LanguageSwitcher({ currentLocale }: { currentLocale: Locale }) {
const pathname = usePathname();
function switchLocale(newLocale: Locale) {
// Replace current locale in path
const newPath = pathname.replace(`/${currentLocale}`, `/${newLocale}`);
window.location.href = newPath;
}
return (
<select
value={currentLocale}
onChange={(e) => switchLocale(e.target.value as Locale)}
className="rounded-lg border px-2 py-1 text-sm"
aria-label="Select language"
>
{locales.map((locale) => (
<option key={locale} value={locale}>
{localeNames[locale]}
</option>
))}
</select>
);
}
Need a Multilingual Website?
We build internationalized websites with proper SEO, RTL support, and content management. Contact us to expand your global reach.