The phone call about changing a room price isn’t a support request. It’s a symptom of broken infrastructure.
These five B&B marketing sites are one layer of a direct-booking system: sites that own their own traffic via Google Ads, backed by a booking signal tracker that closes the loop between ad spend and actual reservations. The ads engine can optimize toward real bookings only if pricing and promotions stay current between campaigns. That means owners need to update content themselves — not on a two-day cycle that runs through me. Every architectural decision on these projects traced back to that constraint. A developer dependency at the content layer breaks the whole economic model.
Hybrid output, not static vs SSR
The common framing of “static or SSR” missed the actual shape of these sites. Customer-facing pages — room cards, photo galleries, pricing tables — don’t change between requests. Admin routes need to run at request time: OAuth state validation, GitHub App commit, session management. The right answer was output: 'server' in astro.config.mjs with export const prerender = true on every public page. Astro emits those pages as static HTML at build time, indistinguishable from a pure-static build. Unmatched routes — the admin console — hit the Node runtime.
One site is genuinely static: fixed pricing, no dynamic requirements, deployed to Firebase Hosting with output: 'static'. Firebase’s CDN handles immutable assets with year-long cache headers; HTML at five minutes. No server, no cold starts, no failure surface.
The other four run on Railway with @astrojs/node. Two use standalone mode — Astro’s entry point handles everything. Two use middleware mode, wrapping Astro in an Express server that adds gzip and brotli compression before responses leave the container. Astro’s standalone adapter doesn’t compress; that Express layer was worth a measured ~890ms reduction in Lighthouse’s text compression penalty on mobile. Cloudflare sits in front of all Railway deployments: room pages almost always hit cache, so SSR cost lands only on form submissions and cache misses. Cold starts are real — a few seconds after inactivity — but they only affect the first visitor after a quiet period, not the high-traffic paths.
The adapter swap between standalone and middleware cost nothing in component code. Two projects changed adapter mid-build when requirements shifted. That flexibility was the reason to choose Astro over Next.js for this shape of site.
The image pipeline has more moving parts than one script
Every client handed over a folder of JPEGs, some over 8MB. The naive fix — convert to WebP — misses a few things that matter at scale.
The actual pipeline runs at build time before astro build and produces per-source-image: a legacy full WebP at 1600px for existing page references, a thumbnail WebP at 700px for gallery use, and six responsive variants (480, 960, 1600px in both WebP and AVIF). Quality settings differ by format — WebP at 72/74/78 by breakpoint, AVIF at 55/60/65 — because AVIF’s encoder achieves equivalent visual quality at substantially lower quality numbers. One site adds a 2400px breakpoint for retina coverage on hero images where the source was high enough resolution to support it.
The pipeline checks mtime before encoding. On a repo with 40 source images, a cold run produces 9 output files per source; subsequent runs skip files where the source hasn’t changed. That check matters when the pipeline runs on every CI build — without it, 40 images × 9 variants × ~2s each adds an eight-minute penalty to every deploy.
The legacy path exists because pages were migrated incrementally to a ResponsivePicture component that uses the srcset variants. Dropping legacy WebP paths would break galleries still referencing <stem>.webp directly. The pipeline keeps both so pages opt in to responsive treatment one at a time, without a migration gate holding up new sites.
The admin console was the engineering problem
Three clients wanted to update promotional banners and room cards themselves. The naive answer — give them a CMS — failed on the first site before it generalized. Every owner took the credentials, used the dashboard for two or three weeks, and stopped. CMS dashboards assume an editor workflow. These owners run operations through WhatsApp and phone calls. A content management interface is a second job they didn’t agree to.
The replacement was a purpose-built form backed by the GitHub API. The owner fills a form — banner text, promotional period, room card details — submits it, and the admin console commits the change to the repository and triggers a Railway redeploy. Two minutes later the live site reflects it. No terminal, no credentials to remember, no call to me.
The implementation uses a GitHub App rather than a personal access token. This matters for three reasons: App authentication credentials are scoped to the installation, commits appear under the App bot’s identity rather than Wayne’s personal account, and the same App installation can target different repos per deployment without credentials overlap. The admin auth layer is Google OAuth with an email allowlist validated server-side — no account creation step for the owner, and the allowlist means a leaked link doesn’t grant access.
One non-obvious decision: the targetBranch() function maps the BASE_URL environment variable to the correct git branch. A dev Railway deployment writes to dev; staging writes to staging; the production deployment writes to main. The same codebase, the same admin code, with zero changes needed when promoting between environments. This matters because the admin console itself needs to be tested across environments without risk of dev edits contaminating the live branch.
Photo card writes use the git tree API for atomicity — a single commit lands the updated JSON, the uploaded JPEG, and the converted WebP together. The owner never sees a half-applied state where the site shows a card with broken image references.
The remaining failure mode: a CI build fails silently from the owner’s perspective. They submit, wait two minutes, and the site doesn’t change. Build status notifications route to a contact email as a stopgap. It’s imperfect, but build failures on these projects are rare enough that the failure rate has stayed manageable.
Tailwind v4 and the font loading gap
CSS-first configuration — design tokens in a .css file instead of tailwind.config.js — required about a day to internalize and then felt genuinely cleaner than v3, especially sharing color palette tokens across five projects without a shared package. A handful of utility names changed between versions; ring utilities and gradient helpers were the recurring collision. Twenty minutes per project to fix; across five projects that’s half a day I hadn’t budgeted.
The font loading problem was the one I found twice before fixing it properly. Google Fonts loaded as a render-blocking stylesheet on two sites before I caught it. The fix — media="print" on the <link> with onload switching it to all — is standard, but the v4 font configuration change was different enough from v3 that the blocking behavior wasn’t visible until late in the build cycle each time. It went into the base layout template after the second occurrence.
A different performance investment: several sites use inlineStylesheets: 'always' in astro.config.mjs. Astro’s default 'auto' inlines stylesheets under 4KB and emits a separate file for larger bundles. The shared Tailwind bundle on these sites ran ~12KB — large enough to fall back to a render-blocking external request even with the stylesheet already likely in browser cache, adding measurable LCP delay. Inlining eliminates that round-trip at the cost of larger HTML per page, but on a site with fewer than 50 pages gzipping well, the LCP improvement is worth the trade.
The drift problem in retrospect
The image conversion script went into every project as a copy. By the fourth repo, four versions had quietly diverged: different target widths in one, an extra quality flag in another, different output path conventions in a third. Each project had surfaced a constraint the previous one hadn’t hit, and I adjusted locally without propagating the change. That’s the first architectural decision I’d revisit: a shared internal package from the second project, not four loosely synchronized copies.
The whole stack — Astro with hybrid output, GitHub App admin, Firebase for static and Railway for SSR — was reverse-engineered from one constraint: owners can update their own content without calling me. That dependency was the real blocker, not the OTA commission rate. Commission rates are far higher than most people realize, but the reason small operators stay on platforms isn’t ignorance — it’s that building an alternative that they could actually operate used to require ongoing developer access. Without AI-assisted development, delivering five custom sites with integrated admin consoles at a budget these clients could absorb wasn’t an economically viable proposition. The service tier simply didn’t exist before.