All work WEB PLATFORM / EDUCATION in progress

19  /  WEB PLATFORM / EDUCATION

Embassy.camp

Bookings cannot stop while I rebuild the site, which makes the migration the product, not a one-off script.

Original build live; rebuild May – September 2026WordPressElementor ProACFNext.js 15Payload CMS 3PostgreSQLTypeScriptnext-intl

The challenge

Embassy Camps sells language camps to families in four languages, taking registrations, camper documents and payments online. The first build put all of that on WordPress. Years of selling later, page content was locked inside Elementor markup, orders sat in WooCommerce's HPOS tables, and the media was split between registered attachments and files Elementor had written on its own. Moving off that stack meant carrying every page, article, camp, customer and historical order across, with nothing in the replacement quietly leaning on the old host.

What I built

The original site is live at embassy.camp: Elementor Pro layouts, ACF-driven content, and a WordPress plugin I wrote for bookings, which collects student data and surfaces it in a dedicated Camp Bookings area with filters and administrator checks. The replacement I am building is a Next.js 15 and Payload 3 application on Postgres, and there the migration is the product rather than a one-off script. Seventeen idempotent import steps carry authors, categories, tags, media, pages, posts, camps, coupons, customers, past camps, menus, redirects and 837 historical WooCommerce orders across, then derive bookings, booking documents and camp marketing pages from them, each step applying a catalogued list of data fixes at import time. I have not cut it over. WordPress remains the source of truth for as long as it is the thing still selling.

How it works

SOURCE OF TRUTH THE REPLACEMENT NOT YET WordPress, still livethe thing still selling Seventeen import stepsidempotent, fixes at import Next.js 15 + Payload 3Postgres, four locales Not cut overthe step not yet taken re-run, same result Waits on staging no cutover until it exists Now in Postgres: 837 legacy orders, 785 bookings, 2,698 media assets mirrored locally; every route checked 200 in all four locales
The mechanism, drawn from the build.

What it looks like

Embassy.camp — screenshot
Running locallyWEB PLATFORM / EDUCATION

The detail

01

Local media mirror

Every image, font and video was still being fetched from the old WordPress host, so the day that host went dark the new site would be broken images in the wrong typeface. I wrote a codemod to repoint them at a local mirror rather than a find-and-replace: about a fifth of the references are assembled from one- and two-letter prefix constants whose meaning changes from file to file, so it resolves constants per file and checks each target exists before writing the edit. A page still returns 200 when an image inside it 404s, so verification became fetching every asset on all 114 routes in all four locales.

02

Reading WooCommerce

The live site runs HPOS, so orders sit in wp_wc_orders and wp_wc_orders_meta rather than wp_posts, and the booking plugin's camper and parent fields are stored under short meta keys I had to find by inspecting the database instead of assuming from convention. I was matching five Korea orders to a camp by name, which held until a second Korea camp was created and the first one was renamed. I moved matching to the product id on the line item: a name is a label that gets rewritten, and the id is what WooCommerce charged against.

03

Bytes over headers

My document importer trusted Content-Type, and the old host served every .docx and .heic out of its uploads folder as text/plain, so it was throwing valid files away. It now reads magic numbers, which a misconfigured web server cannot get wrong, while still catching the case the header check existed for: a WordPress 404 answers 200 with HTML, and filing that as somebody's document is worse than not having it.

04

Private documents

I serve customer documents through a route that resolves both authentication systems in play, Clerk and Auth.js, and proves ownership against the caller's own bookings rather than treating a valid session as sufficient. Marking the files private in Payload was the easy half. The route also falls back to local disk when object storage is not configured, instead of answering 503.

05

Four locales

English, Arabic, Russian and Simplified Chinese share one routing and message setup, with per-locale type treatment: I set Arabic larger with its own display face, suppressed italics in Chinese, and gave Russian an explicit expansion budget on display headings. I store article bodies as a single HTML block rather than pretend Elementor markup converts losslessly into a typed block set, and the phase notes say so plainly. The Payload seed is additive and does not touch the render path, so I can make the switch from committed content to CMS content later, verify it, and revert it if it is wrong.

The outcome

The original site has been taking bookings for years and is still the one selling. The rebuild runs against a database holding 86 posts, 26 camps, 80 pages, 498 customers, 785 bookings, 837 archived legacy orders and 2,698 media records, with every route I checked returning 200 in all four locales. Phases 0 to 13 of the written plan are marked done, three of them with caveats the plan states itself: payment is a scaffold waiting on real keys, the admin CSV export shipped while the analytics dashboard is deferred, and the performance pass is code-side until there is a deploy to measure. Cutover waits on a staging environment.