Skip to Content

How to Set Up Simple Static Site Next.js Localization

Resolving i18n issues in static sites without over‑engineering.

Published on

Website localized URLs surrounded by random characters and a white triangle.

If you were to make a static Next.js website with localization, you'd run into these issues:

  1. The i18n option in next.config.js can't be used simultaneously with output: 'export':

    Error: Specified "i18n" cannot be used with "output: export". See more info here(opens in new tab).

  2. It's difficult to have your default locale served at the base URL, i.e. at / instead of at /en, for example.

    There are suggestions on Stack Overflow(opens in new tab) and in the next-intl package docs(opens in new tab) to use middleware, but middleware is not supported for static exports(opens in new tab).

  3. There's no way to set up a dynamic HTML lang attribute, as discussed on Github(opens in new tab), which is important for SEO.

    You can use a dynamic route(opens in new tab) but that would require putting all your pages under a single app/[locale]/layout.tsx which means the default route would always get prefixed, and that is not what we want.

Well, I'm going to show you how to set up a dead simple static site with localization that avoids all the issues mentioned above and doesn't even use external packages.

TLDR: The full code is in my repo nextjs-simple-localization(opens in new tab).

Configuration

We can start by adding our translations in JSON files under a dictionaries folder. Here's how dictionaries/en.json would look like:

{
	"home": {
		"heading": "Hello World!",
		"subheading": "This is the English version."
	},
	"about": {
		"heading": "My name is Hristiyan!",
		"subheading": "Find me at my website dodov.dev."
	}
}

Other translations would have the exact same structure, e.g. dictionaries/es.json:

{
	"home": {
		"heading": "¡Hola Mundo!",
		"subheading": "Esta es la versión en español."
	},
	"about": {
		"heading": "¡Me llamo Hristiyan!",
		"subheading": "Encuéntrame en mi sitio web dodov.dev."
	}
}

We can then define a config.ts file for using throughout the app:

import en from "./dictionaries/en.json";
import es from "./dictionaries/es.json";

export const translations = { en, es } as const;
export type Locale = keyof typeof translations;
export const locales = Object.keys(translations) as Locale[];
export const defaultLocale: Locale = "en" as const;

Routing

We'll go with the standard approach of having app/[locale]/layout.tsx and set up generateStaticParams(opens in new tab) to create a page for each non-default locale:

import { defaultLocale, locales } from "@/config";

export type Props = { params: { locale?: string } };

export function generateStaticParams(): Props["params"][] {
	return locales
		.filter((locale) => locale !== defaultLocale)
		.map((locale) => ({ locale: locale }));
}

We also specify app/[locale]/page.tsx for our main content:

import { Metadata } from "next";

export function generateMetadata(): Metadata {
	return {
		/* ... */
	};
}

export default function Home() {
	return <main>...</main>;
}

And what about the default locale which must be out of the dynamic route? Well, we just create app/page.tsx that exports the main page:

export {
	generateMetadata,
	default as default,
} from "./[locale]/page";

The downside here is that we'd have to do this for each page:

// app/[locale]/about/page.tsx
export default function About() {
	return <main>...</main>;
}

// app/about/page.tsx
export { default as default } from "../[locale]/about/page";

…but I think that it's a fair trade-off for small sites.

Lang attribute

To specify <html lang="...">, we have to use a root layout(opens in new tab). But how do we retrieve the locale prop? The dynamic [locale] route is rendered as a child of this root layout and its props can go only down the component tree, not up. Well, we actually can do this, just not in a React way.

We add a global state variable for the locale in config.ts:

export const state = { locale: defaultLocale };

…then we update this variable in app/[locale]/page.tsx:

import { state } from "@/config";

export default function Home({ params: { locale } }) {
	state.locale = locale;
	return <main>...</main>;
}

…and we use it in the root layout app/layout.tsx:

import { state } from "@/config";

export default function RootLayout({ children }) {
	return (
		<html lang={state.locale}>
			<body>{children}</body>
		</html>
	);
}

Here we rely on the fact that React renders the child component before the parent one, so the state variable is updated before getting used. I know this is a terrible design pattern, but remember — this is a static site. What matters is if the statically generated HTML for each page has the correct lang attribute. And it does.

Caveats

There are a few minor quirks to this, but they don't affect behavior in production.

  1. As previously said, you have the inconvenience of having to make duplicate page.tsx files for each page to make the default locale work.

  2. In dev mode, the lang attribute doesn't update on client-side navigations. You have to reload the page.

    What's weird is that it does work in production, but only sometimes, perhaps due to some caching. However, that probably isn't an issue since it's always correct on page load, and that's what's important for SEO.

  3. Because of the previous point, you might not be able to add components in app/[locale]/layout.tsx that depend on the locale value because it's not always updated on the client. It's fine if you just need it in the static HTML, though.

  4. In dev mode, navigating to a missing route, e.g. /404, will result in the following error, as reported on GitHub(opens in new tab):

    Error: Page "/[locale]/page" is missing param "/404" in "generateStaticParams()", which is required with "output: export" config.

    The same will happen for sitemap.ts(opens in new tab), if you have one:

    Error: Page "/sitemap.xml/[[...__metadata_id__]]/route" is missing exported function "generateStaticParams()", which is required with "output: export" config.

    However, your 404 page and sitemap will be built and then served successfully in production, so that's just an inconvenience.

Conclusion

Currently, it can get unnecessarily complex to set up a simple static Next.js site. However, you can get it working with a few small workarounds. Check my repo nextjs-simple-localization(opens in new tab) for the full example and feel free to clone it and do as you please!