WhatsAppCall MeLinkedInGitHub
Sep 5, 20268 min readDevelopment

Bilingual English/Arabic Next.js Apps With RTL: A Practical Guide (next-intl + Tailwind)

Author

Marwan Ayman

مطوّر فل-ستاك ومهندس أتمتة

Bilingual English/Arabic Next.js Apps With RTL: A Practical Guide (next-intl + Tailwind)

Short answer: a bilingual English/Arabic Next.js app needs five things done once and then left alone: locale routing with next-intl (no prefix for English, /ar for Arabic), a dir attribute on the html element, Tailwind's logical utilities instead of left and right, an Arabic font loaded through next/font, and hreflang alternates in both the metadata and the sitemap. Everything else is content and the dozen RTL bugs listed at the end.

This is the setup behind this site and the SouqSoft marketplace, a bilingual Arabic/English product on Next.js 16 with right-to-left support throughout. Code is trimmed to what matters.

How should locale routing work for Arabic and English?

Short answer: use next-intl with localePrefix: 'as-needed', so English stays at /pricing and Arabic lives at /ar/pricing. Existing English URLs keep their rankings, and every Arabic page gets its own crawlable address.

// src/i18n/routing.ts
import { defineRouting } from 'next-intl/routing';

export const routing = defineRouting({
  locales: ['en', 'ar'],
  defaultLocale: 'en',
  localePrefix: 'as-needed', // /pricing (en) and /ar/pricing (ar)
});
// src/i18n/request.ts
import { getRequestConfig } from 'next-intl/server';
import { hasLocale } from 'next-intl';
import { routing } from './routing';

export default getRequestConfig(async ({ requestLocale }) => {
  const requested = await requestLocale;
  const locale = hasLocale(routing.locales, requested) ? requested : routing.defaultLocale;
  return {
    locale,
    messages: (await import(`../../messages/${locale}.json`)).default,
  };
});
// src/middleware.ts
import createMiddleware from 'next-intl/middleware';
import { routing } from './i18n/routing';

export default createMiddleware(routing);

export const config = {
  // Skip API routes, Next internals and any path with a file extension
  matcher: ['/((?!api|_next|.*\\..*).*)'],
};

One gotcha from that matcher: any route you add outside the [locale] folder must contain a dot in its path (for example /feed.xml or /blog-cover/slug.png), otherwise the middleware rewrites it to /en/... and it 404s.

Where does the direction switch happen?

Short answer: in the root layout, on the html element. Set lang and dir from the locale once, and the browser handles text direction, list bullets, scrollbars and form controls for the whole document.

// src/app/[locale]/layout.tsx
import { NextIntlClientProvider, hasLocale } from 'next-intl';
import { getMessages, setRequestLocale } from 'next-intl/server';
import { notFound } from 'next/navigation';
import { routing } from '@/i18n/routing';

export default async function LocaleLayout({ children, params }) {
  const { locale } = await params;
  if (!hasLocale(routing.locales, locale)) notFound();
  setRequestLocale(locale);
  const messages = await getMessages();

  return (
    <html lang={locale} dir={locale === 'ar' ? 'rtl' : 'ltr'}>
      <body>
        <NextIntlClientProvider locale={locale} messages={messages}>
          {children}
        </NextIntlClientProvider>
      </body>
    </html>
  );
}

How do you write Tailwind that works in both directions?

Short answer: replace every physical direction class with its logical equivalent, and reserve the rtl: variant for the few things that must visually flip. Tailwind has supported logical utilities since version 3.3, so there is no plugin to install.

Physical (breaks in RTL)Logical (works in both)Notes
ml-4 / mr-4ms-4 / me-4margin-inline-start / end
pl-6 / pr-6ps-6 / pe-6padding-inline
left-0 / right-0start-0 / end-0inset-inline
text-left / text-righttext-start / text-endAlso fixes table headers
rounded-l-lgrounded-s-lgBorder radius per side
border-l-4border-s-4Blockquote and callout bars
space-x-4flex gap-4space-x needs rtl:space-x-reverse; gap needs nothing
translate-x-2 on a chevronrtl:-translate-x-2Motion direction must flip explicitly
Arrow iconrtl:-scale-x-100 or rtl:rotate-180Only directional icons flip; logos and clocks never do

Run a one-off search for ml-, mr-, pl-, pr-, left-, right-, text-left and text-right across the codebase before the first Arabic review. Fixing them in bulk takes an hour; finding them one by one in QA takes a week.

Which fonts and typography rules apply to Arabic?

Short answer: load one Arabic family with Latin glyphs through next/font, give Arabic text slightly more line height, and never apply letter-spacing to it. Cairo, IBM Plex Sans Arabic, Tajawal and Noto Sans Arabic are the safe choices.

import { Inter, IBM_Plex_Sans_Arabic } from 'next/font/google';

const inter = Inter({ subsets: ['latin'], variable: '--font-latin' });
const plexArabic = IBM_Plex_Sans_Arabic({
  subsets: ['arabic'],
  weight: ['400', '500', '600', '700'],
  variable: '--font-arabic',
});

// In the layout: className={`${inter.variable} ${plexArabic.variable}`}
// In CSS:
// html[dir='rtl'] body { font-family: var(--font-arabic), system-ui, sans-serif; line-height: 1.75; letter-spacing: 0; }

