Skip to main content

Overview

Astro Portfolio v3 includes built-in internationalization (i18n) support, allowing the site to be available in multiple languages. The system uses Astro’s native i18n configuration combined with a custom translation utility.
The i18n configuration is defined in ~/workspace/source/astro.config.mjs:48-54 and translation utilities are in ~/workspace/source/src/utils/i18n.ts.

Supported Languages

The portfolio supports four languages:

English (en)

Default language - no URL prefix required

Español (es)

Spanish - accessible at /es/

Français (fr)

French - accessible at /fr/

Deutsch (de)

German - accessible at /de/

Configuration

Astro Config

The i18n setup in astro.config.mjs:
// astro.config.mjs:48-54
export default defineConfig({
  i18n: {
    defaultLocale: 'en',
    locales: ['en', 'es', 'fr', 'de'],
    routing: {
      prefixDefaultLocale: false,
    },
  },
});
Key settings:
  • defaultLocale: 'en' - English is the default language
  • prefixDefaultLocale: false - English pages don’t have /en/ prefix
  • All other languages use a prefix (/es/, /fr/, /de/)

Translation Definitions

Translations are defined in src/utils/i18n.ts:
// src/utils/i18n.ts:8-13
export const languages = {
  en: 'English',
  es: 'Español',
  fr: 'Français',
  de: 'Deutsch',
};

export const defaultLang = 'en';

Translation System

Translation Keys

The ui object contains all translations organized by language:
// src/utils/i18n.ts:17-66
export const ui = {
  en: {
    'nav.home': 'Home',
    'nav.about': 'About',
    'nav.projects': 'Projects',
    'nav.contact': 'Contact',
    'hero.title': "Hi, I'm Lewis Kori",
    'hero.subtitle': 'Full-stack Developer & Tech Enthusiast',
    'hero.cta': 'View My Work',
    'about.title': 'About Me',
    'projects.title': 'My Projects',
    'contact.title': 'Get In Touch',
  },
  es: {
    'nav.home': 'Inicio',
    'nav.about': 'Sobre Mí',
    'nav.projects': 'Proyectos',
    'nav.contact': 'Contacto',
    'hero.title': 'Hola, soy Lewis Kori',
    'hero.subtitle': 'Desarrollador Full-stack y Entusiasta de la Tecnología',
    'hero.cta': 'Ver Mi Trabajo',
    'about.title': 'Sobre Mí',
    'projects.title': 'Mis Proyectos',
    'contact.title': 'Contactar',
  },
  fr: {
    'nav.home': 'Accueil',
    'nav.about': 'À Propos',
    'nav.projects': 'Projets',
    'nav.contact': 'Contact',
    'hero.title': 'Bonjour, je suis Lewis Kori',
    'hero.subtitle': 'Développeur Full-stack et Passionné de Technologie',
    'hero.cta': 'Voir Mon Travail',
    'about.title': 'À Propos',
    'projects.title': 'Mes Projets',
    'contact.title': 'Me Contacter',
  },
  de: {
    'nav.home': 'Startseite',
    'nav.about': 'Über Mich',
    'nav.projects': 'Projekte',
    'nav.contact': 'Kontakt',
    'hero.title': 'Hallo, ich bin Lewis Kori',
    'hero.subtitle': 'Full-stack Entwickler und Technik-Enthusiast',
    'hero.cta': 'Meine Arbeit Ansehen',
    'about.title': 'Über Mich',
    'projects.title': 'Meine Projekte',
    'contact.title': 'Kontaktieren',
  },
} as const;

Utility Functions

Getting Current Language

Extract the language from the URL:
// src/utils/i18n.ts:68-72
export function getLangFromUrl(url: URL) {
  const [, lang] = url.pathname.split('/');
  if (lang in ui) return lang as keyof typeof ui;
  return defaultLang;
}
Usage:
---
import { getLangFromUrl } from '@/utils/i18n';

const currentLang = getLangFromUrl(Astro.url);
// currentLang will be 'en', 'es', 'fr', or 'de'
---

Using Translations

Create a translation function for a specific language:
// src/utils/i18n.ts:74-78
export function useTranslations(lang: keyof typeof ui) {
  return function t(key: keyof (typeof ui)[typeof defaultLang]) {
    return ui[lang][key] || ui[defaultLang][key];
  };
}
Usage in components:
---
import { getLangFromUrl, useTranslations } from '@/utils/i18n';

const lang = getLangFromUrl(Astro.url);
const t = useTranslations(lang);
---

<nav>
  <a href="/">{t('nav.home')}</a>
  <a href="/about">{t('nav.about')}</a>
  <a href="/projects">{t('nav.projects')}</a>
  <a href="/contact">{t('nav.contact')}</a>
</nav>

<section>
  <h1>{t('hero.title')}</h1>
  <p>{t('hero.subtitle')}</p>
  <button>{t('hero.cta')}</button>
</section>

Language Switcher Component

The LanguageSwitcher component allows users to change languages:
---
// src/components/shared/LanguageSwitcher.astro:1-7
import { languages, getLangFromUrl } from '@/utils/i18n';

