← Back to Work

TravelHouse (freelance · solo)

Bilingual (EN / AR · LTR + RTL) travel agency site on Next.js 15 + Payload CMS 3 with a layout-builder block system. Freelance, solo end-to-end: I owned the visual design, brand system, frontend, backend (CMS schema + Postgres + Supabase storage), and SEO.

Role
Freelance — Solo designer + full-stack engineer
Published
مارس ٢٠٢٦
Locales
EN · AR
Direction
LTR + RTL
Surface
Pages + Tours + Destinations + Blog
Next.js 15React 19TypeScriptPayload CMS 3PostgreSQLSupabase StorageFramer MotionVitestPlaywright

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:

  1. 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).
  2. 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,
}

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

← All case studies