astro-i18next

Last updated: July 31, 2025Author: Jakub Pomykała

In this guide, you'll learn how to implement internationalization in Astro.js using astro-i18next and manage your translations with SimpleLocalize. This approach leverages the powerful i18next ecosystem with server-side rendering support.

Installation

Install the required dependencies for astro-i18next integration:

npm install astro-i18next i18next

What you get:

  • Full i18next ecosystem support
  • Server-side rendering with translations
  • Automatic locale detection and routing
  • Namespace support for organized translations
  • Interpolation and pluralization
  • Plugin ecosystem compatibility

Configuration

Create an i18next configuration file:

// src/i18n/config.ts
import i18next from "i18next";

export const languages = {
  en: "English",
  es: "Español",
  fr: "Français",
  de: "Deutsch"
};

export const defaultLanguage = "en";

export const showDefaultLanguage = false;

i18next.init({
  debug: false,
  fallbackLng: defaultLanguage,
  lng: defaultLanguage,
  supportedLngs: Object.keys(languages),
  ns: ["common", "navigation", "forms"],
  defaultNS: "common",
  resources: {
    en: {
      common: () => import("../locales/en/common.json"),
      navigation: () => import("../locales/en/navigation.json"),
      forms: () => import("../locales/en/forms.json"),
    },
    es: {
      common: () => import("../locales/es/common.json"),
      navigation: () => import("../locales/es/navigation.json"),
      forms: () => import("../locales/es/forms.json"),
    },
    fr: {
      common: () => import("../locales/fr/common.json"),
      navigation: () => import("../locales/fr/navigation.json"),
      forms: () => import("../locales/fr/forms.json"),
    },
    de: {
      common: () => import("../locales/de/common.json"),
      navigation: () => import("../locales/de/navigation.json"),
      forms: () => import("../locales/de/forms.json"),
    },
  },
});

export default i18next;

Configure astro-i18next in your astro.config.mjs:

// astro.config.mjs
import { defineConfig } from "astro/config";
import astroI18next from "docs/integrations/astro-i18next.mdx";

export default defineConfig({
  integrations: [
    astroI18next({
      baseLanguage: "en",
      i18next: {
        debug: false,
        supportedLngs: ["en", "es", "fr", "de"],
        fallbackLng: "en",
        ns: ["common", "navigation", "forms"],
        defaultNS: "common",
      },
      i18nextPlugins: {
        fsBackend: "i18next-fs-backend",
      },
    }),
  ],
});

Translation Files Structure

Organize your translations using namespaces:

src/
  locales/
    en/
      common.json
      navigation.json
      forms.json
    es/
      common.json
      navigation.json
      forms.json
    fr/
      common.json
      navigation.json
      forms.json
    de/
      common.json
      navigation.json
      forms.json

Example translation files:

// src/locales/en/common.json
{
  "site": {
    "title": "My Astro App",
    "description": "A multilingual website built with Astro.js"
  },
  "welcome": {
    "title": "Welcome to our website",
    "subtitle": "Experience the power of internationalization",
    "cta": "Get started"
  },
  "footer": {
    "copyright": "© {{year}} My Company. All rights reserved.",
    "privacy": "Privacy Policy",
    "terms": "Terms of Service"
  }
}
// src/locales/en/navigation.json
{
  "menu": {
    "home": "Home",
    "about": "About",
    "services": "Services",
    "contact": "Contact",
    "blog": "Blog"
  },
  "breadcrumb": {
    "home": "Home",
    "current": "Current Page"
  }
}
// src/locales/en/forms.json
{
  "contact": {
    "title": "Contact Us",
    "name": "Full Name",
    "email": "Email Address",
    "subject": "Subject",
    "message": "Your Message",
    "submit": "Send Message",
    "success": "Thank you for your message!",
    "error": "Please fill in all required fields."
  },
  "newsletter": {
    "title": "Subscribe to our newsletter",
    "email": "Enter your email",
    "subscribe": "Subscribe",
    "success": "Successfully subscribed!"
  }
}

