In-depth Next.js guides covering App Router, RSC, ISR, and deployment. Get code examples, optimization checklists, and prompts to accelerate development.
I was building a multi-language e-commerce site with Next.js and next-intl when I hit a frustrating problem. When users switched languages on a product page like /sl/izdelki/eko-pianji-hrbti, the language switcher would drop them at /en/products instead of /en/products/organic-chicken-backs (the English slug for the same product). After digging through the next-intl source code and testing various approaches, I discovered the solution involves mapping localized slugs and using the i18n router correctly. This guide shows you the exact pattern to preserve dynamic segments when switching locales.
The Core Problem
When you have a route like /[locale]/products/[slug], switching languages creates a challenge. The [slug] parameter is a dynamic segment that differs per locale. You can't simply change the locale prefix and keep the same slug—you need to look up the translated slug for the new locale.
The naive approach of just changing the locale prefix breaks the page:
typescript
// ❌ WRONG: Drops the slug or uses wrong slug
router.push(`/en/products/${slug}`); // slug might not exist in English
You need a way to map slugs across locales, store those mappings, and use them when switching languages.
The Solution: Slug Mapping Store + Router.replace
The solution requires three parts: a store to hold slug mappings per locale, a hook to populate that store from the server, and the router logic to use those mapped slugs when switching.
Step 1: Create a Slug Mapping Store
First, create a Zustand store to hold translated slugs for each locale. This acts as a cache so you don't need to fetch translations on every language switch.
Step 2: Create a Server Component to Populate the Store
Create a client component that gets the localized slugs from the server and populates the store. This should run on every page where you need slug switching.
This component receives the slug mappings as props from the server and stores them in Zustand. The store persists across route changes until the component updates it with new data.
Step 3: Pass Slug Data from Server Page
In your page component, fetch the localized slugs and pass them to the TranslatedSlugsSetter.
The getLocalizedSlugs function queries your CMS/database for the same product's slug in all available locales. For this to work, your products need a unique identifier (like an ID) that stays consistent across locales.
This function loops through each locale and fetches the document's slug for that locale. The result is an object mapping locales to their slugs.
Step 4: Create the Locale Switcher with Slug Mapping
Now create the locale switcher component that uses the stored slugs when changing languages.
typescript
// File: src/components/locale-switcher.tsx'use client';
import { useRouter, usePathname } from'@/i18n/routing';
import { useLocale } from'next-intl';
import { useTranslatedSlugsStore } from'@/lib/store/translated-slugs-store';
typeLocale = 'sl' | 'en' | 'ru';
interfaceLocaleSwitcherProps {
// Optional: pass current params if neededcurrentParams?: Record<string, string>;
}
exportfunctionLocaleSwitcher({ currentParams }: LocaleSwitcherProps) {
const router = useRouter();
const locale = useLocale() asLocale;
const pathname = usePathname();
const translatedSlugs = useTranslatedSlugsStore((state) => state.slugs);
consthandleLocaleChange = (newLocale: Locale) => {
// If we have a slug mapping and there's a slug in the current pathif (translatedSlugs[newLocale] && pathname.includes('/')) {
const pathParts = pathname.split('/').filter(Boolean);
// Find if there's a slug parameter (usually last segment)const hasSlugParam = pathParts.length > 1;
if (hasSlugParam && translatedSlugs[newLocale]) {
// Reconstruct path with new locale and translated slug// For /sl/izdelki/eko-pianji-hrbti -> /en/products/organic-chicken-backs// Get the route structure (e.g., 'izdelki' or 'products')const routeSegment = pathParts[1]; // 'izdelki', 'products', etc.// Map route names to ensure they're correct for the new localeconstrouteMap: Record<string, Record<Locale, string>> = {
'izdelki': { sl: 'izdelki', en: 'products', ru: 'produkty' },
'products': { sl: 'izdelki', en: 'products', ru: 'produkty' },
'produkty': { sl: 'izdelki', en: 'products', ru: 'produkty' },
// Add more route mappings as needed
};
const newRouteSegment = routeMap[routeSegment]?.[newLocale] || routeSegment;
const newPathname = `/${newRouteSegment}/${translatedSlugs[newLocale]}`;
// Use router.replace with pathname object for i18n support
router.replace(
{ pathname: newPathname },
{ locale: newLocale }
);
return;
}
}
// Fallback: just switch locale if no slug mapping available
router.replace(
{ pathname },
{ locale: newLocale }
);
};
return (
<divclassName="locale-switcher">
{(['sl', 'en', 'ru'] as const).map((loc) => (
<buttonkey={loc}onClick={() => handleLocaleChange(loc)}
disabled={locale === loc}
className={locale === loc ? 'active' : ''}
>
{loc.toUpperCase()}
</button>
))}
</div>
);
}
The key insight here is that router.replace() accepts a pathname object with an options object containing the locale. The i18n router will automatically apply the locale prefix, so you only provide the base path without the locale prefix. You pass the new locale in the options, and next-intl handles adding the correct prefix for that locale.
Understanding the Router.replace Pattern
The pattern router.replace({ pathname }, { locale }) works because:
pathname object: Contains the actual path without locale prefix (e.g., /products/organic-chicken-backs)
locale option: Tells the i18n router which locale to apply
Automatic prefix: next-intl automatically adds the locale prefix based on your routing configuration
Now when a user is on /sl/izdelki/eko-pianji-hrbti and clicks "EN", they're taken to /en/products/organic-chicken-backs—the correct English version with the translated slug.
Key Concepts to Understand
The pattern works because of a few important concepts:
Store-based caching: By storing slug mappings in Zustand, you avoid expensive lookups on every language switch. The store persists across client-side route changes.
Server-side slug fetching: You fetch all localized slugs on the server once per page load, then pass them to the client. This keeps the logic centralized and the data fresh.
Router API flexibility: The i18n router's replace() method accepts both pathname objects and locale options, giving you control over both the path and the language without hardcoding locale prefixes.
Route mapping: Products might be at /izdelki/[slug] in Slovenian but /products/[slug] in English. Maintaining a mapping of route names across locales ensures the URL structure is correct for each language.
Wrapping Up
Switching languages while preserving dynamic route parameters in next-intl requires three components working together: a slug mapping store, server-side slug fetching, and a locale switcher that uses the router correctly. By following this pattern, your users can switch between languages without losing their place in the app. The key is separating concerns—the server fetches and maps data, the client stores it, and the router applies it when needed.
Let me know in the comments if you have questions about implementing this pattern, and subscribe for more practical development guides.