Headline: A bilingual Next.js site needs three things done right: one URL per language (a
/enand/arpath prefix, not a cookie), a layout that flips through CSS logical properties instead of hand-patched left/right overrides, andhreflangmetadata that tells search engines the two URLs are translations of each other. I learned the first one the hard way — a cookie-driven language switcher meant crawlers only ever indexed the English half of the site.
Key takeaways
-
Serve each language on its own URL. Path prefixes such as
/en/servicesand/ar/servicesare the multilingual structure Google's documentation recommends; content that switches per cookie on a single URL gets exactly one language indexed. -
Search engine crawlers do not persist cookies, and Googlebot crawls mostly from US IP addresses without varying its
Accept-Languageheader — a cookie-based language toggle is invisible to them. -
dir="rtl"on<html>plus CSS logical properties flip a layout automatically. Tailwind'sms-*,me-*,ps-*,pe-*, andtext-startutilities map tomargin-inline-startand friends. -
Load Arabic fonts per locale with
next/font— for exampleIBM_Plex_Sans_Arabicwith thearabicsubset — so English visitors never download the Arabic font and vice versa. -
hreflanglives ingenerateMetadatathroughalternates.languages, must be reciprocal between the two locales, and needs anx-defaultpointing at the fallback URL.
Most of what I build serves users in both English and Arabic. The first bilingual site I shipped used a cookie-driven switcher: one URL, and the server rendered whichever language the cookie asked for. It felt elegant until I checked what search engines had indexed — English only, on every page. These field notes cover the move to URL-prefix routing in the Next.js App Router, treating RTL as a first-class layout mode instead of a patch, and the metadata that makes both languages visible to crawlers and answer engines.
How should I structure locale routing in the Next.js App Router?
Put the locale in the URL as the first path segment: an app/[locale]/ dynamic segment, so every page exists at /en/... and /ar/.... The locale layout reads the param and sets both lang and dir on the <html> element, and generateStaticParams prerenders both language trees.
// app/[locale]/layout.tsx
const locales = ['en', 'ar'] as const;
export function generateStaticParams() {
return locales.map((locale) => ({ locale }));
}
export default async function LocaleLayout({
children,
params,
}: {
children: React.ReactNode;
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
return (
<html lang={locale} dir={locale === 'ar' ? 'rtl' : 'ltr'}>
<body>{children}</body>
</html>
);
}
Middleware handles exactly one job: a request to a bare path such as / or /services gets redirected to its localized twin, negotiated from a saved preference cookie first and the Accept-Language header second. After that redirect, the URL — not the cookie — is the single source of truth for language. Translations themselves are plain JSON dictionaries I await inside server components, so no translation strings ship in the client bundle; next-intl earns its place when you need ICU plurals, rich formatting, and typed message keys, but a two-locale marketing site does not need it on day one.
Why does cookie-based language switching hurt SEO?
Because a crawler is always a first-time visitor with an empty cookie jar: it requests the URL, receives the default language, and indexes that. Your second language never enters the index. Googlebot does not persist cookies between requests, crawls predominantly from US IP addresses, and does not systematically vary its Accept-Language header — Google's own documentation recommends separate URLs per language rather than dynamic content on one URL. Cookie switching also breaks hreflang outright, since hreflang maps languages to URLs and both languages share one. And it breaks humans too: a link copied by an Arabic reader opens in whatever language the recipient's cookie says, not what the sender was looking at.
| Concern | Cookie-based switching | URL-prefix routing |
|---|---|---|
| URL shape | One URL, content varies | One URL per language |
| What crawlers index | Default language only | Both languages |
| hreflang possible | No — nothing to point at | Yes |
| Shared links keep language | No — recipient's cookie wins | Yes |
| CDN caching | Needs Vary: Cookie, poor hit rate |
Plain per-URL caching |
| Switcher implementation | Set cookie + reload | Link to the same path in the other locale |
The migration cost me less than I feared: the switcher became a link to the same pathname under the other prefix, and the cookie survives only to make the bare-URL redirect remember your choice.
How do I make a layout flip for RTL without rewriting the CSS?
Set dir="rtl" on <html> and write every style in CSS logical properties; the browser does the flipping. A physical property such as margin-left stays on the left in both directions, but the logical margin-inline-start means "the side where text starts" — left in English, right in Arabic. In Tailwind that is a mechanical substitution: ml-4 becomes ms-4, pr-6 becomes pe-6, text-left becomes text-start, border-l-2 becomes border-s-2.
// Physical utilities — the sidebar sticks to the wrong side in RTL
<aside className="ml-4 pr-6 text-left border-l-2">
// Logical utilities — flip automatically when dir="rtl"
<aside className="ms-4 pe-6 text-start border-s-2">
Flexbox and grid already follow the document direction, so most layout flips for free. What needs manual attention is a short list. Directional icons — back arrows, chevrons, "next" indicators — must mirror, which rtl:-scale-x-100 handles; logos, checkmarks, and media-playback icons must not. Mixed-direction text is the subtle one: an English product name, an email address, or a code identifier inside an Arabic sentence can drag punctuation to the wrong side, and wrapping the embedded token in <bdi> (or using dir="auto" on user-generated content) isolates it. For numerals, Intl.NumberFormat decides between Western digits and Eastern Arabic digits per locale — pick one convention and apply it everywhere.
How do I load Arabic fonts without shipping them to everyone?
Declare one font per script with next/font and attach only the active locale's font variable in the layout. next/font self-hosts and subsets at build time, so there is no runtime request to Google Fonts and no layout shift from late-loading glyphs.
import { Inter, IBM_Plex_Sans_Arabic } from 'next/font/google';
const inter = Inter({ subsets: ['latin'], variable: '--font-sans' });
const arabic = IBM_Plex_Sans_Arabic({
subsets: ['arabic'],
weight: ['400', '500', '700'],
variable: '--font-sans',
});
// in app/[locale]/layout.tsx
<html lang={locale} dir={dir} className={locale === 'ar' ? arabic.variable : inter.variable}>
Two script-specific notes from shipping this. Arabic has no italic tradition — browsers synthesize a slant that reads as broken, so my Arabic styles never use font-style: italic and lean on weight for emphasis instead. And Arabic glyphs sit taller than Latin ones, so a line-height tuned for English headlines usually needs loosening for the Arabic rendering of the same component.
How do I wire hreflang so both languages get indexed?
Return alternates.languages from generateMetadata on every page, listing the absolute URL of each translation plus an x-default.
// app/[locale]/services/page.tsx
export async function generateMetadata({ params }: Props) {
const { locale } = await params;
const base = 'https://www.devya.dev';
return {
alternates: {
canonical: `${base}/${locale}/services`,
languages: {
en: `${base}/en/services`,
ar: `${base}/ar/services`,
'x-default': `${base}/en/services`,
},
},
};
}
Three rules keep it valid. The annotations must be reciprocal — the English page lists the Arabic URL and the Arabic page lists the English one, and each lists itself. The URLs must be absolute and canonical. And x-default names the page for visitors matching neither language, normally the English URL. Localize the rest of the metadata while you are there: an Arabic page with an English <title> and meta description looks broken in Arabic search results, and answer engines quoting the page inherit the mismatch.
FAQ
Q: Does Googlebot use cookies or Accept-Language to find my Arabic content?
A: No. Googlebot does not persist cookies and generally crawls without varying Accept-Language, mostly from US IPs. Content only reachable through a cookie or header switch stays unindexed — give each language its own URL.
Q: Should Arabic live on a path prefix, a subdomain, or a ccTLD?
A: A path prefix (/ar/) is the cheapest to operate: one deployment, one domain accumulating authority, and hreflang ties the variants together. Subdomains and country TLDs make sense for separate regional businesses, not for a language variant of one site.
Q: Does dir="rtl" break flexbox or grid?
A: No. Flex rows and grid columns follow the document direction automatically. Breakage comes from physical utilities (ml-*, text-left) and absolutely positioned elements pinned with left:/right: — replace them with logical equivalents.
Q: Do I need next-intl for a bilingual site?
A: Not necessarily. An app/[locale] segment plus JSON dictionaries awaited in server components covers a static bilingual site. next-intl pays off when you need ICU plurals, date and number formatting, and typed message keys across a large app.
Q: Which icons should flip in RTL?
A: Only directional ones — back and forward arrows, chevrons, progress indicators. Logos, checkmarks, and media-playback controls keep their orientation.
Originally published on devya.dev. Also on eng-ahmed.com. Built by Devya Solutions.
Top comments (0)