DEV Community

Abo-Elmakarem Shohoud
Abo-Elmakarem Shohoud

Posted on

Building bilingual Arabic-first SaaS with Next.js 14: a production recipe

description: "Most 'Arabic support' is dir=rtl and a font swap. This is the architecture for actually shipping a bilingual product — direction, typography, content storage, and the SSR trick that keeps Arabic queries indexable."
tags: nextjs, arabic, i18n, typescript

canonical_url: https://aboelmakarem.pro/blog:

Most "Arabic support" I see in production is dir="rtl" on the body and a Google-Fonts swap to Cairo. That is not bilingual. That is translated. Buttons sit on the wrong side, numbers leak left-to-right inside right-to-left sentences, the input cursor jumps to the wrong corner when you type a URL, and Googlebot indexes none of your Arabic content because the language toggle runs in the client.

I have shipped three Arabic-first SaaS products from the same Next.js 14 architecture: Tornix.ai (AI project management with a Critical Path Method engine and Primavera P6 import/export), Oravex.app (Odoo 18 with an Arabic-fluent natural-language layer), and Costra.ailigent.ai (AI construction cost estimation, Arabic-RTL throughout). Each one taught me that "bilingual" is not one problem. It is five, and they have to be solved in the right order.

The five layers of bilingual

When people ask me to "add Arabic support" to an existing app, the answer is almost always: rewrite the layout assumptions. Bilingual is structural, not decorative. There are five layers, and skipping one of them creates a visible bug that takes ten times longer to fix later than to design correctly.

  1. Direction. The DOM's dir attribute, the document's writing mode, and every layout assumption that follows from it. ml-4 becomes a bug. flex-row becomes a bug. Anything with a hardcoded left or right becomes a bug.
  2. Text rendering. Bidirectional text (Arabic with embedded English brand names, URLs, code identifiers, numbers) needs the Unicode Bidirectional Algorithm to do the right thing. Most of the time the browser handles it, but only if you don't fight it with CSS overrides.
  3. Typography. Latin and Arabic have different x-heights, different optical sizes, different stroke contrasts. A font that looks balanced in English will look thin in Arabic. You need a pair, not a fallback chain.
  4. Content storage. Where do Arabic and English strings live? In the same row with two columns, or in a translations table, or in JSON? The choice ripples through every API response, every search query, every sitemap entry.
  5. SEO and discovery. Arabic queries are a separate index. Search Console treats /?lang=ar as a distinct page, hreflang has to be exactly right, and if your language toggle is client-side, Googlebot never sees the Arabic content at all unless you give it a server-rendered shadow copy.

Each layer is independent, and each one has a "right" answer in Next.js 14 that is not obvious from the docs.

Layer 1: direction and language context

Skip next-intl or next-i18next for the first cut. They are good libraries, but they solve a translations-dictionary problem, and what you have on day one is a direction-and-document problem. Build a tiny context first.

// lib/LanguageContext.tsx
'use client'

import { createContext, useContext, useEffect, useState, ReactNode } from 'react'

type Language = 'en' | 'ar'
type Direction = 'ltr' | 'rtl'

interface LanguageContextValue {
  language: Language
  setLanguage: (lang: Language) => void
  dir: Direction
}

const LanguageContext = createContext<LanguageContextValue | null>(null)

export function LanguageProvider({ children }: { children: ReactNode }) {
  const [language, setLanguageState] = useState<Language>('en')

  useEffect(() => {
    const stored = (localStorage.getItem('lang') as Language) || 'en'
    const url = new URL(window.location.href)
    const param = url.searchParams.get('lang') as Language | null
    const initial = param ?? stored
    setLanguageState(initial)
  }, [])

  useEffect(() => {
    const dir: Direction = language === 'ar' ? 'rtl' : 'ltr'
    document.documentElement.lang = language
    document.documentElement.dir = dir
    localStorage.setItem('lang', language)
  }, [language])

  const dir: Direction = language === 'ar' ? 'rtl' : 'ltr'
  return (
    <LanguageContext.Provider value={{ language, setLanguage: setLanguageState, dir }}>
      {children}
    </LanguageContext.Provider>
  )
}

export const useLanguage = () => {
  const ctx = useContext(LanguageContext)
  if (!ctx) throw new Error('useLanguage must be used inside LanguageProvider')
  return ctx
}
Enter fullscreen mode Exit fullscreen mode

Two things matter here. First, document.documentElement.dir flips the entire CSS logical-properties cascade in one assignment, which is why the next layer can be expressed in pure logical CSS. Second, the provider lives at the root in app/layout.tsx, not inside the blog or dashboard. Bilingual is a document-level concern.

Layer 2: Tailwind without the ltr: and rtl: prefix soup

