The direct-booking stack for a B&B in Hengchun runs on a simple premise: own the conversion surface. That means a fast Astro site, GA4 contact events as conversion proxies for Google Ads, and promotional banners and cards that reflect what the property is actually offering this week. The ads engine can optimize toward contact intent only as long as the site content is accurate. An outdated promo doesn’t just fail to convert — it drives contact that can’t be fulfilled, which trains the algorithm on bad signals.
For the first several months, every content update routed through me. WeChat message in, terminal open, edit JSON, push, wait for Railway to build. Forty-five minutes on a good day. Two weeks if it arrived on a weekend. That’s not a low-maintenance system — that’s a developer-mediated external dependency with slower response time than calling an OTA.
The obvious answer was a lightweight headless CMS. The answer I’d watched fail with other operators: two weeks of active use, then the platform becomes an app that never opens. These owners aren’t avoiding technology — they’re deeply practical about it. The mental model is “I need to change the summer package banner.” Nothing in that sentence maps to “content collection,” “rich text node,” or “entry field.” Bridging that vocabulary gap requires ongoing developer support, which recreates the dependency in a new interface, adds credential sprawl, and introduces a new failure surface between the owner and the live site.
The content was already in the repo. The persistence layer already existed. The gap was a write interface that didn’t require knowing what a commit was.
Architecture: one process, two modes
The site runs on Railway with @astrojs/node in standalone mode. Customer-facing pages export prerender = true — they build to static HTML and are served as files. Admin routes don’t. They’re SSR, running on the same Node process, protected by a session check on every request.
That output mode choice was deliberate. output: 'server' with marketing pages opting into prerendering means Railway hosts one process that serves both fast, cacheable static pages and a live admin console. No second service. No separate deployment target. No infrastructure that exists only to support an interface one person uses.
Auth: infrastructure already in her daily life
Google OAuth via arctic, 7-day signed JWT sessions via jose. No new credentials — the owner already has Gmail, a Google Maps listing, and Google Business Profile. The same Google account that manages the property’s presence across Google’s ecosystem unlocks the admin console.
Access is gated by an email whitelist stored in a JSON file in the repo itself. Adding or removing an email is an admin action that commits a change back to the repo, same as any other write. A seed admin email in the Railway env vars is a permanent recovery hatch — no UI operation can revoke it, so there’s no path to accidentally locking out the only admin with filesystem access.
The JWT carries the user’s email. On every admin request, the server reads the cookie, verifies the signature, checks the email against the current whitelist. No session store. No database lookup.
GitHub writes: App, not token
Not a personal access token.
A GitHub App. The distinction matters more than it might look. App installation tokens are short-lived and auto-rotated by GitHub — there’s nothing to rotate manually, nothing to revoke if a token leaks from a deploy config. The App is installed per repository, so its write scope is bounded by the installation, not by whatever the token creator had access to the day they minted it. Every commit carries the App as committer and the signed-in admin’s Google email as author — so git log shows who pressed save.
The write path is consistent across all admin actions: read the target JSON file from the deploy branch, capture its blob SHA, update the data structure, write it back with the SHA. That SHA is the API’s optimistic concurrency control. Send a stale one and the write returns 409. For a single-admin console that’s an edge case. For a multi-admin scenario it’s the central design question — does the system track what it’s overwriting, or is last-write-wins the implicit policy.
Atomic photo card commits
A photo promo card requires three files to land together: the original JPEG, a WebP version generated server-side by sharp, and the updated JSON file. Three sequential createOrUpdateFileContents calls would expose a partial state window where the JSON references an image that doesn’t exist yet, or an image exists with no card referencing it.
The fix is the git data API. Upload each blob independently to get its SHA, compose a new tree on top of the current branch tip, create a commit with that tree, advance the branch ref in a single update. One network round-trip per blob, then one commit that lands all three files atomically. A partial state is structurally impossible — the commit either lands completely or doesn’t land.
Deleting a photo card runs the same pattern in reverse: build a tree with sha: null for the JPEG, the WebP, and the card’s entry in the JSON, commit it. One operation removes all three.
The local sandbox
A storage abstraction layer checks whether BASE_URL contains localhost. If it does, reads go to the local filesystem and writes go there too — no API calls, no commits. Vite’s HMR picks up the file change and the dev dashboard reflects it instantly. The full round-trip through GitHub and Railway only runs on Railway.
On Railway, the same code routes all reads and writes through Octokit. The branch targeted — dev, staging, or main — is resolved from the Railway service URL. The prod admin writes to main. The staging admin writes to staging. The dev admin writes to dev. Same codebase, three environments, zero configuration changes between them.
What the owner now controls
The PromoBanner strip at the top of the homepage — date-bounded bilingual text with active/pending/expired status visible in the dashboard. PromoCard blocks — text cards or photo cards with bilingual copy, CTAs, seasonal date ranges, and optional image uploads. The admin whitelist itself.
Every change auto-generates a commit message that names what changed. Six months of banner and card updates are in the git log with enough context to reconstruct the editorial history without any purpose-built audit infrastructure.
The developer dependency is gone. Replaced by a commit log, a Railway build, and 90 seconds.