Context
TravelHouse is a travel agency website with both Arabic and English locales (RTL + LTR), a CMS-driven page system (the home page, dynamic [slug] pages, tours, destinations, services, blog, and contact), and a Payload admin panel for the editorial team.
Engagement shape. Solo freelance build, end-to-end. I owned the visual design and brand system (colour palette, typography, layout grid, component look-and-feel) as well as the entire engineering stack — Next.js frontend, Payload CMS schema and admin, Postgres, Supabase storage, SEO, and migrations. The client supplied the brief, the logo, and the tone; everything else came out of this engagement.
The brand baseline I designed for them: deep teal #1B4D3E for ink and navigation, golden orange #F2A900 for accents, on a clean white background — calibrated to travel-agency credibility, not noise. Picked for AA contrast against white and against each other, so the same palette holds up across hero CTAs, body copy, footer chrome, and admin notifications.
Problem
Multi-language travel sites are a familiar shape, but two things make them non-trivial:
- RTL is not a flag. Right-to-left isn't a switch on a stylesheet — it changes layout symmetry, icon direction, font choice (Arabic typography wants different vertical rhythm), and content authoring (editors expect the admin to feel native in their language).
- Editors need composition, not templates. A single "page template" forces every page into the same shape. Hardcoded sections force engineers into the loop for every layout tweak. Layout-builder blocks let editors compose pages from a typed catalogue of sections.
Architecture
Why blocks over hardcoded sections?
Editors don't compose blog posts from JSX components — they compose them from blocks. Treating marketing pages the same way means a new landing variant doesn't require a deploy. The trade-off is that block configuration must be carefully designed (each block is its own typed schema), but that's a one-time cost paid in exchange for editorial autonomy.
Implementation
i18n is Payload-native — no parallel library. Locales are declared once on the Payload config and that single block drives both the admin and the frontend:
localization: {
locales: ['en', 'ar'],
defaultLocale: 'en',
fallback: true,
}localized: trueon each text / rich-text field stores both languages inside one document. Editors toggle the admin language and the same record exposes the other locale's values. No mirrored records, no drift between EN and AR copies of "the same" page.fallback: truemeans a half-translated document still renders — missing AR fields fall back to the EN value, so launches aren't gated on every string being translated.- No
next-intl,i18next, orreact-i18nextin the dependency tree. Translations are content, and content is Payload's job.
Locale-prefixed routing. Every public route lives under /[locale]/(frontend) with locale ∈ {en, ar}. A small getLocaleFromParams helper resolves the route param to a strict locale, which is then passed straight into Payload's local API (payload.find(..., locale), payload.findGlobal(..., locale)) — Payload returns the localized values directly, no client merging step.
Direction at <html>. <html lang={locale} dir={locale === 'ar' ? 'rtl' : 'ltr'}> is set at the layout level. Components flip via logical Tailwind utilities (ps-, pe-, ms-, me-); directional icons mirror with rtl:rotate-180. The brand's typography and rhythm work in both scripts without special casing per page.
Layout builder. RenderBlocks walks the page's layout array and dispatches each typed block to a renderer. Adding a new block is: add the schema in Payload, add the renderer component, register it in the dispatcher. No CMS schema migration, no template change. Editors compose tours, destinations, services, and blog posts from the same typed catalogue.
SEO is per-page and per-locale. pageSeoMetadata derives <title>, <meta description>, canonical URLs, hreflang alternates for the other locale, and JSON-LD (Organization) from the page document. Pages without a Payload entry fall back to a friendly empty state pointing to /admin rather than rendering a broken page.
Migrations, not push. The Postgres adapter is run with explicit migrations rather than push: true — schema changes are versioned and reproducible across environments.
Outcome
- Bilingual site live with EN and AR locales; LTR/RTL handled at the layout level
- Editorial team composes pages, tours, destinations, services, and blog posts from typed Payload blocks — no engineering involvement for content updates
- Media served from Supabase Storage via Payload's S3 adapter, with on-the-fly Sharp image processing
- Per-locale SEO with
hreflangalternates andOrganizationJSON-LD