Tailwind ships RTL variants you can opt into. Don't use them. Or rather, don't reach for them first. Modern CSS has logical properties that already do the right thing: margin-inline-start, padding-inline-end, border-inline-start. Tailwind 3 exposes them as ms-*, me-*, ps-*, pe-*, border-s, border-e. Use those, and 90% of your layout flips for free when dir changes.

// tailwind.config.ts
import type { Config } from 'tailwindcss'

const config: Config = {
  content: ['./app/**/*.{ts,tsx}', './components/**/*.{ts,tsx}'],
  theme: {
    extend: {
      fontFamily: {
        mono: ['var(--font-jetbrains)', 'monospace'],
        arabic: ['var(--font-plex-arabic)', 'system-ui', 'sans-serif'],
      },
    },
  },
  plugins: [],
}

export default config
Enter fullscreen mode Exit fullscreen mode

The rule I follow: any spacing or border that means "from the edge of the text" uses the logical variant. ms-4 replaces ml-4. pe-6 replaces pr-6. Positional things that mean literal screen edges (a fixed sidebar at the right of the viewport in both languages) stay physical. About 95% of utility classes in a typical layout are the first kind.

Layer 3: typography is a pairing, not a fallback

IBM Plex Sans Arabic is the best free Arabic typeface in production right now. It has eight weights, generous counters, a stroke contrast that holds up at 14px, and it pairs cleanly with monospace Latin like JetBrains Mono. The mistake is treating it as a fallback (font-family: 'Inter', 'IBM Plex Arabic'). Browsers will use Inter for the Latin glyphs and Plex Arabic for the Arabic glyphs in the same paragraph, which produces a visible vertical-rhythm mismatch. Pick the font explicitly per language.

// app/fonts.ts
import { JetBrains_Mono, IBM_Plex_Sans_Arabic } from 'next/font/google'

export const jetbrains = JetBrains_Mono({
  subsets: ['latin'],
  weight: ['300', '400', '500', '700', '800'],
  variable: '--font-jetbrains',
  display: 'swap',
})

export const plexArabic = IBM_Plex_Sans_Arabic({
  subsets: ['arabic'],
  weight: ['300', '400', '500', '700'],
  variable: '--font-plex-arabic',
  display: 'swap',
  preload: true,
})
Enter fullscreen mode Exit fullscreen mode

IBM Plex Sans Arabic adds about 85kb gzipped per weight you ship. Be honest about which weights you actually use. I ship four (300, 400, 500, 700) because anything heavier than 700 looks like a fax in Arabic, and 200 is illegible below 18px. Then on the body element apply the family based on language: className={language === 'ar' ? 'font-arabic' : 'font-mono'}. The two families never mix inside the same block, which is what keeps the rhythm consistent.

Layer 4: content storage as dual columns, not a translations table

For a SaaS with a content surface (blog posts, product descriptions, FAQ), the choice between dual columns (title_en, title_ar) and a translations table keyed by post_id + locale decides how painful your queries become. I have built both. Dual columns win for two-language products. The translations table is correct for five or more languages, but it forces a join on every read and Postgres full-text search becomes a configuration project.

-- supabase/schema.sql (excerpt)
create table posts (
  id uuid primary key default gen_random_uuid(),
  slug text not null unique,
  title_en text not null,
  title_ar text not null,
  excerpt_en text,
  excerpt_ar text,
  content_en text,
  content_ar text,
  search_vector_en tsvector generated always as (
    to_tsvector('english', coalesce(title_en,'') || ' ' || coalesce(content_en,''))
  ) stored,
  search_vector_ar tsvector generated always as (
    to_tsvector('arabic', coalesce(title_ar,'') || ' ' || coalesce(content_ar,''))
  ) stored,
  published_at timestamptz,
  status text not null default 'draft'
);
Enter fullscreen mode Exit fullscreen mode

The serving function is a one-liner: const title = lang === 'ar' ? post.title_ar : post.title_en. No joins, no fallback dance, and the two search vectors mean Arabic and English queries hit the right tokenizer. Postgres has a built-in arabic text search configuration since version 11. Most people don't know this and end up writing trigram search instead, which is fine for a prototype and wrong for a product.

Layer 5: hreflang the way Next.js doesn't document

This is where I see the most production sites get it wrong, and it is the bug that costs you the most in lost traffic. The Next.js Metadata API has an alternates.languages option that looks like exactly what you want.

// what you reach for first — and what silently breaks
export const metadata = {
  alternates: {
    languages: {
      'ar-EG': '/?lang=ar',
    },
  },
}
Enter fullscreen mode Exit fullscreen mode

Next strips the ?lang=ar query string when it renders the <link> tag. The hreflang ends up pointing to /, which is identical to the English canonical, so Google merges the two and your Arabic URL never gets indexed. Inject the link tag raw in the layout's <head> instead.

