I recently shipped a bilingual (Arabic + English) profile page on our company site, basmaos.com. Arabic is the default language, English lives under /en, and the whole site is a small PHP app with no framework.
Most bilingual SEO guides assume English first and treat right-to-left as an afterthought. Here is what actually mattered when Arabic comes first, including two caching surprises I didn't expect.
1. Pick one URL scheme and keep it
Our site already served Arabic at the root and English under /en:
https://basmaos.com/hatem-gheta ← Arabic (default)
https://basmaos.com/en/hatem-gheta ← English
It was tempting to add /ar/... for symmetry. I didn't, because two URLs for the same Arabic page means duplicate content and split signals. Instead, /ar/... is a single permanent redirect to the root:
if ($path === '/ar' || str_starts_with($path, '/ar/')) {
$target = substr($path, 3);
$target = $target === '' || $target === '/' ? '/' : rtrim($target, '/');
return redirect($target.($query !== '' ? '?'.$query : ''), 301);
}
People still guess /ar/ when they share links, so the redirect catches them in one hop, with no chains and no loops. Trailing slashes get the same treatment: one 301 to the canonical form.
2. hreflang: every page lists every language, including itself
Each language version carries the full set, plus x-default:
<link rel="canonical" href="https://basmaos.com/en/hatem-gheta">
<link rel="alternate" hreflang="ar" href="https://basmaos.com/hatem-gheta">
<link rel="alternate" hreflang="en" href="https://basmaos.com/en/hatem-gheta">
<link rel="alternate" hreflang="x-default" href="https://basmaos.com/hatem-gheta">
Three rules I check in tests:
- The canonical points at itself. Never point the English page's canonical at the Arabic page; that tells Google to drop the English one.
- The links are reciprocal. If the Arabic page lists the English one, the English page must list the Arabic one back.
-
x-defaultis the Arabic version, because that is what someone with no language preference should land on.
The sitemap repeats the same alternates with xhtml:link, so Google sees one consistent story everywhere.
3. RTL is more than dir="rtl"
<html lang="ar" dir="rtl">
That attribute is the easy part. The issues show up in the details:
Use logical CSS properties. margin-left is wrong in one of your two languages. margin-inline-start, padding-inline-start and inset-inline-end flip automatically:
.product-points li { padding-inline-start: 24px; }
.product-points li::before { inset-inline-start: 4px; }
Isolate mixed-direction text. An Arabic page that shows an English name, or an English page that shows an Arabic one, needs both lang and dir on that element:
<h1>
Hatem Gheta
<span lang="ar" dir="rtl">حاتم غيته</span>
</h1>
Without that, punctuation and brand names such as "BASMA OS" end up in the wrong place.
Watch alignment inside grids. My first version put the Arabic name at the far edge of the English hero, because an RTL span inside an LTR grid aligned to its own start. justify-self: start fixed it.
Arrows and icons need flipping. An arrow that means "go" points right in English and left in Arabic:
[dir="rtl"] .arrow { transform: scaleX(-1); }
4. Structured data: one person, two pages
The page describes a real person, so it uses ProfilePage with a Person as mainEntity. The important decision was the @id:
{
"@type": "Person",
"@id": "https://basmaos.com/hatem-gheta#person",
"name": "Hatem Gheta",
"alternateName": ["حاتم غيته"],
"url": "https://basmaos.com/hatem-gheta",
"worksFor": { "@id": "https://basmaos.com/#organization" },
"sameAs": ["https://www.linkedin.com/in/hatemgheta/"]
}
-
The same
@idandurlon both language pages. It is one person, not two. My first version seturlto the current language's page, which quietly told search engines there were two differenturls for the same entity. -
The name in the other script goes in
alternateName. On the Arabic page the order is reversed: Arabicname, EnglishalternateName. -
sameAslists only profiles that are definitely mine. It is tempting to add every link you have. Don't: an unrelated or closed profile there does more harm than good. - Everything in the JSON-LD is also visible on the page. Structured data that says things the page doesn't is a fast way to lose trust.
5. Accessibility caught a brand-color bug
Running axe-core on both languages flagged our brand gold, #b8913f, as text on a light background: a contrast ratio of 2.7:1, well below the 4.5:1 that WCAG AA requires. It was already used across the whole site, not just the new page.
The fix was a separate token used only for text:
--gold: #b8913f; /* buttons, borders, lines */
--gold-text: #86671f; /* text on light backgrounds: 4.85:1 */
Buttons and decoration kept the original color; only text changed. Visually you barely notice, and the audit went from failing to 0 violations.
6. The surprise: LinkedIn caches preview images by URL
After the launch I changed the title on the page and regenerated the Open Graph image. The page updated. LinkedIn's Post Inspector picked up the new title… and kept showing the old image.
Debugging this took a detour, because the image hash from the live URL didn't match the file on disk either:
file on server: 4666015a… (79,405 bytes)
served via CDN: fd7df16f… (78,080 bytes)
The CDN header said cache-status: MISS, and a cache-busting query string returned the same bytes. So it wasn't serving a stale copy: the CDN re-compresses images on the fly. That explained the hash mismatch, but not LinkedIn.
The real cause: LinkedIn caches the preview image by its URL, and re-scraping the page doesn't necessarily re-fetch an image whose URL hasn't changed. The fix was simply a new file name:
- <meta property="og:image" content="https://basmaos.com/basmaos-assets/hatem-gheta-og.jpg">
+ <meta property="og:image" content="https://basmaos.com/basmaos-assets/hatem-gheta-og-2.jpg">
One more inspection and the new card appeared. Lesson: version your OG image file names, the same way you version CSS.
7. Arabic text in images needs real shaping
The Open Graph card has the name in both scripts. Many image libraries draw Arabic letters disconnected and left-to-right. I rendered the card as HTML in headless Chromium and took a screenshot, which gives correct Arabic shaping for free.
8. Test the boring parts
The whole site is checked by a plain PHP test script that calls the router directly, with no web server. For this page it verifies:
-
200for both languages;301for/ar/...and trailing slashes - one
<h1>, one<title>, one meta description per page - a self-referencing canonical and the complete hreflang set
- the page appears in the sitemap with alternates
- the JSON-LD parses, the Person
@idis identical in both languages, andsameAscontains only the expected profile - no
noindex
None of it is clever, but each check is something I have seen go wrong on a real site.
Where it stands
Deployed, crawlable, in the sitemap, and submitted through Search Console. Indexing is up to Google, and I'll update this post once both pages are indexed.
If you are building for Arabic, Hebrew, Persian or Urdu audiences, I'd like to hear what tripped you up. The RTL details are where I learned the most.
Top comments (1)
The cache lesson is especially practical. I’d make the hreflang/canonical set a route-level test: fetch both rendered pages for every translated slug, assert reciprocal alternates and self-canonicals, then compare the structured-data @id. It catches a surprising class of regressions where language routing still looks correct to a human but starts splitting one entity into two search identities.