Skip to content

A Next.js Locale Switcher That Doesn't Lose Your Visitor

Adityo Guni Waluyo

Build a Next.js App Router locale switcher that preserves the current path with usePathname and a regex swap, plus an accessible active state.

TL;DR

A naive locale switcher sent users to the locale homepage instead of the same page. The fix: a small client component that swaps the /en or /id prefix while preserving the rest of the pathname, plus aria-current for the active state. Stick with this for simple sites; adopt next-intl once routing grows complex.

On the first deploy of a company profile site, I opened a browser tab and clicked the "ID" button in the header while on the /en/about page. The expectation was clear: switch to /id/about. The reality? I landed on /id, aka the homepage. Visitors immediately lost their place. My initial locale switcher was very naive, just linking to the locale root without considering the path behind it.

At first, I thought this was a Next.js routing configuration or middleware issue that wasn't correctly catching the prefix. I tried tinkering with middleware.ts, adding complex regex for forced redirects. The result was nil, or it even caused a dizzying redirect loop.

Wrong guess at the middleware

Turns out, the problem wasn't in the middleware. In Next.js App Router, i18n with sub-path routing means special files are nested under app/[lang], and the lang parameter is forwarded to every layout and page [1]. So, there's only one key: just swap the locale prefix, and leave the rest of the pathname intact.

Since usePathname is a client hook and Server Components cannot read the URL directly [2], I built a small client component to handle this. The logic is simple: take the current pathname, then replace the /en or /id prefix with the target locale.

function swapLocale(pathname, next) {
  return '/' + next + pathname.replace(/^\/(?:id|en)(?=\\/|$)/, '');
}

This function checks if the pathname starts with /id or /en followed by a slash or the end of the string, then replaces it. If the pathname is /en/about, called with next = 'id', the result is instantly /id/about. Clean and predictable.

A feature often forgotten is the accessible active state. Using aria-current is important because it marks the currently active item in a set, one per set. Its value can be page, step, true, or false. If absent or false, it means it's not exposed to screen readers [3].

I use a simple pattern: compare the pathname from the hook with the href of the menu item. If they match, I inject aria-current="page".

const active = pathname === item.href;

return (
  <Link
    href={item.href}
    aria-current={active ? 'page' : undefined}
    className={active ? 'bg-accent' : 'bg-transparent'}
  >
    {item.label}
  </Link>
);

For the locale toggle, which is a different kind of set, I use aria-current="true" for the currently active locale.

When to switch libraries

When should this manual approach be abandoned? Honestly, for a simple two-locale site, I stick with this regex approach. There's no need to add a dependency that bloats the bundle.

But if the project starts needing features like complex nested routing, or structured translation message management per component, it's time to use a library. next-intl has createNavigation which wraps Link, redirect, usePathname, useRouter, and getPathname with automatic locale handling [4]. The locale prop in its Link component also automatically sets hreflang. Their documentation even suggests pairing useSelectedLayoutSegment with aria-current page in the active-link example, which aligns with the pattern I built manually.

Sometimes the best solution isn't the most sophisticated one, but the one that best fits the scale of the problem.

Related articles