Right, let’s talk about menus. You’ve set up your i18n paths, your locales are neatly organized, and your content is being translated. Fantastic. But now you need a menu that changes its items based on the user’s language. This is where many frameworks’ built-in i18n support suddenly gets a bit… vague. They handle the routes, but they leave the content of those routes up to you. And they’re right to do so. Your menu structure is your business, not the router’s.

The core idea is simple: you need to serve a different array of menu items based on the current locale. But the implementation has a few traps for the unwary. We’re going to build this from the ground up, the right way.

The Data Structure: Your Single Source of Truth

First, do NOT hardcode your menu titles in your layout component. I’ve seen it. It’s a nightmare to maintain. Instead, create a dedicated data structure. I’m a big fan of a simple JavaScript object keyed by locale. This becomes your single source of truth for navigation.

// data/navigation.js
export const menuItems = {
  en: [
    { title: 'Home', path: '/' },
    { title: 'About Us', path: '/about' },
    { title: 'Our Products', path: '/products' },
    { title: 'Contact', path: '/contact' }
  ],
  de: [
    { title: 'Startseite', path: '/' },
    { title: 'Über Uns', path: '/about' },
    { title: 'Unsere Produkte', path: '/products' },
    { title: 'Kontakt', path: '/contact' }
  ],
  fr: [
    { title: 'Accueil', path: '/' },
    { title: 'À Propos', path: '/about' },
    { title: 'Nos Produits', path: '/products' },
    { title: 'Contact', path: '/contact' }
  ]
};

Why is this beautiful? It’s colocated. To add a new menu item, you add it in one file, for all languages, and you’re immediately forced to consider the translations. No hunting through components.

Accessing the Menu in Your Components

Now, in your component (let’s say you’re using Next.js’s app router), you get the current locale from your i18n library or framework, and you fetch the corresponding menu. It’s straightforward.

// components/main-nav.js
'use client'; // If using Next.js and need client-side interactivity

import { useLocale } from 'next-intl'; // Example using next-intl
import { menuItems } from '@/data/navigation';

export default function MainNav() {
  const locale = useLocale();
  const items = menuItems[locale] || menuItems['en']; // Fallback gracefully

  return (
    <nav>
      <ul className="flex space-x-4">
        {items.map((item) => (
          <li key={item.path}>
            <a href={item.path} className="text-sm font-medium hover:text-blue-600">
              {item.title}
            </a>
          </li>
        ))}
      </ul>
    </nav>
  );
}

Handling the Active State

This is the first gotcha. Notice our menuItems structure uses the base path (/about). But your current URL might be /de/about. How do you check if a menu item is “active”? You need to compare the pathname without the locale prefix. You must strip the locale out of the current URL before comparing.

// components/main-nav.js (extended)
import { useLocale } from 'next-intl';
import { usePathname } from 'next/navigation'; // Next.js hook
import { menuItems } from '@/data/navigation';

export default function MainNav() {
  const locale = useLocale();
  const pathname = usePathname(); // This returns e.g., '/de/about'
  const items = menuItems[locale];

  // Function to remove the locale prefix for comparison
  const getPathWithoutLocale = (path) => {
    // This regex matches the locale pattern at the start of the path
    return path.replace(new RegExp(`^/${locale}`), '') || '/';
  };

  const currentPathWithoutLocale = getPathWithoutLocale(pathname);

  return (
    <nav>
      <ul>
        {items.map((item) => {
          const isActive = currentPathWithoutLocale === item.path;
          return (
            <li key={item.path}>
              <a
                href={`/${locale}${item.path}`}
                className={isActive ? 'text-blue-600 font-bold' : 'text-gray-700'}
              >
                {item.title}
              </a>
            </li>
          );
        })}
      </ul>
    </nav>
  );
}

The Parameter Problem: When Paths Aren’t Static

The second, bigger gotcha: what if your menu points to dynamic routes? For example, a “Blog” item might go to /blog, but a “Product” item might need to go to /products/super-widget-3000? You cannot hardcode this. The path for a specific product page will be different in each language.

This is where parameters in your menu data come in. You need to store a unique identifier, not the final path.

// data/navigation.js (updated for dynamic routes)
export const menuItems = {
  en: [
    { title: 'Home', path: '/', type: 'static' },
    { title: 'Featured Product', id: 'super-widget-3000', type: 'product' },
    { title: 'Latest Post', id: '2024-05-20-welcome', type: 'post' }
  ],
  // ... other locales with translated titles
};

Now, your menu component needs to be smarter. It needs a function that can resolve an item’s type and id into an actual path for the current locale.

// components/main-nav.js (handling dynamic routes)
import { generateProductPath, generatePostPath } from '@/lib/path-generators'; // Your custom logic

// ... inside the component ...
const getHrefForItem = (item) => {
  if (item.type === 'static') {
    return `/${locale}${item.path}`;
  }
  if (item.type === 'product') {
    // This function knows how to generate the correct path for a product ID in a given locale
    return generateProductPath(locale, item.id);
  }
  if (item.type === 'post') {
    return generatePostPath(locale, item.id);
  }
  return '/';
};

// Then in the map function:
{items.map((item) => (
  <a key={item.id || item.path} href={getHrefForItem(item)}>
    {item.title}
  </a>
))}

The generateProductPath function would likely fetch or look up the product’s slug for the requested locale. This is more work, but it’s the only way to handle this correctly without losing your mind. The alternative is storing the slug for every single item in every single language in your menu data, which is a maintenance hell I do not recommend.

The takeaway? Keep your menu data structured, separate logic from presentation, and always, always think about how you’ll resolve dynamic paths. It’s the difference between a robust multilingual site and a duct-tape contraption that falls apart the first time you add a new product.