Skip to main content

Internationalization

Multi-language support using next-intl — when to enable it, what's auto-translated, what isn't, and the SEO traps to avoid.

Why this matters

Internationalization is the rare directory feature that's free to ship in code but expensive to maintain — every translated string is a long-term commitment. Most directories should ship in one language, validate the niche, and only add locales after demand is proven. But when you do enable i18n, the wiring matters: locale-prefixed URLs change your sitemap, hreflang tags affect indexing, and middleware routing has to play nicely with auth refreshes.

This page covers the toggles, the components, and the SEO implications. Read Common pitfalls before flipping i18n: true — most of the issues we see are subtle and only show up after content is live.

Off by default. When on, the site supports multiple languages with URL prefixes like /en/, /ru/, /es/.

Don't enable until you have translations

Turning i18n on with only en.json gives you every downside (extra routing, prefixed URLs) with zero benefit. Only flip it on when you have at least one real translation ready.

How it works

  • Translations live in messages/{locale}.json
  • Middleware (middleware.ts) detects the user's language and routes accordingly
  • Components use the useTranslations() hook to pull strings
  • A language switcher in the header lets users override detection

Setup

Two toggles, both required:

config/features.config.ts
export const featuresConfig = { /* ... */ i18n: true };
config/i18n.config.ts
export const i18nConfig = {
  enabled: true,
  defaultLocale: 'en',
  locales: ['en', 'ru', 'es'],
  localeCookie: 'NEXT_LOCALE',
  localeDetection: true,
};

Then create translation files:

messages/
├── en.json
├── ru.json
└── es.json

Using translations in components

Server components

import { getTranslations } from 'next-intl/server';
 
export default async function Page() {
  const t = await getTranslations('homepage');
  return <h1>{t('title')}</h1>;
}

Client components

'use client';
import { useTranslations } from 'next-intl';
 
export function Hero() {
  const t = useTranslations('homepage');
  return <h1>{t('title')}</h1>;
}

Always use next-intl's Link, not Next's — it auto-prefixes with the current locale:

import { Link } from '@/i18n/navigation';  // or next-intl equivalent
 
<Link href="/pricing">Pricing</Link>
// renders as /pricing for EN, /ru/pricing for RU, etc.

What's not auto-translated

  • User-generated content (project names, descriptions, comments) — stays in whatever language the user typed
  • Blog posts — write separate MDX files per locale, or use a translation service
  • Legal pages — translate the strings in messages/, the content lives there

A real-world walkthrough: adding Spanish to a live English directory

You've shipped your directory in English, validated the niche, and want to add Spanish. The 30-minute path:

  1. Translate messages/en.json to messages/es.json (DeepL handles ~85% of strings well; review the rest manually for tone).
  2. Set i18nConfig.locales = ['en', 'es'] and i18nConfig.enabled = true. Keep defaultLocale: 'en' so your existing English URLs (/, /categories, /project/foo) continue to resolve.
  3. Build and deploy. Your sitemap will now contain /es/-prefixed URLs alongside the unchanged English ones. Existing English URLs and SEO signals stay intact.
  4. Add hreflang annotations in your root layout via next-intl's helpers — this tells Google "the Spanish version of /foo lives at /es/foo" so it doesn't dilute either page's authority.
  5. Don't auto-translate user-generated content. Project titles, descriptions and comments stay in whatever language the submitter wrote them. The directory shell translates; the content does not.

If you instead use localePrefix: 'always', all URLs (English included) gain a /en/ prefix — that's a one-time SEO migration with 301 redirects required. Plan accordingly.

Common pitfalls

  • Enabling i18n with only one translation file. You get all the routing complexity (URL prefixes, middleware overhead, hreflang management) and zero benefit. Wait until you have a complete translation ready.
  • Forgetting to handle locale in dynamic routes. A /project/[slug] page that hardcodes English copy in the React component will render English even on /es/project/foo. Run a final pass: every component using user-facing strings must use useTranslations() or getTranslations().
  • Letting middleware step on the OAuth callback. With i18n on, middleware tries to detect locale on every request — including /auth/callback. Add explicit middleware skips for auth callback and webhook routes or you'll see flaky sign-in flows.
  • Translating slugs. Don't. /categories/saas and /es/categories/saas should share the same slug; only the rendered page text should differ. Translating slugs creates a SEO-unfriendly two-tree URL graph and breaks every existing link.
  • Mixing Link from Next.js with Link from next-intl. The Next.js Link skips locale prefixing and you'll randomly drop users back to the default locale. Use the next-intl Link everywhere — set up an ESLint rule if you want to enforce it.

FAQ

Should I serve translated blog posts from a single MDX file or one per locale?

One per locale. Blog posts are long-form content and machine translation rarely produces SEO-acceptable output. Create content/blog/foo.mdx and content/blog/foo.es.mdx, then route each from a locale-aware loader. The index pages can show translated titles via the same messages/ system if needed.

How do I detect a user's preferred language?

Middleware does it automatically — it reads the Accept-Language header on the first request, sets a NEXT_LOCALE cookie, and uses that for subsequent requests. Users override via the language switcher. If you need explicit control, set localeDetection: false in config and route everyone to your default locale.

Will i18n hurt my SEO?

If done correctly, no — Google handles locale-prefixed sites natively when hreflang is set. If done incorrectly (translated slugs, missing hreflang, leaking English content into translated pages), it can split authority across duplicate URLs and slow indexing. Test with site:yourdomain.com inurl:/es/ after launch to verify Google picked up the translated tree.

See also