Page Implementation

Multilingual Pages

Create pages that support multiple languages:

---
// src/pages/[...lang]/index.astro
import { t, changeLanguage } from "i18next";
import { HeadHrefLangs } from "astro-i18next/components";
import Layout from "../../layouts/Layout.astro";

export async function getStaticPaths() {
  return [
    { params: { lang: undefined } },
    { params: { lang: "es" } },
    { params: { lang: "fr" } },
    { params: { lang: "de" } },
  ];
}

const { lang } = Astro.params;
await changeLanguage(lang || "en");
---

<Layout>
  <Fragment slot="head">
    <HeadHrefLangs />
  </Fragment>

  <main>
    <h1>{t("welcome.title")}</h1>
    <p>{t("welcome.subtitle")}</p>
    <button class="cta-button">
      {t("welcome.cta")}
    </button>

    <section class="features">
      <h2>{t("features.title", "Amazing Features")}</h2>
      <div class="feature-grid">
        <div class="feature">
          <h3>{t("features.fast.title", "Lightning Fast")}</h3>
          <p>{t("features.fast.description", "Built for speed and performance")}</p>
        </div>
        <div class="feature">
          <h3>{t("features.seo.title", "SEO Optimized")}</h3>
          <p>{t("features.seo.description", "Perfect for search engines")}</p>
        </div>
      </div>
    </section>
  </main>
</Layout>

Blog with Translations

---
// src/pages/[...lang]/blog/[slug].astro
import { t, changeLanguage } from "i18next";
import { HeadHrefLangs } from "astro-i18next/components";
import Layout from "../../../layouts/Layout.astro";
import { getCollection } from "astro:content";

export async function getStaticPaths() {
  const blogPosts = await getCollection("blog");
  const languages = ["en", "es", "fr", "de"];

  const paths = [];

  for (const post of blogPosts) {
    for (const lang of languages) {
      paths.push({
        params: {
          lang: lang === "en" ? undefined : lang,
          slug: post.slug
        },
        props: { post, lang: lang || "en" }
      });
    }
  }

  return paths;
}

const { post, lang } = Astro.props;
await changeLanguage(lang);

const { Content } = await post.render();
---

<Layout title={post.data.title}>
  <Fragment slot="head">
    <HeadHrefLangs />
  </Fragment>

  <article>
    <header>
      <nav class="breadcrumb">
        <a href={lang === "en" ? "/" : `/${lang}/`}>
          {t("navigation:breadcrumb.home")}
        </a>
        <span>/</span>
        <a href={lang === "en" ? "/blog" : `/${lang}/blog`}>
          {t("navigation:menu.blog")}
        </a>
        <span>/</span>
        <span>{t("navigation:breadcrumb.current")}</span>
      </nav>

      <h1>{post.data.title}</h1>
      <time datetime={post.data.date.toISOString()}>
        {post.data.date.toLocaleDateString(lang, {
          year: 'numeric',
          month: 'long',
          day: 'numeric'
        })}
      </time>
    </header>

    <Content />
  </article>
</Layout>

Components with Translations

Language Switcher

---
// src/components/LanguageSwitcher.astro
import { localizePath } from "astro-i18next";
import { languages } from "../i18n/config";

const currentPath = Astro.url.pathname;
---

<div class="language-switcher">
  <label for="language-select" class="sr-only">
    Choose language
  </label>
  <select id="language-select" onchange="handleLanguageChange(this.value)">
    {Object.entries(languages).map(([code, name]) => (
      <option
        value={localizePath("/", code)}
        selected={currentPath.startsWith(`/${code}/`) || (code === "en" && !currentPath.includes("/es/") && !currentPath.includes("/fr/") && !currentPath.includes("/de/"))}
      >
        {name}
      </option>
    ))}
  </select>
