The math behind direct bookings over OTA is straightforward — no commission, higher margin, a guest relationship the property actually owns. What makes it fail in practice: the OTA has professional marketing staff, a full-time digital team, and an ad budget large enough to dominate search results on every holiday weekend. The property has one developer and a static site that requires a code push to change the banner.
That’s where this project stood before this work. A B&B site in Hengchun: fast, well-ranked, technically solid. Every promotional content update — seasonal pricing, short-window holiday packages, post-typhoon offers to fill a quiet week — routed through me. The owner would text the night before a long weekend asking if the promo could go up. The marketing cycle runs on hours; the content layer ran on developer availability.
The site failing to rank was never the problem. The problem was the landing page drifting from whatever the owner was actually pushing while the ads were already running.
SSR was not optional, and the Railway proxy broke the first thing
Google OAuth requires a server-side callback endpoint. Admin form submissions that write back to the repository need somewhere to land. Either requirement rules out static output.
Astro’s Node adapter with output: 'server' gave the admin routes a live process. The public marketing pages each kept export const prerender = true — they lose nothing except the comfort of a fully static deploy. The cost is carrying a live server for admin routes that see infrequent traffic.
The Railway proxy rewrites the Host header on inbound requests. Astro’s default origin check compares that rewritten header against the application’s expected host and rejects mismatches with a 403. Every admin form submission was blocked on first test. The fix: disable security.checkOrigin in astro.config.mjs. That’s a deliberate choice, not an oversight — the admin layer is already protected by Google OAuth state, a signed JWT session verified on every request, and an email allowlist. The origin check was redundant and, with a rewriting proxy in front, incorrect. The rationale is documented in the config comment rather than left for the next engineer to reconstruct.
The alternative to SSR was a third-party headless CMS: a monthly subscription, a second credential set, and an entirely separate system to onboard someone who just wants to update a banner. One login, one site. Railway’s monthly fee buys exactly that.
Two streams in one repository, and git cannot tell the difference
Code changes and owner-set promotional data both live in the same repo. The failure mode is not a merge conflict — those surface visibly. A code commit on dev carries forward whichever version of promoBanner.json and promoCards.json exists on that branch. When that commit promotes to staging, it silently replaces whatever the owner had set there. Build succeeds. Deploy completes. No error. Her banner is gone.
Protecting owner data required two things to be true simultaneously. First: promotional content had to leave inline .astro component markup and become standalone data files — src/data/promoBanner.json, src/data/promoCards.json, and src/data/adminWhitelist.json. You cannot write a script to protect content that hasn’t been separated from code. Second: promote.sh. When running dev → staging or staging → main, the script checks out the destination branch’s own copy of each data file before the merge commit lands. The source branch’s versions are discarded.
The edge case that only appears at first promotion: the destination branch has no prior copy of the file. Nothing to restore. Without handling this, the script would inherit the source branch’s test promos onto a fresh production deploy. The solution is seeding blank state — [] for promo arrays, {"emails": []} for the whitelist — so every environment starts clean and dev’s test content never reaches main on first push. The same applies to the promo image directory: a blank .gitkeep is seeded rather than inheriting dev’s uploads.
GitHub App writes, sandbox mode for local, and atomic commits for photo uploads
The admin writes back to the repository through a GitHub App (@octokit/auth-app), not a personal access token. The App has its own installation per deployment environment, and targetBranch() reads BASE_URL at runtime to determine which branch an admin instance writes to. The same codebase runs in three environments — dev, staging, and production — with no code differences between them, only environment variables.
On localhost, the storage layer bypasses GitHub entirely and writes directly to the local filesystem. Vite HMR picks up the change immediately. No GitHub credentials required to develop against the admin UI. No accidental commits to the live repo during testing.
Photo card writes go through the Git Data API as a single atomic commit: upload blobs, compose a new tree against the branch tip, create the commit, advance the ref. A new promo card means a JPEG, a converted WebP, and an updated promoCards.json — all three land in one commit. The dashboard has no observable half-state where the image exists but the JSON hasn’t updated, or vice versa.
The admin whitelist has a SEED_ADMIN_EMAIL env var that permanently bypasses the file check. Removing every email from the UI does not lock out the seed admin — isAllowedEmail() checks the file first, then falls back to the seed regardless. Adding or removing other admins goes through the same GitHub App pipeline, writing to adminWhitelist.json. No config files to touch.
The economics
OAuth authentication, branch-aware GitHub App commits, atomic photo uploads, a live bilingual preview so the owner can see both the Traditional Chinese and English versions before saving, and a promotion pipeline that actively protects owner data from code merges: this is not a feature set that gets built for a client this size under the traditional cost structure. Engineering time costs more than the problem it solves at this scale.
AI-assisted development compressed the delivery enough to flip that calculation. The edge-case analysis — what happens at first promotion, what happens when the proxy rewrites headers, what happens if the whitelist call fails during auth — didn’t disappear. The storage abstraction that makes local development work without touching GitHub required real design work. The time shrank to days, not weeks, and the economics followed.
The banner she set at 11pm is still there when the deploy runs the next morning.