DEV Community

Nicholas Ma
Nicholas Ma

Posted on

Eight locales and no server: internationalizing a static Next.js site

Last month I rebuilt a small website I run. Static export, eight languages, hosted on Cloudflare Pages — free, fast, and nothing to patch.
The catch: static export and i18n don't fight each other, but they change how you think about a few things. Here's what I ended up with and the parts that weren't obvious at first.

The stack

Next.js 15 with next-intl 4, React 18, all of it prerendered with output: 'export'. No middleware, no server functions. Every page becomes a real HTML file at build time.

// next.config.js
import { withNextIntl } from 'next-intl/plugin';
const nextConfig = {
  output: 'export',
  images: { unoptimized: true },
};
export default withNextIntl(nextConfig);
Enter fullscreen mode Exit fullscreen mode

The images.unoptimized flag is the first hint that static export has opinions. There's no image pipeline when there's no server, so next/image needs to be told to leave files alone.

Locale from the URL, not the request

On a server you'd typically read Accept-Language and pick a locale per request. With static export there's no request to inspect. The locale has to come from the URL itself, and every locale needs to be a separate set of prerendered files.
I defined the routing with one deliberate choice:

// src/i18n/routing.ts
import { defineRouting } from 'next-intl/routing';
export const routing = defineRouting({
  locales: ['en', 'de', 'ja', 'ko', 'es', 'fr', 'pt', 'it'],
  defaultLocale: 'en',
  localePrefix: {
    mode: 'always',
    prefixes: { en: '' },
  },
});
Enter fullscreen mode Exit fullscreen mode

mode: 'always' normally means every URL carries its locale (/de/team, /ja/quiz). The prefixes: { en: '' } line carves out an exception: English gets no prefix, so it sits at /team and /quiz while everything else keeps its language code.
Why bother? Two reasons. English is the primary audience, so clean URLs there are worth a bit of extra config. And it keeps one canonical URL per page instead of two (/team and /en/team) pointing at the same content, which is exactly the kind of thing that makes Google treat your pages as duplicates.

Wiring the locale into prerendering

next-intl normally detects the locale in middleware. With static export, middleware never runs — the host just serves files. So the locale has to be plumbed through the route segment manually:

// src/i18n/request.ts
import { getRequestConfig } from 'next-intl/server';
import { routing } from './routing';
export default getRequestConfig(async ({ requestLocale }) => {
  let locale = await requestLocale;
  if (!locale || !routing.locales.includes(locale)) {
    locale = routing.defaultLocale;
  }
  return {
    locale,
    timeZone: 'UTC',
    messages: (await import(`../messages/${locale}.json`)).default,
  };
});
Enter fullscreen mode Exit fullscreen mode

The requestLocale here comes from the [locale] directory in the app router, not from a header. Messages live as plain JSON per language under src/messages/, loaded statically at build time. No dynamic import at runtime, no bundling surprises.
There's one critical piece that's easy to miss: you have to tell Next.js which locale paths to prerender. That's what generateStaticParams does, in the root layout under app/[locale]/layout.tsx:

// src/app/[locale]/layout.tsx
import { routing } from '@/i18n/routing';
export function generateStaticParams() {
  return routing.locales.map((locale) => ({ locale }));
}
Enter fullscreen mode Exit fullscreen mode

This ensures every language gets its own folder of static HTML files at build time. Miss it, and you only get the default locale.
Then every server component calls setRequestLocale(locale) so the page's metadata and content both know which language they're rendering. Miss that call and you get a build error, which is honestly a feature — it means the locale is never silently wrong.

The SEO-critical part: hreflang

Eight near-identical pages per feature means Google needs help knowing they're translations of each other, not duplicates. That's what hreflang is for, and getting it right was the main reason I put real effort into this.

// src/lib/seo.ts
const DOMAIN = 'https://pokemongen.com';
const LOCALES = ['en', 'de', 'ja', 'ko', 'es', 'fr', 'pt', 'it'];
export function alternates(path: string, locale: string) {
  const cleanPath = path === '/' ? '' : path;
  const canonical =
    locale === 'en' ? `${DOMAIN}${cleanPath}` : `${DOMAIN}/${locale}${cleanPath}`;
  return {
    canonical,
    languages: {
      ...Object.fromEntries(
        LOCALES.map((l) => [l, l === 'en' ? `${DOMAIN}${cleanPath}` : `${DOMAIN}/${l}${cleanPath}`])
      ),
      'x-default': `${DOMAIN}${cleanPath}`,
    },
  };
}
Enter fullscreen mode Exit fullscreen mode

This feeds into Next's alternates metadata field, so every prerendered page ends up with a canonical tag plus an <link rel="alternate" hreflang="..."> for each of the eight languages, and an x-default pointing at the English URL.
x-default is the one people skip. It's what you want a user with no matching language (or a crawler) to land on. I point it at English. If you forget it, Google has to guess, and it guesses wrong often enough that it's worth the extra line.
The other half of the problem is that metadata has to be translated too, not just the page body. Title, description, and OpenGraph tags are all strings in the same JSON files, pulled per locale. Here's a helper that ties it all together:

// src/lib/page-helpers.tsx
import { getTranslations, setRequestLocale } from 'next-intl/server';
import { alternates } from '@/lib/seo';
export function makePageMetadata({ namespace, path }: { namespace: string; path: string }) {
  return async function generateMetadata(locale: string) {
    setRequestLocale(locale);
    const t = await getTranslations({ locale, namespace });
    return {
      title: t('title'),
      description: t('description'),
      alternates: alternates(path, locale),
      openGraph: { title: t('title'), description: t('description') },
    };
  };
}
Enter fullscreen mode Exit fullscreen mode

And here's how you use it in a page:

// src/app/[locale]/page.tsx
import { makePageMetadata } from '@/lib/page-helpers';
export async function generateMetadata({ params }: { params: { locale: string } }) {
  return makePageMetadata({ namespace: 'home', path: '/' })(params.locale);
}
Enter fullscreen mode Exit fullscreen mode

A page whose H1 says "Team Builder" in German but whose title tag still says "Team Builder" in English is a common i18n bug. Keeping both in the same message namespace means they either both translate or neither does.

Trade-offs of static export

The tradeoffs are real, and they were all fine for this project but worth listing:

  • No next/image optimization. Already mentioned — flag off, serve plain PNGs.
  • No runtime redirects or rewrites. If I wanted /pt to redirect somewhere, it'd have to be a Cloudflare Pages rule, not Next config.
  • Every locale is physical files. Eight languages times however many routes means the build emits a lot of HTML. Fast to serve, but a 9th language is a decision, not a one-line config change.
  • Not-found pages need a locale too. A 404 is still a page, so it lives under [locale] and the static export maps the default one to 404.html for the host. The build itself is a small Node script: run next build, then walk the prerendered HTML and lay it out as /<locale>/<page>/index.html the way Cloudflare Pages expects, mapping _not-found.html to 404.html. It's the kind of glue that takes twenty minutes to write and saves you from fighting an adapter later. ## Was it worth it? For a static, content-focused tool site, yes. The pages load instantly, hosting is effectively free, and the hreflang setup means search engines actually surface the right language to the right region instead of ranking eight copies against each other. The honest downside is the translations. Eight JSON files maintained by hand is tedious, and if your content changes weekly this approach will make you hate yourself. It works here because the copy is mostly static. If your site is heavy on server-side features or user accounts, you'd reach for a server and middleware-based locale detection instead — this setup only wins when you can afford to be fully static. You can try the live site at pokemongen.com.

Top comments (0)