Locale switching updates content but not UI translations (SSR)
The symptom is specific and it points at one thing: your page content changed language, so routing and the locale cookie are working, but the t() strings did not — so the messages your components are reading were never refetched.
That is usually not a cache. It is which locale-switching mode you are in.
The two modes, and why it matters #
useSetLocale() behaves differently depending on whether BetterI18nProvider is above it in the tree:
| What happens on switch | |
|---|---|
With BetterI18nProvider | Cookie is set, new messages are fetched on the client, the tree re-renders — instantly, no server round trip |
| Without it (standalone) | Cookie is set, then router.refresh() or a navigation — the server produces the new strings |
Both work. They fail differently, and the symptom in this article's title is what the second one looks like when the refresh does not actually re-render on the server.
Check first: is BetterI18nProvider wrapping the part of the tree whose strings are stale? If it is not, you are in standalone mode and every locale switch depends on the server producing a fresh render — see below.
If you are in standalone mode #
router.refresh() re-renders on the server, but it cannot get past a cached render. Anything that caches the rendered output will serve the old locale's HTML:
- A statically rendered or ISR page
- Your own CDN in front of Next.js
force-cacheon a fetch in the render path
The fix is to make the page dynamic for the routes that show translated UI, or to bound its revalidation. If a page is cached for an hour, its strings are an hour old — Better i18n has no way to reach into it.
The fastest way to switch modes #
If instant switching is what you want, mount the provider:
// app/layout.tsx
import { BetterI18nProvider } from "@better-i18n/next/client";
export default async function RootLayout({ children }) {
const locale = await getLocale();
const messages = await getMessages();
return (
<BetterI18nProvider
locale={locale}
messages={messages}
config={{ project: "acme/dashboard", defaultLocale: "en" }}
>
{children}
</BetterI18nProvider>
);
}Now useSetLocale() fetches the new language's messages from the CDN in the browser and re-renders. No server, no cache in the way, and the cookie still persists the choice for the next request.
The explicit-choice cookie #
useSetLocale() writes two cookies: the locale, and a marker saying the visitor chose it. The middleware reads that marker so a deliberate choice is not overridden by Accept-Language on the next request.
If your switcher sets the locale cookie by hand instead of calling useSetLocale(), you get the classic version of this bug: the switch works, and one navigation later the browser's language wins and it silently switches back. Use the hook, or write both cookies.
When it really is stale translations #
If the language is right but the wording is old, that is a different problem — the strings are cached somewhere between the CDN and the screen:
curl -s https://cdn.better-i18n.com/your-org/your-project/manifest.jsonNewer than your publish means your app is holding a copy. I published but my app still shows old translations works through the layers.
Checklist #
- Is
BetterI18nProviderabove the stale components? - If not, is the page cached or statically rendered?
- Is your switcher calling
useSetLocale(), or writing cookies itself? - Does the CDN manifest actually have your latest publish?
Better I18N