How to Set Up Simple Static Site Next.js Localization
Resolving i18n issues in static sites without over‑engineering.
Published on
-/- lines long
If you were to make a static Next.js website with localization, you'd run into these issues:
-
The
i18n
option innext.config.js
can't be used simultaneously withoutput: 'export'
:Error: Specified "i18n" cannot be used with "output: export". See more info here(opens in new tab).
-
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).
-
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.
-
As previously said, you have the inconvenience of having to make duplicate
page.tsx
files for each page to make the default locale work. -
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.
-
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. -
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!