DEV Community

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);

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: '' },
  },
});

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.
  • 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,
  };
});

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 }));
}

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
Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.