惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

T
Threatpost
AI
AI
T
Threat Research - Cisco Blogs
Know Your Adversary
Know Your Adversary
C
CERT Recently Published Vulnerability Notes
V
Vulnerabilities – Threatpost
S
Securelist
cs.CL updates on arXiv.org
cs.CL updates on arXiv.org
T
Tenable Blog
C
Cybersecurity and Infrastructure Security Agency CISA
AWS News Blog
AWS News Blog
Cisco Talos Blog
Cisco Talos Blog
Cloudbric
Cloudbric
P
Privacy & Cybersecurity Law Blog
N
News and Events Feed by Topic
D
Docker
博客园 - 司徒正美
Last Week in AI
Last Week in AI
Google DeepMind News
Google DeepMind News
O
OpenAI News
人人都是产品经理
人人都是产品经理
腾讯CDC
博客园 - 三生石上(FineUI控件)
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
Scott Helme
Scott Helme
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
The GitHub Blog
The GitHub Blog
Microsoft Security Blog
Microsoft Security Blog
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
cs.CV updates on arXiv.org
cs.CV updates on arXiv.org
博客园 - 叶小钗
N
News and Events Feed by Topic
J
Java Code Geeks
D
DataBreaches.Net
爱范儿
爱范儿
N
Netflix TechBlog - Medium
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
IT之家
IT之家
H
Heimdal Security Blog
F
Full Disclosure
L
LINUX DO - 最新话题
W
WeLiveSecurity
Blog — PlanetScale
Blog — PlanetScale
V
V2EX
M
MIT News - Artificial intelligence
Apple Machine Learning Research
Apple Machine Learning Research
博客园 - Franky
K
KPMG report finds enterprise disconnect between AI and its ROI | CIO
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
U
Unit 42

DEV Community