// app/layout.tsx
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <head>
        <link rel="alternate" hrefLang="en" href="https://aboelmakarem.pro/" />
        <link rel="alternate" hrefLang="en-US" href="https://aboelmakarem.pro/" />
        <link rel="alternate" hrefLang="ar" href="https://aboelmakarem.pro/?lang=ar" />
        <link rel="alternate" hrefLang="ar-EG" href="https://aboelmakarem.pro/?lang=ar" />
        <link rel="alternate" hrefLang="x-default" href="https://aboelmakarem.pro/" />
      </head>
      <body>{children}</body>
    </html>
  )
}
Enter fullscreen mode Exit fullscreen mode

In app/sitemap.ts, include the Arabic URL as a distinct entry. Submit to Google Search Console and to Bing via IndexNow. About two weeks later, the Arabic URLs start appearing in coverage reports.

The hidden Arabic SEO block

This one is genuinely novel and I have not seen it written up anywhere, so it is worth its own section. Here is the failure mode: your homepage uses the LanguageProvider from layer 1 to toggle between English and Arabic. The toggle works in the browser. You ship. Two months later, you check Search Console and your Arabic impressions are zero, because Googlebot rendered the page with the default state (English) and never saw a single Arabic character.

You have two options. Option A: server-side detect the locale (from a cookie, the Accept-Language header, or the URL) and render the matching language on the server. This is correct, but it doubles the rendering matrix and breaks if your CMS expects one canonical URL.

Option B: render both languages on the server, hide the non-active one visually, and let the client-side toggle swap visibility. This is what I do on aboelmakarem.pro. The Arabic content sits in the DOM at request time, fully visible to crawlers, and a single CSS class moves it out of the visual flow for sighted English users.

/* globals.css */
.sr-only-seo {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}
Enter fullscreen mode Exit fullscreen mode

The block contains the Arabic name (ابوالمكارم شهود), the service descriptions, the Arabic project names, contact information — everything Googlebot needs to match an Arabic query. It is not cloaking. The content is genuinely on the page, available to screen readers in the same way as visually-hidden labels are, and identical in meaning to the English content above it. After adding this block, Arabic impressions on the site went from approximately zero to consistent weekly traffic on the name query "ابوالمكارم شهود" within three weeks. The technique works for any client-rendered language toggle.

Three production callouts

Webfonts: subset your Arabic. The full Arabic-script Unicode range covers Arabic, Persian, Urdu, Pashto, and several historical scripts. If you are shipping a product for Egyptian, Gulf, or Levantine Arabic speakers, you only need the Arabic block plus the presentation forms. Next.js's subsets: ['arabic'] parameter handles this, but verify the actual file size in Network tab. If your .woff2 is over 60kb per weight, the subset is not actually being applied and you are shipping Persian glyphs to no one.

Number rendering: mixed-direction is a pit. Arabic-Indic digits versus Western Arabic digits (٠١٢٣ versus 0123) is a content decision, not a technical one. Banks and government use Arabic-Indic. SaaS dashboards almost universally use Western. Pick one per surface and stay consistent. The bug to watch for is bidirectional reordering: a string like "Tornix v2.4" inside an Arabic paragraph will reverse to "2.4 Tornix v" unless you wrap the Latin run in <bdi> or <span dir="ltr">. Test with a real Arabic paragraph, not just a label.

Form inputs: text-align: start, not left. Every <input> and <textarea> in your design system should use text-align: start so that the cursor begins at the leading edge for both languages. Same for placeholders. Same for error messages. The one exception is numeric inputs (price, quantity, year), which should keep text-align: end so the trailing digit aligns with the field's edge. If your design system has fifty input components and they all use text-align: left, this is a one-day refactor that fixes a hundred small bugs.

What this enables

Three production SaaS — Tornix.ai, Oravex.app, Costra.ailigent.ai — ship from one bilingual architecture and one shared component library. Adding a new product means a new project repo, the same LanguageContext, the same dual-column schema, the same hreflang block, the same hidden-SEO pattern. The cost of bilingual on day one is high. The cost of bilingual on day three hundred is approximately zero. That is the whole argument for designing this in from the start: the layers compound, and the only painful version is the retrofit.

If you are starting a new product for any market where Arabic readers will pay you money — Egypt, Saudi Arabia, the UAE, Jordan, Morocco — build it bilingual on day one. The architecture above is enough to get the first version out the door, and the five layers give you a checklist to verify nothing is missing before you ship.


Written by Abo-Elmakarem Shohoud — Full-Stack Developer at Ailigent, building bilingual AI SaaS from Cairo. Three production products in the wild: Tornix.ai, Oravex.app, Costra.ailigent.ai. Find me on GitHub, LinkedIn, or Twitter/X.

Top comments (0)