All work WEB PLATFORM / TRAVEL live

17  /  WEB PLATFORM / TRAVEL

Singapore Guides

Excursions sell through a form nobody could prove reached a human. Now it cannot report a send it never made.

May – September 2026Next.js 15 (App Router)React 19TypeScriptPayload CMS 3PostgreSQLTailwind CSS v4nginx + pm2 on a VPSGitHub Actions

The challenge

Singapore Guides sells guided excursions, airport transfers and visas to Russian-speaking travellers. The site ran on WordPress and WooCommerce, carrying 266 posts, 63 services and roughly two years of accumulated URLs. Seventy-six of the 352 entries in the redirect map are percent-encoded Cyrillic. The owner could not change a photo without a developer, and nobody could say whether an enquiry form reached a human.

What I built

I rebuilt the site as one Next.js 15 App Router application with Payload CMS 3 running in the same process, backed by Postgres and deployed to a VPS behind nginx and pm2, with GitHub Actions shipping every push to main. I turned the WordPress export into Payload documents and seeded 326 of the 352-entry legacy redirect map into the database; trailing-slash-only rules I leave to middleware. Three systems sit on top of the rebuild: a live-edit layer for replacing any image or video straight on a public page, an agent-facing surface, and an IndexNow submission queue. Products are enquiry-only. I deliberately deferred payments, so there is no checkout.

How it works

THE ENQUIRY THE GUARDRAIL ONLY A REAL SUCCESS Booking formposts to a server action Resend HTTP APIemail to the sales address sendMail never throwsit returns Resend's reason A success it can provethe only kind it reports A visible error403: unverified sender WhatsApp fallback, and LOST ENQUIRY in the server log what a failed send leaves, instead of nothing happening
The mechanism, drawn from the build.

What it looks like

Singapore Guides — screenshot
Captured liveWEB PLATFORM / TRAVEL

The detail

01

Edit in place

An editor appends ?edit=1 to a public URL and every image and video on the page grows a badge; clicking one uploads a replacement. The parameter is not the gate. resolveEditMode re-checks the httpOnly Payload token on the server and requires an admin or editor role, because a client-side check there is a public write endpoint with extra steps. One flat Payload collection keyed "<route>:<slot>" backs all of it, so making a new component editable costs me one key and no schema migration.

02

Failure modes, named

I recorded five things that went wrong in the code as BITE #2 to #6, each next to the line that prevents it. A gallery keyed by array index renumbers the moment a photo moves, so identity is the media ID instead. An overlay with absolute inset-0 silently steals the click target, and twenty parallel multipart POSTs fail in ways that look like corruption, so uploads run three at a time through a queue.

03

Answers to agents

I hand-wrote /api/mcp as a Model Context Protocol server: JSON-RPC 2.0 over stateless Streamable HTTP, with version negotiation and five read-only tools over the published catalogue. It declares no resources, prompts, sessions or auth, and the route file explains why each is absent rather than stubbing it. Alongside it sit an in-browser WebMCP bridge with six tools, Accept: text/markdown negotiation done as a middleware rewrite into /md/* (Vary: Accept on every response including the misses, falling back to HTML rather than a 406), /openapi.json, a public REST v1, Content Signals in robots.txt, and SKILL.md documents indexed at /.well-known/agent-skills/index.json.

04

A queue that cannot fail a save

Publishing a page enqueues a row and a timer drains it; the drain is the only code that touches the network, and in production it is idle because no submission key is set yet. An editor fixing a headline five times in two minutes produces one submission, because a pending row for the same URL slides its retry window forward instead of inserting again, and the coalesced row keeps the earliest event type. I let nothing in the module throw, since a rejected promise inside a Payload afterChange hook would show an editor a failed save for a document that saved correctly.

05

Never a false success

The booking form posts to a server action that emails the sales address through Resend's HTTP API. Resend's most common failure is an unverified sender domain, which returns a 403 that looks like nothing happening, and a send-only key cannot read /domains to check itself at boot. So I made sendMail never throw and never report a success it cannot stand behind: it returns Resend's own reason text, and the action turns a real failure into a visible error with a WhatsApp fallback and a LOST ENQUIRY line in the server log.

06

Repairing broken slugs

The old export had cut product slugs at roughly 40 characters with no regard for word boundaries, shipping URLs like /products/srochnaya-viza-v-singapur-dlya-grazhdan-sn, where "sn" is a truncated СНГ. I wrote a dry-run-first script that renames only the genuinely broken ones, leaves short-but-clean slugs alone, and writes a permanent redirect for every rename, served as a 308. I keep slugs transliterated Russian rather than English, because the visitors search in Russian, and re-running the script is a no-op.

The outcome

The site is live at singaporeguides.ru, served from Postgres behind nginx, and I lifted the staging noindex at cutover. The markdown variant, the MCP endpoint, /openapi.json, /.well-known/api-catalog and a 399-URL sitemap all respond in production. The slug repair is deployed and checkable from outside: the old truncated URLs redirect to their repaired forms. A signed-in editor can now replace any image or video on a public page in place, and I wired the booking form to Resend behind a guardrail that shows a visible error with a WhatsApp fallback rather than a false success.