Most internationalization guides for Next.js start from the same idea: one URL per language, /en/about and /fr/about. It is the right approach when search ranking in each language is a priority. For a portfolio, though, I wanted something simpler: an EN/FR toggle, and addresses that never change.
Here is how I built it, and the two pitfalls I ran into.
Why not localStorage?
The quickest solution is to store the language in localStorage and read it from a React context. The problem shows up on the first reload.
The server knows nothing about localStorage. It renders the page in the default language, then the browser reads the preference and switches. The visitor sees the page in English for a split second before it flips to French. Worse: if a component reads localStorage during render, React spots a mismatch between the server HTML and the client HTML and throws a hydration error.
The fix is to store the language somewhere the server can read it: a cookie.
Reading the language on the server
A small helper reads the cookie and falls back to English when it is missing or invalid:
import { cookies } from "next/headers";
export async function getLocale(): Promise<Locale> {
const store = await cookies();
const value = store.get("locale")?.value;
return isLocale(value) ? value : defaultLocale;
}The root layout uses it to set the lang attribute and pass the initial language to the context provider:
export default async function RootLayout({ children }) {
const locale = await getLocale();
return (
<html lang={locale}>
<body>
<LanguageProvider initialLocale={locale}>
{children}
</LanguageProvider>
</body>
</html>
);
}Server and client now start from the same language. No flash, no hydration error.
Switching languages
On the client, switching updates React state and rewrites the cookie for future visits:
const setLocale = (next: Locale) => {
setLocaleState(next);
document.cookie = `locale=${next}; path=/; max-age=31536000; SameSite=Lax`;
document.documentElement.lang = next;
};The content switches instantly, without a reload.
Translations checked by TypeScript
The real risk with a bilingual site is drift: you add a French string and forget the English one. I made the compiler responsible for catching it.
The English dictionary is the reference, and its type is inferred automatically:
const en = {
contact: {
phone: "Phone",
location: "Location",
},
};
export type Dictionary = typeof en;
const fr: Dictionary = {
contact: {
phone: "Téléphone",
location: "Localisation",
},
};If a key is missing from the French version, tsc refuses to compile. The same principle applies to editorial content, stored in two JSON files typed by a shared interface.
The two pitfalls
First: frozen initial state. A component that initialises its state with translated text, such as a terminal's welcome message, keeps that text after a language switch. useState only uses its initial value on mount, so the component has to react to the language change explicitly.
Second: reacting too early. My first fix reset the state in a useEffect that also ran on mount. That immediate setState interrupted the parent component's entrance animation. The solution: remember the language already displayed, and only act when it actually changes.
const renderedLocale = useRef(locale);
useEffect(() => {
if (renderedLocale.current === locale) return;
renderedLocale.current = locale;
resetHistory();
}, [locale]);The trade-off
Reading a cookie makes every page dynamic: pages are rendered on each request instead of being generated ahead of time. For a portfolio, the difference is negligible.
The other limit is SEO. With a single URL per page, search engines only index one language, the default one. If organic traffic in each language becomes important, migrating to prefixed URLs is still possible, and the typed dictionaries can be reused as they are.
