Skip to main content
Back to Blog
Tutorials
5 min read
December 1, 2024

How to Build a Multi-Language Blog in Next.js

Create a multilingual blog with locale-based routing, translated content, language switcher, and SEO hreflang tags in Next.js.

Ryel Banfield

Founder & Lead Developer

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.

i18nmultilingualbloglocalizationNext.jstutorial

Ready to Start Your Project?

RCB Software builds world-class websites and applications for businesses worldwide.

Get in Touch

Related Articles