Gradient-text headings, uppercase transforms and tight tracking are common in English design systems and all three look wrong in Arabic. Uppercase does nothing, tracking breaks the connected letters, and gradients on thin Arabic strokes lose contrast. Keep those effects behind an ltr: variant or drop them.

How do you format numbers, dates and currency per locale?

Short answer: use Intl through next-intl's useFormatter, and decide deliberately whether Arabic pages show Western or Eastern Arabic digits. Most Egyptian and Gulf e-commerce shows Western digits for prices and phone numbers, which the locale tag ar-u-nu-latn gives you.

import { useFormatter, useLocale } from 'next-intl';

export function Price({ amount, currency }: { amount: number; currency: string }) {
  const format = useFormatter();
  return <span dir="ltr">{format.number(amount, { style: 'currency', currency })}</span>;
}

// Or directly:
new Intl.NumberFormat('ar-u-nu-latn', { style: 'currency', currency: 'SAR' }).format(1250);
// "‏1,250.00 ر.س."   (Western digits, Arabic currency symbol)
new Intl.DateTimeFormat('ar-EG', { dateStyle: 'long' }).format(new Date());
// "٥ سبتمبر ٢٠٢٦"    (Eastern Arabic digits unless you add -u-nu-latn)

Wrap numbers, phone numbers, URLs and code in dir="ltr" inside Arabic paragraphs. Without it, the bidi algorithm reorders mixed content such as "+20 10 1234 5678" or "v2.1.0" in ways that look wrong to readers.

How do search engines and AI assistants learn that the two pages are translations?

Short answer: through hreflang alternates in the page metadata and in the sitemap, an x-default pointing at English, and canonicals that point at the same language, not across. AI crawlers do not run JavaScript, so all of this must be in the server-rendered HTML.

// generateMetadata in any page
export async function generateMetadata({ params }) {
  const { locale } = await params;
  const path = '/pricing';
  return {
    alternates: {
      canonical: locale === 'ar' ? `${SITE_URL}/ar${path}` : `${SITE_URL}${path}`,
      languages: {
        en: `${SITE_URL}${path}`,
        ar: `${SITE_URL}/ar${path}`,
        'x-default': `${SITE_URL}${path}`,
      },
    },
  };
}

In app/sitemap.ts, emit both URLs for every page with the same alternates.languages map. Structured data should carry inLanguage per page, and the entity description in JSON-LD should be translated, not left in English on the Arabic page. I generate both languages from a single profile file so the two never drift.

Which twelve RTL bugs show up in every review?

  1. Physical margin and padding classes (ml-, pr-) that push content the wrong way
  2. space-x-* without rtl:space-x-reverse, which overlaps flex children
  3. Absolutely positioned badges and close buttons using left / right
  4. Chevrons, arrows and "next" icons pointing backwards
  5. Carousels and sliders (Swiper, Embla) not told dir="rtl", so swipe direction inverts
  6. Phone numbers, prices and URLs reordered by bidi inside Arabic sentences
  7. Input fields for email, phone and codes rendering RTL; they need dir="ltr" with text-start
  8. Letter-spacing or uppercase applied to Arabic headings
  9. Truncated text (truncate) showing the ellipsis on the wrong side without text-start
  10. Toasts, drawers and dropdowns animating in from the wrong edge
  11. Date pickers and calendars with English weekday order or Gregorian-only assumptions where Hijri matters
  12. Hard-coded English strings in error messages, validation (Zod), email templates and PDF exports, which are the last places anyone translates

What does a bilingual launch checklist look like?

  • Every page reachable at /ar/... with translated title, description and Open Graph text
  • html[lang] and html[dir] correct on both trees; language switcher preserves the current path
  • No physical direction classes left (grep the list above)
  • Numbers, dates and currency formatted per locale with a deliberate digit choice
  • Validation messages, emails, invoices and PDFs translated
  • hreflang in metadata and sitemap, x-default set, canonicals per language
  • JSON-LD translated per page, same entity facts in both languages
  • An Arabic-speaking reviewer reads five key pages on a phone before launch

Frequently asked questions

Should the default locale have a URL prefix?

Usually not. With next-intl's as-needed prefix mode, English lives at /pricing and Arabic at /ar/pricing. Existing English URLs keep working, and every page still has a distinct, indexable Arabic URL with hreflang pointing both ways.

Do I need separate components for RTL?

No. Set dir on the html element, use Tailwind's logical utilities instead of left and right, and reserve the rtl: variant for the handful of icons and shadows that must flip. One component tree serves both directions.

Which fonts work for Arabic and English together?

IBM Plex Sans Arabic, Cairo, Tajawal and Noto Sans Arabic all ship Latin glyphs that pair acceptably with their Arabic. Load them through next/font with the arabic subset and never apply letter-spacing to Arabic text.

How do I keep numbers Western in Arabic pages?

Use the locale tag ar-u-nu-latn with Intl.NumberFormat or next-intl's useFormatter. Arabic readers in Egypt and the Gulf commonly expect Western digits for prices and phone numbers, while ar-EG alone produces Eastern Arabic numerals.

How do search engines and AI assistants know the Arabic page is a translation?

Set alternates.languages in generateMetadata for every page, include the same alternates in sitemap.xml, add an x-default pointing at English, and keep the canonical of each language pointing at itself.

If you need this built rather than explained, see Next.js development and the direct answer on hiring a bilingual English/Arabic developer.

الوسوم

Next.jsArabicRTLnext-intlTailwind CSSi18n