Authentication Security Deep Dive: From Brute Force to Salted Hashing (With Java Examples) Why AI Systems Don’t Fail — They Drift Spilling beans for how i learn for exam😁"Reinforcement Learning Cheat Sheet" I Replaced Chrome with Safari for AI Browser Automation. Here's What Broke (and What Finally Worked) How Python Borrows Other People's Work The $40 Architecture: Processing 1 Billion API Requests with 99.99% Uptime Vibe Coding: A Workflow Guide (From Zero to SaaS) Most webhook security guides protect the wrong side. The scary part is delivery. Headless CMS for TanStack Start: Build a Blog with Cosmic EU Age Verification App "Hacked in 2 Minutes" — What Actually Happened Comfy Cloud’s delete function does not actually remove files Running AI Models on GPU Cloud Servers: A Beginner Guide Event-driven media intelligence with AWS Step Functions and Bedrock I scored 500 AI prompts across 8 quality dimensions — here's what broke How to Call Google Gemini API from Next.js (Free Tier, No Backend Needed) The Portal Protocol: Reclaiming Human Connection in the Age of AI How to Fix Your Team's Scattered Knowledge Problem With a Self-Hosted Forum Intro to tc Cloud Functors: A Graph-First Mental Model for the Modern Cloud Designing Multi-Tenant Backends With Both Ownership and Team Access I Built a Neumorphic CSS Library with 77+ Components — Here's What I Learned PostgreSQL Performance Optimization: Why Connection Pooling Is Critical at Scale Cómo construí un SaaS multi-rubro para gestionar expensas en Argentina con FastAPI + Vue 3 🚀 I Built an Ethical Hacking Scanner Tool – Open Source Project I Replaced /usage and /context in Claude Code With a Single Statusline A Pythonic Way to Handle Emails (IMAP/SMTP) with Auto-Discovery and AI-Ready Design I Collected 8.9 Million Polymarket Price Points — Here's What I Found About How Markets Really Move EcoTrack AI — Carbon Footprint Tracker & Dashboard Everyone's Using AI. No One Agrees How. 5 self-hosted ebook managers worth trying in 2026 Building Your First AI Agent with LangChain: From Chatbot to Autonomous Assistant Common SOC 2 Failures (Real World) Stop Vibe-Checking Your AI App: A Practical Guide to Evals How to Use SonarQube and SonarScanner Locally to Level Up Your Code Quality Your Next To-Do App Is Dead — I Replaced Mine with an OpenClaw AI Sign a Nostr event in 60 lines of Python using coincurve — no nostr-sdk, no nbxplorer, no rust toolchain ITGC Audit Explained Like You’re in Big 4 Patch Tuesday abril 2026: Microsoft parcha 163 vulnerabilidades y un zero-day en SharePoint Stop scraping everything: a better way to track competitor price changes Listing on MCPize + the Official MCP Registry while routing payments OUTSIDE the marketplace — how I kept 100% of my x402 revenue Building an AI-Powered Risk Intelligence System Using Serverless Architecture Why We Ripped Function Overloading Out of Our AI Toolchain Testing AI-Generated Code: How to Actually Know If It Works SaaS Churn Is Killing Your Business. Here Is What to Do About It (Without a Support Team) The Speed of AI Is No Longer Linear - And Self-Improving Models Are Why How to Implement RBAC for MCP Tools: A Practical Guide for Engineering Teams From Standard Quote to Persuasive Proposal: AI Automation for Arborists I built a CLI that scaffolds complete multi-tenant SaaS apps Axios CVE-2025–62718: The Silent SSRF Bug That Could Be Hiding in Your Node.js App Right Now The dashboard that ended our friendship Data Pipelines Explained Simply (and How to Build Them with Python) The Hidden Cost of AI Systems Nobody Talks About. undefined vs undeclared, and how typeof behaves Switching from file-based jobs to NATS/Kafka in Rust without changing code io_uring Adventures: Rust Servers That Love Syscalls Why Agentic AI is Killing the Traditional Database The POUR principles of web accessibility for developers and designers Quantum Neural Network 3D — A Deep Dive into Interactive WebGL Visualization How To Install Caveman In Codex On macOS And Windows Automation Pipeline Reliability: Why Your Workflow Breaks When Nobody Is Watching I Built an 'Open World' AI Coding Agent — It Works From ANY Folder From Freelancing to Product: A Tech Service Company's SaaS Transformation China's AI Giants: Adding Tencent Hunyuan & ByteDance Doubao to AI University (74 Providers) On the Vibe Coders and Their Lies clerk: Auto-Summarize Your Claude Code Sessions AI Weekly — 2026/04/10–04/17 | The Model Lockdown Is Here, but the Toolchain Is the Real Battleground AI 週報 — 2026/04/10–2026/04/17 模型封鎖潮來了,但工具鏈才是真戰場 Maybe this is how Open-Source apps are born... 🚀 Fine-Tune LLMs with LoRA and QLoRA: 2026 Guide tRPC v11 + Next.js App Router: End-to-End Type Safety Without the Boilerplate ShadCN UI in 2026: Why I Stopped Installing Component Libraries and Started Owning My Components SaaS Billing in React Server Components: Stripe + Supabase Without a Single `useEffect` Join our DEV Weekend Challenge — $1,000 in Prizes Across TEN winners! Submissions Due April 20 at 6:59 AM UTC. Implementing FSRS Spaced Repetition in Flutter + Supabase — Adding Memory Science to an AI Learning App "I Texted My Localhost From the Train — Claude Code Fixed the Bug Before I Got Home" I Built a Sales Prep AI and It Went Deeper Than Expected Design to Code #2: One JSON, Eleven Outputs Solving the 100M-Row Problem: A Summary Table Pattern for High-Volume Push Notification Logs Flutter Web With Wasm: What Actually Changes For Developers I Built 50 Royalty-Free Soundtracks for My Side Project in a Weekend Using AI Music Generation The Vibe Coding Security Checklist: 7 Things to Check Before You Ship Stop Letting Googlebot Guess Fix Your React App's SEO Right Desconstruindo o Streaming do LinkedIn: Como Criar um Engine de Extração de Vídeo de Alta Performance com HLS e FFmpeg (EDA Part-1) EDA (Exploratory Data Analysis) Explained With Real Life — Why Looking at Your Data Is the Most Important Step in Machine Learning Brand Relationship Management at Scale: Our 4-Touch Outreach System for 200+ Brands Why String.fromEnvironment() Might Return an Empty String in Dart JGuardrails 1.0.0 — Hardening Java LLM Apps Against Jailbreaks, Toxicity, and Prompt Injection Plan and Schedule a Full Week of Threads Content From One Claude Conversation Coding Cat Oran Ep3, Five Tables Changed Everything Updated: BFF Pattern I'm done watching freelancers get buried by 200 proposals. So I'm building the alternative. This is my first post BFS Algorithm in Java Step by Step Tutorial with Examples Tracking LLM Pricing Monthly: An Open Dataset for 22 AI Models How We Measure Content ROI on a Comparison Site: Revenue Attribution Without Perfect Data Introducing Nova AI Ops: The AI-Native Operating System for SRE Teams I built a free desktop video downloader for Windows — Grabbit How Talkie OCR Helps Vision-Impaired & Dyslexic Users Read the World Around Them VRCFaceTracking安装和iPhone面捕配置教程,有bug Even CrowdStrike Can't See Your Agents The Automation Gold Rush: What n8n Workflows and Claude Are Opening Up for Developers Right Now
Building bilingual Arabic-first SaaS with Next.js 14: a production recipe
Abo-Elmakare · 2026-05-15 · via DEV Community

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.