Context
Zan is a small custom pressed-flower brand. The freelance brief was to build their entire digital surface end-to-end as a solo engineer: design and copy were provided by the client; frontend, backend, CMS, infrastructure, and QA were all mine. A CMS-driven bilingual landing site (EN / AR · LTR + RTL) on top of Payload CMS 3, plus a conversion-critical booking funnel for ordering custom flower frames. One stakeholder, one production deadline, no engineering team to lean on — every architectural call had to be defensible on its own.
The frame for the engagement: this is a brand site that has to look effortless and load effortlessly, in two languages, with editor autonomy for everything except the funnel itself.
Problem
Three workstreams ran in parallel, each with its own failure mode:
- Landing site. Editors compose the homepage and content pages from typed blocks (Hero, Steps, Stats, Gallery, FAQ, CallToAction, MediaBlock, Form, Banner, Quote, Code, ArchiveBlock, RelatedPosts). If the blocks don't compose cleanly across LTR and RTL, the site becomes uneditable in Arabic.
- Booking funnel. Five-step purchase flow (Frame → Date → Information → Payment → Confirmed). It opens as a modal over any page, has to be screen-reader-correct, keyboard-complete, and not contribute to initial bundle weight on landing.
- Performance + SEO. A custom-product brand with limited paid acquisition needs organic search to work. That puts CWV and per-locale SEO on the critical path, not as a nice-to-have.
Architecture
One canonical URL per locale. A small custom middleware redirects / → /en, blocks /[locale]/home from existing as a duplicate of /[locale], and skips _next / api / admin / files. That gets rid of the most common bilingual-site SEO bug — two URLs ranking against each other for the same content.
Multi-language (EN / AR · LTR + RTL)
A bilingual brand site is two failure surfaces: the technical one (routing, fonts, direction) and the editorial one (whether the admin actually feels native in the second language). I treated them as one problem — and the answer is Payload's built-in localization, not a parallel i18n library.
One source of truth: payload.config.ts. Locales are declared once on the Payload config:
localization: {
locales: [
{ code: 'en', label: { en: 'English', ar: 'الإنجليزية' } },
{ code: 'ar', label: { en: 'Arabic', ar: 'العربية' }, rtl: true },
],
defaultLocale: 'en',
fallback: true,
}That single block does most of the heavy lifting:
- The
rtl: trueflag onarmakes the Payload admin itself flip to RTL when an Arabic editor is editing — it's a native experience for the editorial team, not a translated wrapper. fallback: truemeans a half-translated document never renders blank: missing AR fields fall back to the EN value, so launches aren't gated on every string being translated.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 "the AR copy is stale" bug class.- No
next-intl/i18next/react-i18nextin the stack. Translations are content, and content is Payload's job.
Wiring it to Next.js. The frontend layer is thin:
- Locale-prefixed routes under
/[locale]/(frontend). A typednormalizeSiteLocale(value)resolves the route param to a strict'en' | 'ar'so we never pass a stringly-typed value into a fetch. - Pass the resolved locale into Payload's local API (
getCachedGlobal('header', locale, 1),payload.findGlobal,payload.findfor collections) — Payload returns the localized values directly, no client merging step. - Direction set at
<html>withdir={locale === 'ar' ? 'rtl' : 'ltr'}. Logical Tailwind utilities (ps-,pe-,ms-,me-) handle component flipping;rtl:rotate-180mirrors directional icons (e.g. the funnel's back chevron). - One font for both scripts. Cairo loaded via
next/font/googlewithsubsets: ['arabic', 'latin'],display: 'swap', and a CSS variable. Single network request, correct fallback metrics, no FOUT. - Per-locale site metadata (site name, default title, title template, description, OG locale, alternate locales) lives in a single typed map (
getSiteMetadataByLocale) for the chrome that isn't editor-controlled — one place to change brand copy in either language. LanguageSwitcherannounces in the destination language. The toggle'saria-labelis"Switch to English"when the current locale is Arabic and"التبديل إلى العربية"when the current locale is English — so a screen reader speaks the target language correctly, not the current one.
Performance
The landing page exists to get someone to open the booking modal. Anything that delays first paint or competes with the hero image is a regression in the only metric that matters for this site.
- LCP image is preloaded at the layout level. When the header logo is set in the CMS, the layout emits
<link rel="preload" as="image">for it in<head>so the request leaves before any CSS or JS parses. Hero illustrations usenext/imagewith explicitwidth×height,fetchPriority="high",loading="eager", and per-breakpointsizes(370pxdesktop,100vwmobile) so the browser picks the right srcset entry instead of downloading the desktop asset on a phone. - The booking funnel is not in your initial bundle.
LazyBookingFunnelisdynamic(... { ssr: false })and renders nothing until the user opens it. The modal's RHF form, payment step, validation, pricing, lazy preview content — none of it ships on landing. - Server data is parallelised. The frontend layout fetches header, booking form config, and the current page concurrently via
Promise.allover threegetCachedGlobal(...)calls (revalidating withdepth: 1so we don't deep-populate references we won't render). One network round-trip's worth of latency, not three. - Sharp + Payload sizes pipeline. Every uploaded image gets multiple sized variants (including a dedicated
ogsize) generated on upload by Sharp, served from S3. The OG image renderer preferssizes.ogover the source, so social previews don't re-download multi-megabyte originals. - No SPA hydration cost on content pages. Pages render via React Server Components composing typed Payload blocks; the only client islands are the booking funnel, the language switcher, and individual interactive blocks (search, code-copy button).
Accessibility (WCAG 2.1 AA, audited)
A booking funnel that fails a screen-reader test fails the business — half of accessible-checkout work is dialog hygiene, the other half is making sure no interactive control is unreachable by keyboard. Concrete things I built in, not retrofitted:
- Skip-link to
#main-contentin the layout — visually hidden by default, becomes a focus-visible CTA on first Tab. The<main>has the matchingid. - Booking funnel is a real dialog.
role="dialog"+aria-modal="true"+aria-labelledbypointing to the current step's heading id. The heading text changes per step, and thearia-labelledbyreference picks it up automatically. - Decorative icons are silenced. Every Lucide icon (
X,ChevronLeft,CheckCircle2) carriesaria-hidden="true"; the surrounding button supplies the accessible name via CMS-drivenaria-label(modalCloseAriaLabel,modalBackAriaLabel) so the names are translated, not hardcoded. - Focus management. Body scroll is locked while the modal is open and restored on close. Escape closes (suppressed during submission via a ref so we never abandon a payment in flight). All controls have explicit
focus-visible:ringstyles, not just hover. - Live regions for failure paths. Submit errors render in
aria-live="polite"so screen-reader users hear a failed payment without having to re-tab to discover it. - State-reset on close. When the modal closes, step / form / errors are wiped — a returning user never starts step 1 with stale step-3 errors clinging on.
- RTL is a first-class direction, not a CSS afterthought. Buttons mirror, chevrons rotate, the hero card grid keeps the cutout on the physical right but flips text alignment, and the language switcher itself stays directionally LTR (so "English / عربي" reads consistently for either visitor).
- Color contrast verified at AA for the brand's plum / cream / rose palette across primary text, muted text, and the disabled-button state.
SEO (per-locale)
This is a brand site with no paid acquisition baseline; organic has to work in both languages.
generateMetadataper route + per locale.metadataBaseis set from the runtime origin,titleuses Next'sdefault+templateshape ("%s | Zan Pressed Flowers"/"%s | زان لحفظ الزهور"),descriptionandsiteNamecome from the per-locale metadata map.hreflangalternates at the layout level (alternates.languages: { en: '/en', ar: '/ar' }) plus per-page alternates from Payload entries. Search engines see explicit pairs, not a guess.- OG and Twitter cards per locale.
openGraph.localeisen_USorar_EG;alternateLocalelists the other. Each page can override the OG image; otherwise a brand fallback is used. Twitter card issummary_large_image. - Generated JSON-LD for
Organizationon the home andArticlefor journal posts — not a hand-rolled string, but a typed helper so a missing field is a TypeScript error, not a silent SEO bug. - Sitemap + robots generated post-build by
next-sitemap(which I configured to exposepages-sitemap.xml, disallow/admin/*, and resolve the canonical site URL fromNEXT_PUBLIC_SERVER_URL/ Vercel env in that order). The middleware-prevented duplicate (/[locale]/home) means the sitemap and<link rel="canonical">agree. - Static-by-default. Editor saves trigger Payload revalidation hooks; otherwise pages are served as cached static HTML from the edge. CMS latency does not enter user LCP.
Booking funnel — engineering notes
The funnel is the conversion moment, so it's the part that gets the most discipline.
- Single-responsibility files. Pure rules (
flowRules.tsfor button labels and disabled state,validation.tsfor field rules,lastMinute.tsfor date eligibility,orderSummary.tsfor pricing derivation,applyTemplate.tsfor default-from-config) are React-free and unit-tested with Vitest. The hook (useBookingFunnelFlow.ts) orchestrates state, side effects, and submit. The shell (index.tsx) does layout only. - Pricing is derived, not stored.
orderSummaryis(formData, config) → totals; there is no separate pricing state to drift from the form. That alone removes a category of bugs. - CMS-configurable catalogue. Frame types, sizes, designs, add-ons, copy strings, and ARIA labels all live in Payload's
booking-formglobal. Editors change the catalogue (and the Arabic version of every label) without a deploy. - Validation gated per step.
validateBookingForm(formData, { forSubmit, requirePhoto, messages })runs progressively: weak validation to advance steps, strict validation on the payment step. TherequirePhotoflag relaxes the photo requirement in development so the team can test without uploads. - Submission is idempotent. A
useRefguards re-entrancy; Escape is suppressed during in-flight submission; the finalConfirmedstep is shown only after the API returns success.
Outcome
- Bilingual (EN / AR · LTR + RTL) production site live, with editors composing pages from a typed catalogue of Payload blocks and managing both languages in a single document per piece of content.
- Booking funnel ships zero JS to the landing initial bundle (lazy +
ssr:false); when it does load it's a real WCAG-compliant dialog with keyboard, screen-reader, and RTL coverage. - Performance posture: preloaded LCP image, parallelised RSC data, per-breakpoint
sizes, Sharp-derived OG variant, static-by-default with on-demand revalidation. No CMS round-trip on the user's critical path. - SEO posture: per-locale
metadata,hreflangalternates on every route, JSON-LD, post-build sitemap with/admin/*disallowed, middleware-canonicalised homepage. - Test coverage spanning Vitest unit tests on every pure helper (validation, normalize-config, last-minute, order summary), component tests on the shell + language switcher + hero card + UI primitives, and Playwright E2E for the funnel happy path.