# Arabic and English in one Next.js app: routing, RTL and cache QA

A bilingual site can show the right translation and still be wrong. The URL says English, the root element says Arabic, Google sees both pages as duplicates, or a shared cache serves Arabic product data inside an English shell.

The fix is to treat locale as part of the request's identity. It should travel through the route, HTML attributes, metadata, data requests, cache keys and test suite. This guide uses the Next.js App Router with `/en` and `/ar` paths, but the same contract works when you use regional tags such as `ar-AE` or `en-SA`.

## Start with a small locale contract

Do not begin with every Arabic-speaking market as a separate locale. A locale identifies language and formatting preferences; it does not automatically mean that the content, price or legal terms differ.

The [IETF's BCP 47 specification](https://datatracker.ietf.org/doc/html/rfc5646) defines language tags as a language followed by optional script, region and other subtags. Use `ar` when one Arabic version serves all markets. Add a region, such as `ar-AE`, only when the product genuinely needs a UAE-specific variant. The same applies to English.

Keep the supported set explicit:

```ts
// i18n/config.ts
export const locales = ["en", "ar"] as const;
export type Locale = (typeof locales)[number];
export const defaultLocale: Locale = "en";

export function isLocale(value: string): value is Locale {
  return locales.includes(value as Locale);
}
```

This allowlist should drive route validation, dictionaries, metadata, sitemaps and tests. If those surfaces maintain separate locale lists, they will drift.

## Put the locale in the URL

The current [Next.js internationalization guide](https://nextjs.org/docs/app/guides/internationalization) recommends localized subpaths or domains and shows routes nested below `app/[lang]`. Subpaths are usually the simpler choice for one MENA product because every page has a stable, shareable identity:

- `/en/products`
- `/ar/products`

Use the browser's `Accept-Language` header to choose a first destination for an undecorated URL. Do not keep redirecting a user who has chosen another language. Store that choice in a cookie or account setting and let it override later negotiation.

Avoid hand-parsing `Accept-Language` with `startsWith("ar")`. The header can carry several weighted preferences. Next.js points to locale-matching libraries in its example, which is safer than inventing a partial parser.

In Next.js 16, request interception lives in `proxy.ts`. The shortened example below prefers a saved choice and falls back to English; a production version can add standards-aware header matching for first-time visitors.

```ts
// proxy.ts
import { NextRequest, NextResponse } from "next/server";
import { defaultLocale, isLocale } from "./i18n/config";

export function proxy(request: NextRequest) {
  const { pathname } = request.nextUrl;
  const first = pathname.split("/")[1];

  if (isLocale(first)) return NextResponse.next();

  const saved = request.cookies.get("NEXT_LOCALE")?.value;
  const locale = saved && isLocale(saved) ? saved : defaultLocale;

  return NextResponse.redirect(
    new URL(`/${locale}${pathname}`, request.url),
  );
}

export const config = {
  matcher: ["/((?!api|_next|.*\\..*).*)"],
};
```

Exclude internal assets and any non-localized API routes. A redirect loop in front of `/_next` can make the whole application appear broken.

## Set language and direction at the root

Nest the application under `app/[lang]`, validate the segment, and place both `lang` and `dir` on the root `<html>` element.

```tsx
// app/[lang]/layout.tsx
import { notFound } from "next/navigation";
import { isLocale, locales } from "@/i18n/config";

export function generateStaticParams() {
  return locales.map((lang) => ({ lang }));
}

export default async function LocaleLayout({ children, params }) {
  const { lang } = await params;
  if (!isLocale(lang)) notFound();

  return (
    <html lang={lang} dir={lang === "ar" ? "rtl" : "ltr"}>
      <body>{children}</body>
    </html>
  );
}
```

The [W3C guidance on declaring language](https://www.w3.org/International/questions/qa-html-language-declarations) explains that `lang` helps browsers and assistive technology apply the right pronunciation, fonts and language-specific processing. The separate [W3C guidance for text direction](https://www.w3.org/International/questions/qa-html-dir) recommends declaring the document's base direction on `<html>`.

Do not derive direction from a translated string or a client-side effect. That produces an initial left-to-right render followed by a layout flip. It can also leave server-rendered metadata and the DOM disagreeing about the page language.

Use CSS logical properties so the same component follows the document direction:

```css
.card {
  padding-inline: 1rem;
  border-inline-start: 3px solid var(--accent);
  text-align: start;
}

.icon {
  margin-inline-end: 0.5rem;
}
```

Phone numbers, email addresses, order IDs and code can still mix directions inside an Arabic page. Use semantic elements such as `<bdi>` for unknown user-provided text, or `dir="auto"` on a tightly scoped element. Do not apply `direction: rtl` to every descendant and hope the browser repairs mixed content.

For a broader interface checklist, SultanByte's [Arabic web accessibility guide](/arabic-web-accessibility-uae-qatar) covers keyboard order, labels, errors and screen-reader behaviour.

## Load translations on the server

The Next.js guide uses server-loaded dictionaries and notes that App Router layouts and pages are Server Components by default. That keeps full translation files out of the browser bundle when only rendered text is needed.

```ts
// i18n/dictionaries.ts
import "server-only";
import type { Locale } from "./config";

const dictionaries = {
  en: () => import("./en.json").then((m) => m.default),
  ar: () => import("./ar.json").then((m) => m.default),
};

export function getDictionary(locale: Locale) {
  return dictionaries[locale]();
}
```

Pass the small subset needed by an interactive Client Component rather than the whole dictionary. Also keep content and formatting separate. A locale can select copy, while `Intl.NumberFormat` and `Intl.DateTimeFormat` handle currency, dates and numbering systems. The [MENA date and time guide](/mena-date-time-calendars-zones-qa) explains why locale, calendar, time zone and numbering system are separate decisions.

## Make metadata agree with the route

Each localized page needs its own title and description, a canonical URL for that language, and links to its alternates. Next.js exposes these through the [Metadata API and `generateMetadata`](https://nextjs.org/docs/app/api-reference/functions/generate-metadata).

```ts
// app/[lang]/products/page.tsx
import type { Metadata } from "next";

export async function generateMetadata({ params }): Promise<Metadata> {
  const { lang } = await params;

  return {
    title: lang === "ar" ? "المنتجات" : "Products",
    alternates: {
      canonical: `/${lang}/products`,
      languages: {
        en: "/en/products",
        ar: "/ar/products",
        "x-default": "/en/products",
      },
    },
  };
}
```

Set `metadataBase` once in the root layout so relative URLs resolve to the public production origin. Do not let preview domains or deployment URLs become canonical.

[Google's localized-page guidance](https://developers.google.com/search/docs/specialty/international/localized-versions) requires each language version to list itself and every alternate. The references must be reciprocal: if the English page points to Arabic, the Arabic page must point back. `x-default` is the fallback for users whose language does not match a listed version.

Do not canonicalize the Arabic page to English. They are alternates, not duplicates to be collapsed. Keep each localized page self-canonical unless there is a specific reason to remove it from search.

## Partition data and caches by locale

A locale-prefixed pathname separates page output, but your data layer can still leak content across languages. This often happens when a cached function accepts a product ID while reading locale from hidden global state.

Make locale an explicit input and part of the request identity:

```ts
async function getProduct(slug: string, locale: Locale) {
  const response = await fetch(
    `${process.env.API_URL}/products/${slug}?locale=${locale}`,
    {
      next: {
        revalidate: 3600,
        tags: [`product:${slug}:${locale}`],
      },
    },
  );

  if (!response.ok) throw new Error("Product fetch failed");
  return response.json();
}
```

Next.js documents its current cache behaviour in the [caching guide](https://nextjs.org/docs/app/guides/caching). Whatever cache API you choose, the rule is stable: every input that changes the response must change the cache identity. That includes locale, market, currency, authentication scope and sometimes feature flags.

Use locale-specific invalidation tags when editors can update one translation without touching the other. If the Arabic catalogue changes, invalidating `product:chair:ar` should not flush every language by accident. More importantly, it should never leave stale Arabic content under an English cache key.

![Six-stage bilingual Next.js pipeline showing locale routing, validation, HTML language and direction, metadata, cache partitioning and QA](https://cdn.hashnode.com/uploads/covers/60ecf4a0fc37a15ec15655e8/bc018021-8830-4018-ad7d-78aea6e9f8f2.png)

*Bilingual Next.js request-to-release pipeline. Sources: [Next.js internationalization](https://nextjs.org/docs/app/guides/internationalization), [Next.js metadata](https://nextjs.org/docs/app/api-reference/functions/generate-metadata), [Next.js caching](https://nextjs.org/docs/app/guides/caching), [W3C Internationalization](https://www.w3.org/International/questions/qa-html-dir), [IETF RFC 5646](https://datatracker.ietf.org/doc/html/rfc5646) and [Google Search Central](https://developers.google.com/search/docs/specialty/international/localized-versions). Original SultanByte infographic.*

## Test the contract as one system

A translation snapshot will not catch a wrong canonical URL or a warm-cache language leak. Run the same checks for every supported locale.

For `/en/products` and `/ar/products`, verify:

1. the response ends on the expected locale path;
2. `<html lang>` and `<html dir>` match that path;
3. the H1 and critical controls use the expected language;
4. the canonical URL is self-referential;
5. `hreflang` includes English, Arabic and `x-default`, with reciprocal links;
6. structured data, Open Graph fields and sitemaps use the same public URLs;
7. cold and warm requests return the same language;
8. switching the locale preserves the equivalent route where one exists.

Then test layout behaviour at narrow widths. Check long Arabic labels, mixed numbers, validation errors, tables, menus and focus order at a 390 px viewport. Test with real content rather than short placeholder strings. Arabic copy is not guaranteed to occupy the same width as English, and mirrored spacing can expose assumptions hidden by desktop layouts.

Add a cache isolation test that alternates requests instead of running all English cases first:

```txt
/en/products → /ar/products → /en/products → /ar/products
```

Repeat after a translation update and targeted invalidation. This catches cache keys that look correct in a cold test but fail once another locale has populated shared storage.

## One locale, all the way through

A production bilingual application does not need two separate codebases. It needs one explicit locale contract.

Put the locale in a stable URL, validate it at the route boundary, set language and direction during server rendering, generate matching canonical and alternate metadata, and include locale in every data and cache identity. When the test suite checks those pieces together, Arabic and English stop behaving like two loosely connected skins and become two reliable views of the same product.

*Cover and infographic: original SultanByte editorial artwork based on the linked Next.js, W3C, IETF and Google documentation.*

