A Next.js Locale Switcher That Doesn't Lose Your Visitor
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.