← All posts

next-intl for Greek sites: five localization gotchas

More than translation: five gotchas from a bilingual Next.js site — hreflang traps, slug normalization, middleware order, date formats, font CLS.

next-intl for Greek sites: five localization gotchas

I built klawsfx.com as a bilingual site — Greek and English — using next-intl. The library is well-documented and the setup works. What the docs don't prepare you for is the cluster of sharp edges that only appear when Greek is your non-default locale and you care about SEO.

Here are the five things that cost me real time.

1. hreflang x-default points at the wrong locale by default

hreflang tells Google which page to show for which region. The x-default tag is the fallback — the page to show when no other hreflang matches the visitor's locale. The sensible default for a Greek/English site is English as x-default.

Here's the trap: if your site only has Greek-language pages for certain routes — say, you have /el/services but no /en/services — your buildAlternates helper will generate an x-default hreflang pointing at the locale with the most coverage. If that's Greek by default, you've told Google to send non-Greek-speaking users to Greek pages. The organic traffic drop from that is real and delayed — it takes weeks before you see it in Search Console.

The fix is explicit: when constructing hreflang tags, check availableLocales per route. If English is available, use it for x-default. If not, fall back to whatever locale the page actually has. Never hardcode defaultLocale as x-default without verifying the route exists in that locale.

function buildAlternates(pathname: string, availableLocales: string[]) {
  const entries = availableLocales.map((locale) => ({
    hrefLang: locale,
    href: `${baseUrl}/${locale}${pathname}`,
  }));

  const xDefaultLocale = availableLocales.includes('en') ? 'en' : availableLocales[0];
  entries.push({
    hrefLang: 'x-default',
    href: `${baseUrl}/${xDefaultLocale}${pathname}`,
  });

  return entries;
}

2. Greek characters in slugs need explicit normalization

Slugs in Greek are either full Greek characters or full Latin transliterations — mixing them is a maintenance problem. Pick one and enforce it everywhere.

I use Latin transliteration for slugs: υπηρεσίεςypiresies. This keeps URLs readable in sharing contexts and avoids percent-encoding in analytics. But auto-slugification from a Greek title doesn't do this automatically — most slug utilities strip diacritics from Latin characters but leave Cyrillic/Greek scripts as-is or drop them entirely, producing empty slugs.

The fix is a transliteration step before slugification:

import { transliterate } from 'transliteration';
import slugify from 'slugify';

export function slugifyGreek(input: string): string {
  const latin = transliterate(input); // υπηρεσίες → ypiresies
  return slugify(latin, { lower: true, strict: true });
}

Without this, a Greek title field in the admin panel will silently produce a broken or empty slug.

3. next-intl middleware and Vercel's edge runtime don't play nicely with locale detection from Accept-Language

next-intl's localeDetection relies on the Accept-Language request header. On Vercel's edge runtime, this mostly works — but when you have a custom middleware that also inspects headers (CSP nonces, auth checks, redirects), the order of middleware execution matters and is easy to get wrong.

The symptom: users land on the wrong locale despite having a clear browser preference. You check the response headers and the Set-Cookie for the locale cookie looks correct, but the page still renders in the wrong language.

The root cause is almost always a middleware that returns early (with a redirect or response) before next-intl's locale middleware has a chance to set its cookie. The fix: ensure next-intl's createMiddleware wrapper runs first, and only then layer your custom logic.

// middleware.ts
import createIntlMiddleware from 'next-intl/middleware';
import { routing } from './i18n/routing';

const intlMiddleware = createIntlMiddleware(routing);

export default function middleware(request: NextRequest) {
  const intlResponse = intlMiddleware(request);
  // Apply your own headers to intlResponse, not a new Response
  intlResponse.headers.set('X-Custom-Header', 'value');
  return intlResponse;
}

4. Date and number formatting in Greek locale

Greece uses DD/MM/YYYY dates, a comma as the decimal separator, and a period as the thousands separator — the opposite of the US convention. next-intl handles this via the Intl API, but only if you actually use useFormatter() and pass the locale correctly.

The bug I hit: I was using new Date().toLocaleDateString() directly in a component, which picks up the server's locale at render time, not the user's locale from next-intl's context. In production on Vercel, the server locale is en-US, so Greek users saw 6/2/2026 instead of 2/6/2026.

// Wrong — uses server locale
const dateStr = new Date(post.date).toLocaleDateString();

// Correct — uses next-intl locale
const format = useFormatter();
const dateStr = format.dateTime(new Date(post.date), { dateStyle: 'long' });

5. Greek fonts and the CLS spike

Loading a Greek-supporting font (Greek subset of Inter, or a dedicated Greek font like GFS Didot) requires explicitly requesting the Greek character subset. Without it, Next.js next/font downloads a Latin-only subset and the browser falls back to the system font for Greek characters, causing a visible flash and a CLS spike.

// app/fonts.ts
import { Inter } from 'next/font/google';

export const inter = Inter({
  subsets: ['latin', 'greek'], // both required
  variable: '--font-inter',
  display: 'swap',
});

display: 'swap' is a trade-off: it prevents invisible text but allows a layout shift if the font loads late. For Greek content-heavy pages, display: 'optional' is worth considering — it uses the fallback font if the custom font doesn't load within a small window, which is better for CLS on slow connections.

One meta-point

Most of the issues above don't appear in development because your dev machine is in your locale, your browser is configured correctly, and your middleware runs against a single request in isolation. They only surface in production, often as gradual traffic drops rather than hard errors. Building a staging environment that simulates different Accept-Language headers is worth the setup time.