---
title: "Next-intl Locale Switch: Preserve Dynamic Route Slugs"
slug: "preserve-dynamic-route-slugs-next-intl"
published: "2026-01-31"
updated: "2026-02-22"
categories:
  - "Next.js"
tags:
  - "next-intl locale switch"
  - "preserve dynamic routes"
  - "localized slugs"
  - "router.replace locale"
  - "Zustand slug store"
  - "getLocalizedSlugs"
  - "Next.js i18n routing"
  - "language switcher Next.js"
  - "dynamic route parameters"
  - "slug mapping store"
llm-intent: "how-to"
audience-level: "intermediate"
llm-purpose: "next-intl locale switch: preserve dynamic route parameters by mapping localized slugs, caching with Zustand, and using router.replace—step-by-step guide."
llm-prereqs:
  - "Next.js"
  - "next-intl"
  - "Zustand"
  - "TypeScript"
  - "Payload CMS"
  - "React"
---

**Summary Triples**
- (Next-intl Locale Switch: Preserve Dynamic Route Slugs, expresses-intent, how-to)
- (Next-intl Locale Switch: Preserve Dynamic Route Slugs, covers-topic, next-intl locale switch)
- (Next-intl Locale Switch: Preserve Dynamic Route Slugs, provides-guidance-for, next-intl locale switch: preserve dynamic route parameters by mapping localized slugs, caching with Zustand, and using router.replace—step-by-step guide.)

### {GOAL}
next-intl locale switch: preserve dynamic route parameters by mapping localized slugs, caching with Zustand, and using router.replace—step-by-step guide.

### {PREREQS}
- Next.js
- next-intl
- Zustand
- TypeScript
- Payload CMS
- React

### {STEPS}
1. Understand the locale switch problem
2. Create a slug mapping store
3. Populate the store from server
4. Fetch localized slugs in the page
5. Implement locale switcher using router.replace
6. Map route names across locales
7. Test fallback and edge cases

<!-- llm:goal="next-intl locale switch: preserve dynamic route parameters by mapping localized slugs, caching with Zustand, and using router.replace—step-by-step guide." -->
<!-- llm:prereq="Next.js" -->
<!-- llm:prereq="next-intl" -->
<!-- llm:prereq="Zustand" -->
<!-- llm:prereq="TypeScript" -->
<!-- llm:prereq="Payload CMS" -->
<!-- llm:prereq="React" -->

# Next-intl Locale Switch: Preserve Dynamic Route Slugs
> next-intl locale switch: preserve dynamic route parameters by mapping localized slugs, caching with Zustand, and using router.replace—step-by-step guide.
Matija Žiberna · 2026-01-31

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.

```typescript
// 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:

```typescript
{
  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.

```typescript
// 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`.

```typescript
// 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:

```typescript
// 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.

```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';

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:

1. **pathname object**: Contains the actual path without locale prefix (e.g., `/products/organic-chicken-backs`)
2. **locale option**: Tells the i18n router which locale to apply
3. **Automatic prefix**: next-intl automatically adds the locale prefix based on your routing configuration

So when you call:

```typescript
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:

```typescript
// 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