Dynamic Routing Documentation with i18n

Since Astro doesn’t natively support URL localization out of the box, a different approach was needed to enable multilingual pages. This project uses dynamic parameters ([...pages]) to implement localized paths.

πŸ—οΈ Project Architecture Overview

The project implements an internationalization (i18n) system that enables:

πŸ“ File Structure

src/
β”œβ”€β”€ components/           # Components (Header, Footer, LanguagePicker)
β”œβ”€β”€ content/              # Content (blogs, authors)
β”‚   β”œβ”€β”€ blog/
β”‚   β”‚   β”œβ”€β”€ en/           # English posts
β”‚   β”‚   └── sl/           # Slovenian posts
β”‚   └── authors/          # Author data
β”œβ”€β”€ data/                 # Navigation data
β”‚   └── navigationData.ts
β”œβ”€β”€ i18n/                 # Internationalization system
β”‚   β”œβ”€β”€ routes.ts         # Path translations
β”‚   β”œβ”€β”€ ui.ts             # Language configuration
β”‚   └── utils.ts          # Utility functions
β”œβ”€β”€ layouts/              # Base layouts
β”œβ”€β”€ locales/              # Translation files
β”‚   β”œβ”€β”€ en/               # English translations
β”‚   └── sl/               # Slovenian translations
β”œβ”€β”€ pages/                # Pages with dynamic routing
β”‚   β”œβ”€β”€ [about]/          # About pages
β”‚   β”‚   β”œβ”€β”€ [...index].astro
β”‚   β”‚   β”œβ”€β”€ _about-en.mdx
β”‚   β”‚   └── _about-sl.mdx
β”‚   β”œβ”€β”€ [blog]/           # Blog detail pages
β”‚   β”‚   └── [...slug].astro
β”‚   β”œβ”€β”€ [dyn_routing]/    # Dynamic routing examples
β”‚   β”‚   β”œβ”€β”€ [subpage2]/
β”‚   β”‚   β”‚   └── [...index].astro
β”‚   β”‚   └── [...subpage1].astro
β”‚   β”œβ”€β”€ [pages]/          # General pages
β”‚   β”‚   β”œβ”€β”€ [...index].astro
β”‚   β”‚   β”œβ”€β”€ _pages-en.mdx
β”‚   β”‚   └── _pages-sl.mdx
β”‚   β”œβ”€β”€ [pagination]/     # Pagination examples
β”‚   β”‚   └── [...page].astro
β”‚   β”œβ”€β”€ [...blog].astro   # Blog listing page
β”‚   β”œβ”€β”€ [...contact].astro # Contact page
β”‚   β”œβ”€β”€ [...index].astro  # Home page
β”‚   └── 404.astro         # Error page
└── styles/              # Style files

πŸ”€ How Dynamic Routing Works

1. URL Structure

Examples assume defaultLang = "en" and showDefaultLang = false. These URLs adapt automatically when you change these settings.

Page PatternEnglish URLSlovenian URLDescription
[...index].astro//sl/Home page
[about]/[...index].astro/about/sl/o-projektuAbout
[...contact].astro/contact/sl/kontaktContact
[blog]/[...slug].astro/blog/post/sl/spletni-dnevnik/objavaBlog posts

2. Visual Routing Flow

File: [about]/[...index].astro
β”‚
β”œβ”€β”€ English path
β”‚   β”œβ”€β”€ URL: /about
β”‚   β”œβ”€β”€ Parameters: { about: "about", index: undefined }
β”‚   β”œβ”€β”€ Props: { lang: "en" }
β”‚   └── Content: _about-en.md
β”‚
└── Slovenian path
    β”œβ”€β”€ URL: /sl/o-projektu
    β”œβ”€β”€ Parameters: { about: "sl", index: "o-projektu" }
    β”œβ”€β”€ Props: { lang: "sl" }
    └── Content: _about-sl.md

3. Page Structures

There are two main patterns for implementing dynamic pages:

Pattern A: [...pages].astro

import { buildLocalizedStaticPaths } from "@i18n/utils";

// Single-file: [...pages].astro
export function getStaticPaths() {
    return buildLocalizedStaticPaths("/pages", ["...pages"]);
}

Pattern B: [pages]/[...index].astro

import { buildLocalizedStaticPaths } from "@i18n/utils";

// Folder pattern: [pages]/[...index].astro
export function getStaticPaths() {
    return buildLocalizedStaticPaths("/pages", ["pages", "...index"]);
}

βš™οΈ Key System Components

1. src/i18n/routes.ts - Path Translations

This file defines mappings between English and localized paths:

export const routes: Record<string, Record<string, string>> = {
    sl: {
        about: "o-projektu",
        blog: "spletni-dnevnik",
        "dynamic-routing": "dinamicno-usmerjanje",
        "blog-pagination": "spletni-dnevnik-paginacija",
        pages: "strani",
        "subpage-1": "podstran-1",
        "subpage-2": "podstran-2",
    },
};