</div>

<script>
  function handleLanguageChange(path) {
    // Get current path without language prefix
    const currentPath = window.location.pathname;
    const pathWithoutLang = currentPath.replace(/^\/[a-z]{2}(\/|$)/, '/');

    // Construct new path
    let newPath;
    if (path === '/') {
      newPath = pathWithoutLang;
    } else {
      newPath = path.replace(/\/$/, '') + pathWithoutLang;
    }

    window.location.href = newPath;
  }
</script>

<style>
  .language-switcher {
    margin: 1rem 0;
  }

  .language-switcher select {
    padding: 0.5rem;
    border: 1px solid #ccc;
    border-radius: 4px;
    background: white;
    font-size: 1rem;
  }

  .sr-only {
    position: absolute;
    width: 1px;
    height: 1px;
    padding: 0;
    margin: -1px;
    overflow: hidden;
    clip: rect(0, 0, 0, 0);
    white-space: nowrap;
    border: 0;
  }
</style>

Contact Form with Validation

---
// src/components/ContactForm.astro
import { t } from "i18next";
---

<form class="contact-form" method="POST" action="/api/contact">
  <h2>{t("forms:contact.title")}</h2>

  <div class="form-group">
    <label for="name">{t("forms:contact.name")}</label>
    <input
      type="text"
      id="name"
      name="name"
      required
      placeholder={t("forms:contact.name")}
    />
  </div>

  <div class="form-group">
    <label for="email">{t("forms:contact.email")}</label>
    <input
      type="email"
      id="email"
      name="email"
      required
      placeholder={t("forms:contact.email")}
    />
  </div>

  <div class="form-group">
    <label for="subject">{t("forms:contact.subject")}</label>
    <input
      type="text"
      id="subject"
      name="subject"
      required
      placeholder={t("forms:contact.subject")}
    />
  </div>

  <div class="form-group">
    <label for="message">{t("forms:contact.message")}</label>
    <textarea
      id="message"
      name="message"
      rows="5"
      required
      placeholder={t("forms:contact.message")}
    ></textarea>
  </div>

  <button type="submit" class="submit-btn">
    {t("forms:contact.submit")}
  </button>
</form>

<style>
  .contact-form {
    max-width: 600px;
    margin: 2rem auto;
    padding: 2rem;
    border: 1px solid #e0e0e0;
    border-radius: 8px;
  }

  .form-group {
    margin-bottom: 1.5rem;
  }

  .form-group label {
    display: block;
    margin-bottom: 0.5rem;
    font-weight: 600;
  }

  .form-group input,
  .form-group textarea {
    width: 100%;
    padding: 0.75rem;
    border: 1px solid #ccc;
    border-radius: 4px;
    font-size: 1rem;
  }

  .submit-btn {
    background: #007acc;
    color: white;
    padding: 0.75rem 2rem;
    border: none;
    border-radius: 4px;
    font-size: 1rem;
    cursor: pointer;
    transition: background 0.2s;
  }

  .submit-btn:hover {
    background: #005999;
  }
</style>

Layout with SEO Support

---
// src/layouts/Layout.astro
import { t } from "i18next";
import { HeadHrefLangs } from "astro-i18next/components";
import LanguageSwitcher from "../components/LanguageSwitcher.astro";

export interface Props {
  title?: string;
  description?: string;
}

const { title, description } = Astro.props;
const pageTitle = title ? `${title} | ${t("site.title")}` : t("site.title");
const pageDescription = description || t("site.description");
---