const currentLang = getLangFromUrl(Astro.url);
const currentPath = 
  Astro.url.pathname.replace(`/${currentLang}`, '').replace(/^\//, '') || '/';
---

<div class='relative group'>
  <button
    class='p-2 rounded-md hover:bg-accent transition-colors'
    aria-label='Change language'
  >
    <!-- Globe icon -->
  </button>

  <div class='hidden group-hover:block absolute right-0 mt-2'>
    {Object.entries(languages).map(([lang, name]) => (
      <a
        href={lang === 'en' ? `/${currentPath}` : `/${lang}/${currentPath}`}
        class:list={[
          'block px-4 py-2 hover:bg-accent transition-colors',
          { 'bg-accent': currentLang === lang }
        ]}
      >
        {name}
      </a>
    ))}
  </div>
</div>
Features:
  • Shows current language with highlighting
  • Preserves the current path when switching languages
  • Handles the default locale (no prefix for English)
Location: src/components/shared/LanguageSwitcher.astro:1

URL Structure

Default Language (English)

/ → Homepage
/about → About page
/projects → Projects page
/blog/my-post → Blog post

Other Languages

/es/ → Spanish homepage
/es/about → Spanish about page
/fr/projects → French projects page
/de/blog/my-post → German blog post

Adding New Translations

Step 1: Add Translation Keys

Edit src/utils/i18n.ts and add new keys to all language objects:
export const ui = {
  en: {
    // Existing keys...
    'footer.copyright': '© 2026 All rights reserved',
    'button.learn-more': 'Learn More',
  },
  es: {
    // Existing keys...
    'footer.copyright': '© 2026 Todos los derechos reservados',
    'button.learn-more': 'Aprende Más',
  },
  fr: {
    // Existing keys...
    'footer.copyright': '© 2026 Tous droits réservés',
    'button.learn-more': 'En Savoir Plus',
  },
  de: {
    // Existing keys...
    'footer.copyright': '© 2026 Alle Rechte vorbehalten',
    'button.learn-more': 'Mehr Erfahren',
  },
} as const;

Step 2: Use in Components

---
import { getLangFromUrl, useTranslations } from '@/utils/i18n';

const lang = getLangFromUrl(Astro.url);
const t = useTranslations(lang);
---

<footer>
  <p>{t('footer.copyright')}</p>
  <button>{t('button.learn-more')}</button>
</footer>

Adding a New Language

To add a new language (e.g., Italian):

Step 1: Update Astro Config

// astro.config.mjs
export default defineConfig({
  i18n: {
    defaultLocale: 'en',
    locales: ['en', 'es', 'fr', 'de', 'it'], // Add 'it'
    routing: {
      prefixDefaultLocale: false,
    },
  },
});

Step 2: Add Language to i18n Utilities

// src/utils/i18n.ts
export const languages = {
  en: 'English',
  es: 'Español',
  fr: 'Français',
  de: 'Deutsch',
  it: 'Italiano', // Add Italian
};

export const ui = {
  en: { /* existing translations */ },
  es: { /* existing translations */ },
  fr: { /* existing translations */ },
  de: { /* existing translations */ },
  it: { // Add Italian translations
    'nav.home': 'Home',
    'nav.about': 'Chi Sono',
    'nav.projects': 'Progetti',
    'nav.contact': 'Contatto',
    // ... all other keys
  },
} as const;

Step 3: Test the New Language

Visit /it/ to see the Italian version of your site.

Localized Content

For fully localized content (not just UI translations), you can organize content by language:
src/content/blog/
├── en/
│   ├── post-1.md
│   └── post-2.md
├── es/
│   ├── post-1.md
│   └── post-2.md
└── fr/
    ├── post-1.md
    └── post-2.md

Type Safety

The i18n system is fully type-safe:
import { useTranslations } from '@/utils/i18n';

const t = useTranslations('en');

t('nav.home');      // ✓ Valid key
t('nav.invalid');   // ✗ TypeScript error
t('nav.about');     // ✓ Valid key

Best Practices

  1. Use semantic keys - nav.home instead of home_button
  2. Keep keys organized - Group by component or section
  3. Provide fallbacks - The system falls back to English if a translation is missing
  4. Translate all keys - Ensure all languages have all keys defined
  5. Test all languages - Verify translations in context
  6. Use proper Unicode - Include accented characters correctly
  7. Consider RTL languages - Plan for right-to-left languages if needed
  8. Keep translations short - UI space is limited, especially in mobile views

Advanced Features

Custom Language Detection

Add browser language detection:
export function detectLanguage(): keyof typeof ui {
  if (typeof window !== 'undefined') {
    const browserLang = navigator.language.split('-')[0];
    if (browserLang in ui) {
      return browserLang as keyof typeof ui;
    }
  }
  return defaultLang;
}

Translation Pluralization

For more complex translations with plurals:
export function pluralize(
  count: number,
  singular: string,
  plural: string
): string {
  return count === 1 ? singular : plural;
}

// Usage
const t = useTranslations('en');
const count = 5;
const message = `${count} ${pluralize(count, 'project', 'projects')}`;

Formatted Dates

Localize dates using the Intl API:
---
const lang = getLangFromUrl(Astro.url);
const date = new Date('2026-03-05');

const formattedDate = new Intl.DateTimeFormat(lang, {
  year: 'numeric',
  month: 'long',
  day: 'numeric'
}).format(date);
---

<time datetime={date.toISOString()}>
  {formattedDate}
</time>

Troubleshooting

Issue: Language switcher doesn’t preserve pathSolution: Make sure to remove the language prefix before constructing the new URL:
const currentPath = Astro.url.pathname
  .replace(`/${currentLang}`, '')
  .replace(/^\//, '') || '/';
Issue: Translations showing English fallbackSolution: Verify all translation keys exist in all language objects.Issue: TypeScript errors with translation keysSolution: The ui object uses as const for type inference. Make sure it’s applied.

Components

See how LanguageSwitcher component works

Content Collections

Learn about organizing localized content

Project Structure

Understand where i18n files are located

Build docs developers (and LLMs) love