Important: Keys must match parameters in getStaticPaths() functions!

2. src/i18n/ui.ts - Language Configuration

This file contains key configurations for language support:

These are fully supported across pages, blog, and pagination.

export const languages = {
    en: "English",
    sl: "Slovenian",
};

export const defaultLang = "en"; // set to "sl" to make Slovenian the root
export const showDefaultLang = false; // true β†’ always prefix: /en/..., /sl/...

How to flip the default language

  1. Open src/i18n/ui.ts and set:
    • defaultLang = "sl" (or your new default)
    • showDefaultLang = true | false depending on whether you want a prefix for the default language
  2. The root becomes:
    • / when showDefaultLang = false
    • /{defaultLang}/ when showDefaultLang = true
  3. No other changes are needed: all pages use useTranslatedPath() in getStaticPaths() so URLs adapt automatically.
  4. Sanity check a few URLs (home, about, blog list/post, pagination) and the language switcher.

3. src/i18n/utils.ts - Utility Functions

getLangFromUrl(url: URL)

Extracts the current language from the URL:

const lang = getLangFromUrl(Astro.url); // "sl" or "en"

useTranslations(lang: string)

Returns a function for translating texts:

const t = useTranslations(lang);
const title = t("main:head.title");
const navHome = t("menu.list.home"); // Uses "common" namespace

Supported key formats:

useTranslatedPath(lang: string)

Returns a function for translating paths:

const translatePath = useTranslatedPath(lang);
<a href={translatePath("/about")}>About</a>; // "/o-projektu" for Slovenian

switchLanguageUrl(currentUrl: URL, targetLang: string)

Enables language switching while preserving current page context:

const newUrl = await switchLanguageUrl(Astro.url, "sl");

🧩 Helper: buildLocalizedStaticPaths()

Generates localized getStaticPaths() entries from an English base path and a simple param pattern. It translates the base path for each language, splits it into segments, and maps those segments into Astro route params.

Signature:

function buildLocalizedStaticPaths(
  basePath: string,
  pattern: string[],
  extraProps?: (lang: string) => Record<string, any>
): Array<{ params: Record<string, string | undefined>; props: Record<string, any> }>

Pattern rules:

Examples:

// About folder: [about]/[...index].astro
export function getStaticPaths() {
  return buildLocalizedStaticPaths("/about", ["about", "...index"]);
}

// Dynamic Routing root: [dyn_routing]/[...index].astro
export function getStaticPaths() {
  return buildLocalizedStaticPaths("/dynamic-routing", ["dyn_routing", "...index"]);
}

// Dynamic Routing subpage 1: [dyn_routing]/[...subpage1].astro
export function getStaticPaths() {
  return buildLocalizedStaticPaths("/dynamic-routing/subpage-1", ["dyn_routing", "...subpage1"]);
}

// Dynamic Routing subpage 2 (nested): [dyn_routing]/[subpage2]/[...index].astro
export function getStaticPaths() {
  return buildLocalizedStaticPaths("/dynamic-routing/subpage-2", ["dyn_routing", "subpage2", "...index"]);
}

// Blog listing: [...blog].astro
export function getStaticPaths() {
  return buildLocalizedStaticPaths("/blog", ["...blog"]);
}

// Optional: extra props per language
export function getStaticPaths() {
  return buildLocalizedStaticPaths("/services", ["services", "...index"], (lang) => ({ section: "services", lang }));
}

Note (pagination): the pagination route uses this helper to resolve the localized base (e.g., blog-pagination β†’ spletni-dnevnik-paginacija) and then fans out pages with paginate() while preserving the i18n URL shape.

4. src/data/navigationData.ts - Navigation

const navigationData = [
    {
        label: "menu.list.home", // Translation key
        href: "/", // English path (default)
        children: [],
    },
    {
        label: "menu.list.service",
        href: "/services",
        children: [
            {
                label: "menu.list.subpage-1",
                href: "/services/service-1",
            },
        ],
    },
];

Note: URLs in navigation are always in English because they’re automatically localized via translatePath().

πŸ—£οΈ Translation System

1. Translation File Structure

src/locales/
β”œβ”€β”€ en/
β”‚   β”œβ”€β”€ common.json    # Shared translations (navigation, footer)
β”‚   β”œβ”€β”€ main.json      # Main page
β”‚   β”œβ”€β”€ about.json     # About page
β”‚   └── blog.json      # Blog page
└── sl/
    β”œβ”€β”€ common.json
    β”œβ”€β”€ main.json
    β”œβ”€β”€ about.json
    └── blog.json

2. Translation File Example (common.json)

{
    "menu": {
        "list": {
            "home": "Home",
            "about": "About",
            "blog": "Blog"
        }
    },
    "footer": {
        "made": "Made with {{what}}"
    }
}

Note: For common.json files, you don’t need to prefix with common:. Use t("menu.list.home") directly, not t("common:menu.list.home").