<!DOCTYPE html>
<html lang={t("site.lang", "en")}>
  <head>
    <meta charset="UTF-8" />
    <meta name="description" content={pageDescription} />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
    <title>{pageTitle}</title>

    <!-- Automatically generates hreflang tags -->
    <HeadHrefLangs />
  </head>
  <body>
    <header>
      <nav>
        <a href="/">{t("site.title")}</a>
        <div class="nav-links">
          <a href={t("navigation:menu.home", "/")}>{t("navigation:menu.home")}</a>
          <a href={t("navigation:menu.about", "/about")}>{t("navigation:menu.about")}</a>
          <a href={t("navigation:menu.services", "/services")}>{t("navigation:menu.services")}</a>
          <a href={t("navigation:menu.contact", "/contact")}>{t("navigation:menu.contact")}</a>
        </div>
        <LanguageSwitcher />
      </nav>
    </header>

    <main>
      <slot />
    </main>

    <footer>
      <p>{t("footer.copyright", { year: new Date().getFullYear() })}</p>
      <div class="footer-links">
        <a href="/privacy">{t("footer.privacy")}</a>
        <a href="/terms">{t("footer.terms")}</a>
      </div>
    </footer>
  </body>
</html>

<style>
  /* Add your global styles here */
  header {
    background: #f8f9fa;
    padding: 1rem 0;
    border-bottom: 1px solid #e0e0e0;
  }

  nav {
    max-width: 1200px;
    margin: 0 auto;
    display: flex;
    justify-content: space-between;
    align-items: center;
    padding: 0 2rem;
  }

  .nav-links {
    display: flex;
    gap: 2rem;
  }

  footer {
    background: #333;
    color: white;
    text-align: center;
    padding: 2rem 0;
    margin-top: 4rem;
  }

  .footer-links {
    margin-top: 1rem;
    display: flex;
    justify-content: center;
    gap: 2rem;
  }

  .footer-links a {
    color: #ccc;
    text-decoration: none;
  }

  .footer-links a:hover {
    color: white;
  }
</style>

SimpleLocalize Integration

Set up SimpleLocalize CLI for translation management:

# macOS / Linux / Windows (WSL) curl -s https://get.simplelocalize.io/2.9/install | bash # Windows (PowerShell) . { iwr -useb https://get.simplelocalize.io/2.9/install-windows } | iex; # npm npm install @simplelocalize/cli
# macOS / Linux / Windows (WSL)
curl -s https://get.simplelocalize.io/2.9/install | bash

# Windows (PowerShell)
. { iwr -useb https://get.simplelocalize.io/2.9/install-windows } | iex;

# npm
npm install @simplelocalize/cli

Create simplelocalize.yml in your project root:

apiKey: YOUR_PROJECT_API_KEY

# Upload configuration
uploadFormat: single-language-json
uploadPath: ./src/locales/{lang}/{ns}.json
uploadLanguageKey: en
uploadOptions:
  - REPLACE_TRANSLATION_IF_FOUND

# Download configuration
downloadPath: ./src/locales/{lang}/{ns}.json
downloadLanguageKeys: ['en', 'es', 'fr', 'de']
downloadFormat: single-language-json
downloadOptions:
  - CREATE_DIRECTORIES

Translation workflow:

# 1. Upload source translations to SimpleLocalize
simplelocalize upload

# 2. Translate using SimpleLocalize interface or auto-translation
# (done via web interface)

# 3. Download completed translations
simplelocalize download

# 4. Build and deploy
npm run build

Advanced Features

Pluralization

// src/locales/en/common.json
{
  "items": {
    "count": "{{count}} item",
    "count_plural": "{{count}} items"
  },
  "notifications": {
    "unread": "You have {{count}} unread message",
    "unread_plural": "You have {{count}} unread messages"
  }
}

Usage in components:

---
import { t } from "i18next";

const itemCount = 5;
const messageCount = 1;
---

<div>
  <p>{t("items.count", { count: itemCount })}</p>
  <p>{t("notifications.unread", { count: messageCount })}</p>
</div>

Interpolation with HTML

{
  "legal": {
    "terms": "By signing up, you agree to our <a href='/terms'>Terms of Service</a> and <a href='/privacy'>Privacy Policy</a>."
  }
}

Resources

Was this helpful?