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.
