An Arabic landing page that loads nothing from anyone else
A clinic in Damascus hired me to build their landing page. One page, in Arabic, with exactly one job: turn a tap on an Instagram bio link into a WhatsApp conversation. Nearly every visitor arrives on a phone, inside Instagram’s in-app browser — so that’s what it was built and measured against, not a desktop window that got narrower.
The client and the address stay out of this post. Everything else is the build.
one content file, rendered before the browser sees it
Every Arabic string lives in one typed file. Not the renderers, not the HTML shell, not the stylesheet — change a line there and you’ve changed the page.
The obvious way to get that is to render in the browser: import the strings, build the DOM at runtime. But the page also has to work with JavaScript switched off, and client-side rendering kills that.
So the render happens at build time. A small Vite plugin calls the renderers and bakes the finished HTML into the output file. Nothing renders content in the browser; the JavaScript that ships is only for behaviour. One reviewable file of Arabic copy, and a page that’s finished before any script runs — otherwise mutually exclusive. That’s the entire reason this project has a build step.
arabic isn’t english with the text moved to the other side
The bug that took longest to find never appeared on the page at all.
The booking form doesn’t submit anywhere. It composes a WhatsApp message and
opens it, with the visitor’s answers — phone number included — dropped into an
Arabic sentence. Arabic runs right to left, and a paragraph’s direction reorders
any weak run inside it. A string of digits is a weak run. Measured,
0912 345 678 reached the other end as 678 345 0912; +963 912 345 678
arrived with its groups reversed and the plus stranded on the wrong end. One
unbroken run of digits survives. A single space doesn’t. A leading + doesn’t.
The fix is the plain-text version of <bdi>: wrap every interpolated value in
U+2068 and U+2069, the directional isolate characters.
It hid because the phone field is dir="ltr" — the number looks correct the
whole time it’s being typed, and flips inside WhatsApp, on the recipient’s
screen, where nobody on this end ever looks. A form that works perfectly,
sending a number that doesn’t. Interpolate user input into right-to-left text
and that bug is already waiting for you.
Four smaller ones from the same family:
- Never put
letter-spacingon Arabic. The letters join; spacing them apart breaks the joins and renders a word as loose disconnected glyphs. There’s none anywhere in the stylesheet, so any that turns up is a bug by definition. - Two adjacent isolates render backwards.
<bdi>2026</bdi> <bdi>Clinic</bdi>displays as “Clinic 2026” — isolates lay out right-to-left against each other like everything else. Consecutive Latin words go inside one isolate, not two. - Isolation belongs to markup, not to strings. Copy gets its Latin runs
wrapped on the way into HTML. A
<title>or an attribute value holds text, not markup — use the markup-producing helper there and the browser tab shows literal<bdi>tags. Two escapes, two jobs, and the compiler can’t tell them apart for you. - Position logically, draw physically.
inset-inline-endto place a thing so it flips with the page;border-rightandborder-bottomto draw it. Drawing the select chevron withborder-inline-endflipped it to point sideways in RTL. A down arrow points down in both directions.
no backend, and nothing from anyone else’s server
One FAQ answer says the site stores nothing and has no database. That answer is the best thing on the page, because it’s literally true rather than a policy someone wrote.
There’s no server. The form URL-encodes its four fields into a wa.me link and
opens WhatsApp with the message pre-written, so the visitor reads it before
sending.
And the built page makes zero requests off its own origin — no analytics tag, no embedded posts, no font CDN. The six woff2 files are self-hosted for that reason; every icon and the logo are inline SVG; the favicon is a data URI. One third-party script would turn that FAQ answer into a lie, so it’s an absolute rule and not a preference. The page has no runtime dependencies at all.
nothing unfinished reaches a visitor
A landing page gets built while the client is still finding the photographs.
The rule: a field with no value renders no element at all. No placeholder strings, no empty shells, no “coming soon”. Leave the address unset and the address block is absent from the DOM — the section stands on the phone number and reads finished rather than half-filled. Policy lives in the types the same way: a testimonial needs a consent flag in its own data or the renderer drops it, so one can’t go live ahead of its paperwork.
Around that sits a guard script and two builds that differ in one thing. Both
fail on a marker word or an href="#", across the sources and the built output.
The preview build prints every slot that’s deliberately standing in — that’s
how a client is shown a white rectangle at the exact size of the photo they
still owe. The launch build treats those same stand-ins as fatal. A placeholder
can sit on a preview all week and still can’t reach a live domain by being
forgotten.
It exists because it once failed: five white rectangles rendered to visitors while the guard passed clean, because it was looking for marker words and a generated data URI contains none in any language. Same lesson as a rule — refuse loudly at build time, never disappear quietly at render time. A renderer that quietly drops a malformed card leaves whoever pasted the link with nothing to explain why.
what it took
Two things ate most of the time, and neither was visible from my desk.
The reel rail. A horizontal row of cards you flick through, looping — which means quietly jumping the scroll position back a whole row at the end. The first version gated that jump on the rail being still for 80ms, on the theory that a write during a fling gets discarded. It stopped the jump from ever firing while someone was flicking, which is the only moment it matters: the next flick lands while the last one’s momentum is still firing scroll events, so the rail is never still.
Measured at 390px with flicks 200ms apart, the fourth flick left the rail on the
first card of the strip, where a swipe does nothing at all —
overscroll-behavior-x: contain, so not even a rubber-band says why. Twice the
runway didn’t help: the same gesture walked fourteen cards over eight flicks and
the jump never fired once. Runway is finite; flicking isn’t.
It jumps on every touchstart now, by a whole row — not by centring a card,
because off a snap point the rail is never exactly on one, so centring moves it
a row plus the remainder, and the remainder is what a visitor sees. Snap has
to be off for the frame the jump happens in, or mandatory snapping clamps a
four-card jump to one.
That section also broke the whole page, in a way that looked unrelated. Every
card carries a screen-reader note positioned absolutely, and overflow only
clips a descendant whose containing block is inside the scroller. The rail was
position: static, so those notes resolved against the document: cards waiting
off-screen put boxes at x ≈ -786, and the page scrolled sideways and shrank
itself to a third of the screen on every phone, JavaScript on or off. A scroll
container has to be position: relative.
The failure mode worse than no JavaScript. Elements fade in as you reach
them. An inline script sets the hiding flag before first paint, the stylesheet
hides 39 elements behind it, and only the main module un-hides them. So the page
had a state worse than JavaScript being off: JavaScript on, module not
arriving. Measured with that request blocked, 39 of 39 stayed invisible for good
and the booking form sat there visible and silently dead — <noscript> doesn’t
apply when scripting is enabled. The module now sets a ready flag, and the
inline script drops the hiding flag after three seconds if it never appears.
the takeaway
On a page like this, correctness is mostly about what the person who built it never sees. The number that arrives reversed in someone else’s WhatsApp. The section that renders as an empty shell because a field is missing. The 39 elements that stay hidden when one request doesn’t land. None of those show up in a browser on my desk on a good connection — they show up on the other end, on a phone, once, to someone who won’t report it. Every mechanism in here exists to drag one of those failures back to build time, where it can be loud.
About 3,500 lines of TypeScript and CSS. No framework and no runtime dependencies — the build’s only job is to make the page finished before it’s served.