The sequence mattered before a single line of code did. You can’t run a Google Ads campaign for a small B&B if the only place for paid clicks to land is an OTA profile — every conversion you drive still pays their commission, and their retargeting pixel gets fed with your ad spend. The campaign needed a direct-booking landing surface first. That’s what this project is: Step 1 of a stack where the site exists, then analytics goes live to capture intent signals, then the ads turn on with a destination worth paying to drive traffic to.
This site launched in June as that foundation. The Google Ads campaign follows.
What “direct booking” actually requires structurally
There are no platform links anywhere on this site. No partner badge, no “also available on,” no backup exit for guests who hesitate. That absence is intentional — a site that routes hesitant visitors to an OTA is just a prettier OTA referral page. The client had seen exactly this pattern: “Best Rate Guaranteed” hero sections with buttons that linked to Booking.com.
OTAs hold their search positions through three compounding advantages: years of accumulated ad spend dominating search and social, brand trust built over a decade across millions of reviews, and full-time conversion teams running A/B tests on checkout flows. A single property site doesn’t touch any of that by existing. What it does is give the ads campaign a place to close — which is why the site is necessary infrastructure, not a vanity project.
AI tooling made this financially viable for a client who couldn’t staff any of it. The analytics architecture, image pipeline, admin system, and ad infrastructure each used to require dedicated headcount. Compressing that into a single engagement meant a property that would never have had this channel can now field the full stack.
The admin console is the product guests never see
The most important thing I built isn’t visible in the booking flow. It’s the console the owner uses between my visits.
She can push seasonal promos — a Mother’s Day banner, a new package card — without calling me. Google OAuth handles authentication, with the allowlist managed entirely in Railway environment variables: a seed admin account that can never be locked out, and an optional comma-separated list of additional admins that takes effect on the next request. No config file to edit, no redeploy to trigger just to add a new email. When she submits a promo, the admin writes the update to the repo via GitHub App API — a commit from the app, not from her — and Railway detects the push and deploys within 1-2 minutes. The UI is a friendlier front door to a git commit.
Self-service matters here because she’s the one with visibility between sessions. She sees which dates have gaps, which holidays are approaching, which package ideas are worth testing. If the tool doesn’t work or doesn’t feel trustworthy, those opportunities don’t get pushed.
Three decisions that mattered more than they appeared:
Preview inline, not in a new tab. Early version opened preview in a new tab. On a phone — how she reviews a promo card at 10pm — nobody switches tabs to verify layout. Moving preview into the form kept typing and confirmation in one view.
One color for the entire banner. Initial design allowed per-line color selection. The first test post came out looking like a pride flag. I collapsed it to a single color for the whole strip — fewer choices, better result every time.
Revalidate on HTML and promo images. Homepage and promo images were cached for an hour. She made a change, saw nothing update, concluded the tool was broken, and stopped using it. I switched those routes to max-age=0, must-revalidate so edits show up in the promised 1-2 minutes. The _astro build assets stay immutable (they’re content-hashed, so the filename changes on every edit). A tool the owner abandoned because it seemed broken is worse than no tool.
Measuring the moment before someone calls
GA4 via gtag.js, with three-tier host gating baked in at the client level. The production hostname fires real events into the GA4 property. localhost fires into GA4 DebugView with debug mode on, so I can verify event structure locally without polluting the dataset. Every other hostname — the dev Railway deploy, any staging URL — loads nothing. There’s no server-side filtering involved; the check is in the bootstrap script itself.
The events are all CTAs: phone tap, LINE add, online booking link, map directions, reviews. Pageviews are ambient noise. For this operator, most bookings close over LINE or a phone call — not through a trackable checkout. A click proxy in GA4 is the closest available signal to an actual conversion: a phone tap means a guest who had already decided. Those signals feed a weekly report showing which CTAs fired per session and which sessions ended with no contact at all, which changes which promos get prioritized the following week.
81 megabytes doesn’t load on a hotel-browsing connection
The raw photo set was 81MB. At the mobile connection speed someone uses while browsing accommodations at 11pm, that site doesn’t finish loading — the guest closes the tab before the hero image renders.
I built a WebP/AVIF pipeline using sharp that produces three widths (480, 960, 1600) in both formats, committed into the repo. On Railway, the build’s conversion step is a near-instant no-op since the variants are already present — only new images trigger actual encoding. The quality tuning was: WebP at 70-76 depending on width, AVIF at 44-50 (visually indistinguishable from higher quality on these photos but ~30% smaller). No sharpening — sharpening inflates compressed format size because high-frequency detail encodes poorly in WebP and AVIF. Instead, each variant gets a gentle pop: saturation 1.1, mild linear contrast. It gives the photos presence without the grain shimmer that full sharpening introduced on softer source images.
<head> optimizations: async font loading, preloaded AVIF hero, system Chinese fonts instead of a CJK webfont (a subset easily exceeds 400KB for imperceptible visual difference on a B&B site), all CSS inlined to eliminate render-blocking stylesheet round-trips. Gzip and Brotli via Express compression middleware. The 12-second initial render block is gone.
The admin writes to git, so security means something
Because the admin console commits to the repository via GitHub App, this isn’t a brochure site with a contact form — it has a real attack surface. I added path-aware CSP and HSTS in security-headers.mjs, running the policy in Report-Only mode first via a Railway environment flag to watch violations accumulate before enforcing.
Path-awareness is essential here. The public pages need Google Fonts and GA4 (googletagmanager.com, fonts.googleapis.com, fonts.gstatic.com, *.google-analytics.com). The admin console needs none of those but does need blob: and data: for the upload preview thumbnails and frame-src 'self' for the inline <iframe srcdoc> preview. A blanket policy that covers both either over-permits on the public side or breaks the console. Two separate allowlists, keyed by whether the request path starts with the admin prefix, solve this cleanly.
The commission column is still in her spreadsheet. June is the first month it has competition.