3. Using Translations in Components

---
import { useTranslations } from "@i18n/utils";
const t = useTranslations(lang);
---

<h1>{t("main:hero.title")}</h1>
<p>{t("menu.list.home")}</p>
<footer>{t("footer.made", { what: "Astro" })}</footer>

πŸ“ Blog System with linkedContent

1. Linking Content Between Languages

The blog system enables linking posts between different languages. The key question is: how does the system know the user is still on the same post when switching languages?

πŸ”— Solution: Each blog post must have a linkedContent identifier in the frontmatter. This identifier connects posts in different languages that cover the same topic. When users switch languages, the system uses this identifier to find the corresponding post in the target language.

Each blog post must have a linkedContent identifier in the frontmatter:

English post (en/security-trends.md):

---
title: "Top Security Trends for 2025"
linkedContent: "security-trends-2025"
author: "Nik Klemenc"
---

Slovenian post (sl/varnostni-trendi-2025.md):

---
title: "Glavni varnostni trendi za leto 2025"
linkedContent: "security-trends-2025" # Same identifier!
author: "Nik Klemenc"
---

2. Authors with Multilingual Data

The system enables linking authors with blog posts. In this case, a JSON format is used where each author has multilingual data. Under the β€œposition” key, positions are defined in English and Slovenian, which are then dynamically displayed based on the selected language.

{
    "nik-klemenc": {
        "name": "Nik Klemenc",
        "image": "./nik.jpg",
        "position": {
            "en": "Full-stack Developer",
            "sl": "Full-stack razvijalec"
        }
    }
}

πŸ“„ Pagination

The project includes an example of pagination in [pagination]/[...page].astro, implemented using Astro’s default paginate() and adapted for localized URLs.

// Pagination with Astro's paginate()
export const getStaticPaths = async ({ paginate }) => {
    const posts = /* fetch posts and sort per language */ [];
    return paginate(posts, {
        pageSize: 4,
        params: { pagination: "blog-pagination" },
        props: {
            /* lang, authors, totals */
        },
    });
};

πŸ’» Usage Examples

1. Basic Page with Dynamic Routing

---
import { useTranslations, buildLocalizedStaticPaths } from "@i18n/utils";

export function getStaticPaths() {
    return buildLocalizedStaticPaths("/services", ["services", "...index"]);
}

const { lang } = Astro.props;
const t = useTranslations(lang);
---

<Base title={t("services:head.title")}>
    <h1>{t("services:title")}</h1>
</Base>

2. Language Switching

---
// LanguagePicker.astro - actual implementation
import { switchLanguageUrl, getLangFromUrl, useTranslations } from "@i18n/utils";
import { languages } from "@i18n/ui";

// Get current language
const currentLang = getLangFromUrl(Astro.url);
const t = useTranslations(currentLang);

// Prepare URLs for all languages
const languageUrls = await Promise.all(
    Object.entries(languages).map(async ([lang, label]) => {
        const targetUrl = await switchLanguageUrl(Astro.url, lang);
        const translatedLabel = t(`menu.languages.${lang}`);
        return { lang, label: translatedLabel, targetUrl };
    })
);
---

<!-- Dropdown selector -->
<select
    name="language"
    onchange="window.location.href = this.value"
    aria-label={t("menu.languagesText.selectLanguage")}
>
    {languageUrls.map(({ lang, label, targetUrl }) => (
        <option
            value={targetUrl}
            selected={lang === currentLang}
        >
            {label}
        </option>
    ))}
</select>
---
import { useTranslatedPath } from "@i18n/utils";

const translatePath = useTranslatedPath(lang);
---

<a href={translatePath("/about")}>
    {t("menu.list.about")}
</a>

✨ Best Practices

1. File Naming

2. Translation Keys

3. Content Linking

4. SEO Optimization

❓ Frequently Asked Questions

Why not use Astro’s built-in i18n?

Astro doesn’t provide fully localized, per-segment URLs with content-linked slugs and the precise prefix behavior used here (defaultLang/showDefaultLang + route translations). This project implements a small i18n layer (routes.ts, useTranslatedPath, switchLanguageUrl) that guarantees:

How do I add a new language?

  1. Add the language to src/i18n/ui.ts (languages).
  2. Create translation files in src/locales/[lang]/....
  3. Add route mappings in src/i18n/routes.ts for any segments you localize.

No changes to page files are required β€” all getStaticPaths() use useTranslatedPath() and automatically pick up the new language.

How does language switching work for blog posts?

Each post has a linkedContent identifier. switchLanguageUrl() uses this to find the matching post in the target language, translate the base route (blog ↔ spletni-dnevnik), and apply the correct prefix depending on showDefaultLang.

Can I use relative paths?

Use absolute paths with a leading slash (/about, not about). Path translation relies on consistent absolute segments.

🎯 This approach enables fully localized URLs with static generation performance and predictable SEO-friendly paths.