Next-intl Locale Switch: Preserve Dynamic Route Slugs
A step-by-step Next.js guide to mapping localized slugs, caching with Zustand, and using router.replace for seamless…

⚡ Next.js Implementation Guides
In-depth Next.js guides covering App Router, RSC, ISR, and deployment. Get code examples, optimization checklists, and prompts to accelerate development.
Switching Locales While Preserving Dynamic Route Parameters in next-intl
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:
// ❌ 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.
// File: src/lib/store/translated-slugs-store.ts
import { create } from "zustand";
export type Locale = "sl" | "en" | "ru";
interface TranslatedSlugsState {
slugs: Partial<Record<Locale, string>>;
setSlugs: (slugs: Partial<Record<Locale, string>>) => void;
}
export const useTranslatedSlugsStore = create<TranslatedSlugsState>((set) => ({
slugs: {},
setSlugs: (slugs) => set({ slugs }),
}));
This simple store holds the slug mappings. For a product, you might have:
{
sl: 'eko-pianji-hrbti',
en: 'organic-chicken-backs',
ru: 'ekologichnyy-kuryatyy-spiny'
}
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.
// File: src/components/translated-slugs-setter.tsx
"use client";
import { useEffect } from "react";
import { useTranslatedSlugsStore } from "@/lib/store/translated-slugs-store";
interface TranslatedSlugsSetterProps {
slugs: Partial<Record<"sl" | "en" | "ru", string>>;
}
export function TranslatedSlugsSetter({ slugs }: TranslatedSlugsSetterProps) {
useEffect(() => {
useTranslatedSlugsStore.setState({ slugs });
}, [slugs]);
return null; // This component doesn't render anything
}
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.
// File: src/app/(frontend)/[locale]/izdelki/[slug]/page.tsx
import { TranslatedSlugsSetter } from '@/components/translated-slugs-setter';
import { getPayloadClient } from '@/lib/payloadClient';
import { getLocalizedSlugs } from '@/lib/payload';
type Props = {
params: Promise<{ slug: string; locale: string }>;
};
export default async function ProductPage({ params: paramsPromise }: Props) {
const params = await paramsPromise;
const { slug, locale } = params;
// Fetch your product data
const payload = await getPayloadClient();
const product = await fetchProduct(slug, locale);
// Fetch localized slugs for all locales
const localizedSlugs = await getLocalizedSlugs('products', product.id);
return (
<>
<TranslatedSlugsSetter slugs={localizedSlugs} />
{/* Rest of your page */}
</>
);
}
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.
Here's an example implementation:
// File: src/lib/payload.ts
import { getPayloadClient } from "./payloadClient";
export async function getLocalizedSlugs(
collection: string,
documentId: string | number,
): Promise<Partial<Record<"sl" | "en" | "ru", string>>> {
try {
const payload = await getPayloadClient();
const locales = ["sl", "en", "ru"] as const;
const slugs: Partial<Record<(typeof locales)[number], string>> = {};
for (const locale of locales) {
const result = await payload.findByID({
collection,
id: documentId,
locale,
});
if (result?.slug) {
slugs[locale] =
typeof result.slug === "string" ? result.slug : result.slug[locale];
}
}
return slugs;
} catch (error) {
console.error("Error fetching localized slugs:", error);
return {};
}
}
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.
// 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';
type Locale = 'sl' | 'en' | 'ru';
interface LocaleSwitcherProps {
// Optional: pass current params if needed
currentParams?: Record<string, string>;
}
export function LocaleSwitcher({ currentParams }: LocaleSwitcherProps) {
const router = useRouter();
const locale = useLocale() as Locale;
const pathname = usePathname();
const translatedSlugs = useTranslatedSlugsStore((state) => state.slugs);
const handleLocaleChange = (newLocale: Locale) => {
// If we have a slug mapping and there's a slug in the current path
if (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 locale
const routeMap: 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 (
<div className="locale-switcher">
{(['sl', 'en', 'ru'] as const).map((loc) => (
<button
key={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
So when you call:
router.replace(
{ pathname: "/products/organic-chicken-backs" },
{ locale: "en" },
);
next-intl transforms it to /en/products/organic-chicken-backs (if your routing uses locale prefixes).
Complete Example: Product Page with Language Switching
Here's how all the pieces work together in a real product page:
// File: src/app/(frontend)/[locale]/izdelki/[slug]/page.tsx
import { TranslatedSlugsSetter } from '@/components/translated-slugs-setter';
import { LocaleSwitcher } from '@/components/locale-switcher';
import { getPayloadClient } from '@/lib/payloadClient';
import { getLocalizedSlugs } from '@/lib/payload';
type Props = {
params: Promise<{ slug: string; locale: string }>;
};
export default async function ProductPage({ params: paramsPromise }: Props) {
const params = await paramsPromise;
const { slug, locale } = params;
// Fetch product
const payload = await getPayloadClient();
const product = await payload.find({
collection: 'products',
where: { slug: { equals: slug } },
locale,
});
if (!product.docs.length) {
notFound();
}
const productData = product.docs[0];
// Fetch localized slugs for language switching
const localizedSlugs = await getLocalizedSlugs('products', productData.id);
return (
<>
{/* Store slug mappings in client-side store */}
<TranslatedSlugsSetter slugs={localizedSlugs} />
{/* Navigation with language switcher */}
<header>
<LocaleSwitcher />
</header>
{/* Product content */}
<main>
<h1>{productData.title}</h1>
<p>{productData.description}</p>
</main>
</>
);
}
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.
Thanks, Matija