SOPs
How We Build Websites
The Stack
Section titled “The Stack”Every new site starts with the same tech. No exceptions unless there is a specific reason (a real backend for bookings, payments, dashboards — in that case the public marketing pages still go on this stack, and the backend lives somewhere else).
- Framework: Astro 5+ — static site generator, compiles to plain HTML, zero runtime JavaScript overhead.
- Styling: Tailwind CSS 4 via
@tailwindcss/viteplugin (not PostCSS — Vite plugin is faster). - Fonts: Self-hosted woff2, never Google Fonts CDN.
- Hosting: Cloudflare Pages — auto-deploy on
git push, free tier, global edge caching, automatic TLS. - DNS + CDN: Cloudflare (apex + www CNAME to
[project].pages.dev). - Repo: Private GitHub repo,
build.txtin the root, CLAUDE.md with the repo conventions.
All three reference sites on April 15, 2026 — Springville House Cleaning, All Things Handy Utah, Go Kart Park — use this exact stack. Go Kart Park is on Astro 6.0.6 and the other two on 5.17.1, but the pattern is identical.
New Site Checklist
Section titled “New Site Checklist”Follow in order. Do not skip steps.
- Buy the domain. Cloudflare Registrar or Namecheap. If Cloudflare, the zone is already there.
- Create the Astro repo locally:
Terminal window npm create astro@latest -- --template minimal [project-name]cd [project-name]npm install @tailwindcss/vite tailwindcss @astrojs/sitemapgit init && gh repo create --private astro.config.mjsessentials:import { defineConfig } from 'astro/config';import tailwindcss from '@tailwindcss/vite';import sitemap from '@astrojs/sitemap';export default defineConfig({site: 'https://yourdomain.com',trailingSlash: 'always',build: { inlineStylesheets: 'auto' },integrations: [sitemap()],vite: { plugins: [tailwindcss()] },});package.jsonbuild script copies the build number into the public folder so the live site can expose it:"build": "cp build.txt public/build.txt && astro build"- Initialize
build.txtwithecho 1 > build.txt. public/_headersfor aggressive asset caching:/fonts/*Cache-Control: public, max-age=31536000, immutable/images/*Cache-Control: public, max-age=31536000, immutable/_astro/*Cache-Control: public, max-age=31536000, immutablepublic/robots.txtwith sitemap pointer:User-agent: *Allow: /Sitemap: https://yourdomain.com/sitemap-index.xml- Self-host the font. Download Inter (or your chosen font) as woff2 into
public/fonts/. Declare it insrc/styles/global.csswithfont-display: swapand unicode-range subsetting for Latin only. Never<link>to fonts.googleapis.com. - Base
Layout.astrowith viewport meta, canonical link, meta description, and a<slot />. Wrap main content in<main>. - Hook up Cloudflare Pages via GitHub Actions. Follow the Cloudflare Pages — Deploy via GitHub Actions Only SOP. Never leave a Pages project as direct-upload — pushes get silently ignored and content sits stuck for days. Copy the template workflow, add the two GitHub secrets, done.
- Push to main. First deploy takes about two minutes.
- Add the custom domain in Cloudflare Pages → Custom domains. DNS happens automatically if the zone is on Cloudflare.
- Verify the site is live — curl returns 200, browser loads the page.
- Run the agentic PageSpeed loop (next section) until mobile and desktop both hit 100.
- Ask: is this a local-only site? A site is local-only if customers come from one geographic area (a city, a metro, a state) and there is no international audience, no overseas affiliates, no foreign-language content, and no e-commerce that ships outside the US. Handyman, house cleaning, window cleaning, go-kart park, lawn care, regional service businesses — local. SaaS, course platforms, national blogs, e-commerce that ships nationally or internationally — not local. If the answer is yes, apply three Cloudflare WAF custom rules at the zone level on day one: allow verified bots (
cf.client.botskips remaining rules, so Googlebot/Bingbot/the real Facebook crawler get through), block non-US traffic (ip.geoip.country ne "US"), and block/xmlrpc.php(WordPress probe path, our sites do not run WordPress so any hit is hostile). Order matters — the bot-skip rule must fire first so verified crawlers do not get caught by the country block. This pattern was codified on 2026-05-15 after the daily traffic digest for All Things Handy Utah surfaced 1,048 of 1,840 requests in 24 hours coming from a single Singapore-based Go scraper. The rules went to four local-only zones the same day:allthingshandyutah.com,cascadewindowcleaning.com,springvillehousecleaning.com,gokartpark.com. If the site is national or international, skip this step.
**Show the rules, JSON payload, and API recipe**
Rule 1 — Allow verified bots (skip)
Section titled “Rule 1 — Allow verified bots (skip)”- Action:
skipremaining rules in the current ruleset - Expression:
(cf.client.bot) - Why: Googlebot, Bingbot, the verified Facebook crawler, Slackbot, etc. need through.
cf.client.botis true only for crawlers Cloudflare has cryptographically verified (reverse-DNS + IP-range match), so it cannot be spoofed by a User-Agent string.
Rule 2 — Block non-US traffic
Section titled “Rule 2 — Block non-US traffic”- Action:
block - Expression:
(ip.geoip.country ne "US") - Why: Local-only sites have no legitimate foreign visitors. Block the country, not individual IPs — countries are stable, IP-block lists rot.
- Adjust per business: if the business serves Canada too, use
(ip.geoip.country ne "US" and ip.geoip.country ne "CA"). If state-only matters more than country, prefer(ip.geoip.country ne "US" or ip.geoip.subdivision_1_iso_code ne "US-UT")— but country-only is the default because state geolocation is less reliable.
Rule 3 — Block /xmlrpc.php
Section titled “Rule 3 — Block /xmlrpc.php”- Action:
block - Expression:
(http.request.uri.path eq "/xmlrpc.php") - Why: WordPress XML-RPC probe traffic. None of our sites run WordPress, so any hit on this path is hostile. Pre-existing rule on most zones, included here for completeness so a new zone gets all three in one shot.
The Payload — Copy-Pasteable
Section titled “The Payload — Copy-Pasteable”What was actually pushed to the four zones on 2026-05-15. Drop the rules array into a PUT against the zone’s custom firewall ruleset entrypoint and you reproduce the live config.
{ "rules": [ { "action": "skip", "action_parameters": { "ruleset": "current" }, "description": "Allow verified bots (Googlebot, Bingbot, FB crawler, etc.) — skip remaining rules", "enabled": true, "expression": "(cf.client.bot)", "logging": { "enabled": true } }, { "action": "block", "description": "Block all non-US traffic (local business, US-only customers)", "enabled": true, "expression": "(ip.geoip.country ne \"US\")" }, { "action": "block", "description": "Block xmlrpc.php bot scanning", "enabled": true, "expression": "(http.request.uri.path eq \"/xmlrpc.php\")" } ]}Apply via the API:
eval "$(python3 ~/apps/cc/secrets/secrets_cli.py env)"ZONE="example.com"ZID=$(curl -s "https://api.cloudflare.com/client/v4/zones?name=$ZONE" \ -H "X-Auth-Email: $CF_EMAIL" -H "X-Auth-Key: $CF_GLOBAL_API_KEY" \ | python3 -c "import sys,json; print(json.load(sys.stdin)['result'][0]['id'])" --bare 2>/dev/null \ || curl -s "https://api.cloudflare.com/client/v4/zones?name=$ZONE" \ -H "X-Auth-Email: $CF_EMAIL" -H "X-Auth-Key: $CF_GLOBAL_API_KEY" \ | python3 -c "import sys,json; print(json.load(sys.stdin)['result'][0]['id'])")
# Pull the entrypoint ruleset ID for the custom firewall phaseRID=$(curl -s "https://api.cloudflare.com/client/v4/zones/$ZID/rulesets/phases/http_request_firewall_custom/entrypoint" \ -H "X-Auth-Email: $CF_EMAIL" -H "X-Auth-Key: $CF_GLOBAL_API_KEY" \ | python3 -c "import sys,json; print(json.load(sys.stdin)['result']['id'])")
# Push the three rulescurl -s -X PUT "https://api.cloudflare.com/client/v4/zones/$ZID/rulesets/$RID" \ -H "X-Auth-Email: $CF_EMAIL" -H "X-Auth-Key: $CF_GLOBAL_API_KEY" \ -H "Content-Type: application/json" \ --data @local-site-rules.jsonSave the JSON above as local-site-rules.json next to wherever you run the command from.
Optional Add-Ons
Section titled “Optional Add-Ons”After the three rules are in place, two zone-level toggles are worth flipping. Both are free and both layer on top of the WAF rules — not substitutes.
Bot Fight Mode
Section titled “Bot Fight Mode”- Where: Security → Bots → Bot Fight Mode (or Pro plans get Super Bot Fight Mode).
- Why: Adds JavaScript challenges to suspicious traffic the free WAF rules do not catch. Catches scrapers that fake a US IP via VPN but cannot execute JS.
- Risk: Can occasionally flag legitimate uptime monitors. Whitelist via the WAF “skip” rule if a real monitor gets caught.
Browser Integrity Check
Section titled “Browser Integrity Check”- Where: Security → Settings → Browser Integrity Check. On by default.
- Why: Lightweight check for malformed headers and known abusive browsers. Confirm it is on for every new zone.
Region-Tracking Beacon
Section titled “Region-Tracking Beacon”Optional, only if the owner wants Utah-vs-rest-of-US breakdowns in their daily digest. The pattern uses a Pages Function writing to D1 (or Workers Analytics Engine). Full implementation is documented in the Analytics and Tracking section below — same pattern as Springville House Cleaning’s build 24.
Country-level breakdowns come free from Cloudflare zone analytics. Region/city breakdowns require the beacon. Most local sites do not need it; add only on request.
What This Does Not Do
Section titled “What This Does Not Do”- Does not replace the six-knob hardening. Still run Cloudflare Zone Hardening — SSL strict, Always HTTPS, Min TLS 1.2, HSTS, DNSSEC, root SPF. The WAF rules sit on top, not in place of.
- Does not stop a determined attacker. A scraper on a US residential proxy will pass the country block. The rules are a noise filter, not a security boundary.
- Does not affect Cloudflare Pages auto-deploy. WAF rules apply to public hostnames, not to the Pages deploy infrastructure.
Verifying the Rules Are Live
Section titled “Verifying the Rules Are Live”eval "$(python3 ~/apps/cc/secrets/secrets_cli.py env)"ZONE="example.com"ZID=$(curl -s "https://api.cloudflare.com/client/v4/zones?name=$ZONE" \ -H "X-Auth-Email: $CF_EMAIL" -H "X-Auth-Key: $CF_GLOBAL_API_KEY" \ | python3 -c "import sys,json; print(json.load(sys.stdin)['result'][0]['id'])")
curl -s "https://api.cloudflare.com/client/v4/zones/$ZID/rulesets/phases/http_request_firewall_custom/entrypoint" \ -H "X-Auth-Email: $CF_EMAIL" -H "X-Auth-Key: $CF_GLOBAL_API_KEY" \ | python3 -m json.toolThree rules, in the order above, all enabled: true. Then watch the next day’s digest — bot percentage should drop to single digits.
Precedent
Section titled “Precedent”2026-05-15 — All Things Handy traffic noise. Daily digest surfaced 57% of requests coming from a single Singapore-based Go scraper. Pushed the three rules to four local-only zones in one pass: allthingshandyutah.com, cascadewindowcleaning.com, springvillehousecleaning.com, gokartpark.com. Codified the question and the payload here so the next new local site gets the rules at spin-up time, not after a bot wave shows up in analytics.
- Add site to Google Search Console via the
/add-to-gscskill. Submit the sitemap. - Add site to the tms-internal Web Properties sidebar and stub a page.
- Wire up the pageview tracker (see "Analytics and Tracking" below) if the site needs state or city level breakdowns for reporting. Not needed for every site — Cloudflare's default analytics are fine if country level is enough.
The Agentic PageSpeed Loop
Section titled “The Agentic PageSpeed Loop”This is the exact pattern that took Springville House Cleaning from 82 to 100 on mobile and desktop during the 7FA training call on April 15, 2026. Builds 16 through 23 of the Springville repo are the audit trail.
Prompt
Section titled “Prompt”For [domain]: run Google PageSpeed Insights, identify the top LCP /CLS / TBT blocker, fix it in the repo, bump build.txt, commit, push,wait for the Cloudflare Pages deploy, rerun PageSpeed, and do notstop until mobile and desktop are both 100. Show me the score afterevery iteration.What actually happens each iteration
Section titled “What actually happens each iteration”- Claude Code hits the PageSpeed Insights API for the live URL.
- Reads the top opportunity from the response (image format, unused CSS, render-blocking resources, layout shift, whatever).
- Edits the code. One fix per commit. Do not batch five fixes into one build.
- Bumps
build.txt, commits withBuild X: short summary, pushes. - Waits for Cloudflare Pages to publish (about 90 to 120 seconds).
- Reruns PageSpeed, reports the new score, loops.
Expect 5 to 10 iterations on a fresh site. Each loop is about 5 minutes end to end. Budget an hour for a full sweep on a brand new site.
The order Springville’s fixes actually landed
Section titled “The order Springville’s fixes actually landed”| Build | Fix | Why |
|---|---|---|
| 16 | Convert hero + gallery images to WebP, add fetchpriority="high" to hero | LCP was the hero photo |
| 17 | Darken primary color from #0d9488 to #0f766e for AA contrast | Accessibility a11y score |
| 18–19 | Self-host Inter font with unicode-range subsetting, font-display: swap | Remove Google Fonts render-blocking chain |
| 20 | Add width and height to every <img> | Eliminate layout shift (CLS) |
| 21 | build.inlineStylesheets: 'auto' in astro.config.mjs | Inline critical CSS to unblock LCP |
| 22 | Drop the font preload | Was competing with the hero image for bandwidth |
| 23 | Replace mobile hero image with a pure CSS gradient + text | Text becomes LCP instead of a 42K image |
Note Build 22: preloading the font was the “obvious” optimization, and it actively made the score worse on mobile. The loop caught it. Trust the loop over your intuition.
Performance Playbook (Ranked By Impact)
Section titled “Performance Playbook (Ranked By Impact)”High impact
Section titled “High impact”- WebP images with responsive sizes (15–25 points). Export every photo as WebP at quality 70. Keep a JPEG fallback. Use
srcsetfor responsive sizes (800 for mobile, 1600 for desktop). Always includewidthandheight. Mark the LCP image withfetchpriority="high". - Inline critical CSS (10–15 points).
build.inlineStylesheets: 'auto'inastro.config.mjs. External CSS is render-blocking; inlining unblocks LCP. - Self-host fonts with unicode-range subsetting (8–12 points). Download woff2, declare with
font-display: swap, subset to Latin only. Do not preload if you have a large LCP image. - Make text the LCP, not an image (8–12 points when possible). On mobile, a CSS gradient background with headline text is faster than any image.
Medium impact
Section titled “Medium impact”- AA color contrast (5–8 points). 4.5:1 for normal text, 3:1 for large text. Use darker primaries if your palette is too light.
- Semantic HTML and aria labels (5–8 points).
<main>,<header>,<nav>,<footer>.aria-labelon every icon-only button.
Lower impact (do it anyway)
Section titled “Lower impact (do it anyway)”Cache-Control: max-age=31536000, immutableon/fonts/,/images/,/_astro/via_headers.- JSON-LD structured data in the Layout head (LocalBusiness, Service, HouseCleaningBusiness, whatever fits).
- Canonical
<link>on every page to stop duplicate indexing.
Tab Re-Engagement (Title Flip)
Section titled “Tab Re-Engagement (Title Flip)”When a visitor switches away from the tab, flip the browser title between the real title and 3-4 short teasers with leading emojis every 2 seconds, then restore on return. The motion in the tab bar is what catches the eye; the emoji at the front is the hook. Pick the teasers to match what the page is actually selling — a workshop signup might lean urgency, a portfolio might lean curiosity, a product page might lean social proof. Never hardcode “book a call.” Three to four teasers, each under 30 characters, varied emoji categories, no fake urgency unless the page is genuinely time-bound.
Drop this at the bottom of the page’s main JS:
(function () { var real = document.title; var teasers = [/* 3-4 strings with leading emoji, picked per-page */]; var timer = null, i = 0; document.addEventListener("visibilitychange", function () { if (document.hidden) { i = 0; document.title = teasers[0]; timer = setInterval(function () { document.title = (++i % 2) ? real : teasers[(i / 2) % teasers.length]; }, 2000); } else { clearInterval(timer); timer = null; document.title = real; } });})();Mobile And Accessibility Defaults
Section titled “Mobile And Accessibility Defaults”Bake these in on day one. Retrofitting later is painful.
<meta name="viewport" content="width=device-width, initial-scale=1.0" />- Semantic HTML (
<main>,<header>,<nav>,<section>,<footer>) aria-labelon every icon-only button- Minimum 44px touch targets (Tailwind
py-3 px-6or larger on CTAs) - Input font size minimum 16px (prevents iOS zoom-on-focus)
alttext on every image- Dark text on light or light text on dark. No gray-on-gray.
Deploy And Verify
Section titled “Deploy And Verify”npm run buildlocally to catch errors.npm run previewto sanity check the production build.echo $(($(cat build.txt) + 1)) > build.txtgit commit -am "Build X: short summary"andgit push origin main.- Cloudflare Pages auto-deploys in 90 to 120 seconds.
bash ~/apps/cc/verify-deploy.shin the background — it polls the live site until the build number matches, then opens the page. Do not say “deployed” until it confirms.
Analytics and Tracking
Section titled “Analytics and Tracking”Cloudflare’s default zone analytics stop at country granularity. There is no clientRegionName or clientCity in the Cloudflare GraphQL schema, which means if a site needs “Utah visitors only” or “top Utah cities” in a daily report, the defaults cannot deliver it.
The pattern below persists request.cf (country, regionCode, city, postalCode, lat, lon) to a D1 table on every pageview. Bots that do not execute JavaScript never hit the tracker, so the dataset already skews human without any filtering.
Springville House Cleaning is the reference implementation (build 24, April 15, 2026). Use the same shape for any future site that needs sub-country reporting.
The Pattern
Section titled “The Pattern”- Create a D1 database for the project via the Cloudflare API (or dashboard). Name it
{project}-pageviews. Note thedatabase_idit returns. - Create the
pageviewstable. Schema lives inscripts/d1-schema.sqlin the site repo. Key columns:ts,path,country,region,city,postal,lat,lon,ua,visitor_hash,referrer. Thevisitor_hashissha256(ip + ua + YYYY-MM-DD)so daily uniques work without storing PII. - Bind D1 to the Pages project as
DBon bothproductionandpreviewdeployment configs via the Pages API (or dashboard). Without the binding, the Function cannot see the database. - Add
functions/api/track.js— POST endpoint that filters bot UAs + non-page paths, then inserts one row per pageview. Reads geo fromrequest.cf. - Add a fire-and-forget client snippet to the base
Layout.astro, inside<body>, wrapped inrequestIdleCallbackso it never blocks rendering:<script is:inline>const send = () => fetch('/api/track', {method: 'POST',headers: { 'Content-Type': 'application/json' },body: JSON.stringify({ path: location.pathname, referrer: document.referrer || null }),keepalive: true,}).catch(() => {});if ('requestIdleCallback' in window) requestIdleCallback(send, { timeout: 2000 });else setTimeout(send, 0);</script> - Build the daily digest as a Python script in
scripts/that queries D1 via the Cloudflare HTTP API. Filter byregion = 'UT'(or whatever state the business serves), group by path for top pages, group by city for the city breakdown. Schedule via cron on the local machine or the VPS.
Gotchas
Section titled “Gotchas”- Use Global API Key auth (
X-Auth-Email+X-Auth-Key) for the D1 HTTP API. Scoped Bearer tokens withD1 Read+D1 Writepermission groups return 401 on the/queryendpoint as of April 15, 2026. This is Cloudflare weirdness, not a permissions bug — we already tested it. wrangler.jsoncis not the source of truth for Pages bindings. Pages projects wired to GitHub auto-deploy read bindings from the dashboard config, not fromwrangler.jsonc. Bind via the Pages API or dashboard, not wrangler.- Do not add curl-like UAs to the bot filter. Real browsers also sometimes send short UAs. Keep the filter narrow (bot, crawl, spider, etc.) so legitimate visitors are never dropped.
- The client-side ping is fire-and-forget. No retry, no analytics of its own. If the endpoint 500s, you lose the row. That is fine — the tracker is for trend reporting, not audit logging.
What’s Different Per Site
Section titled “What’s Different Per Site”Springville House Cleaning. Pure marketing, form submits to Resend + a CRM. The freshest example of the full pattern. Sitemap integration on. JSON-LD HouseCleaningBusiness schema. Reference implementation for the D1 + Pages Function pageview tracker — daily digest filters to Utah visitors and breaks down top cities.
All Things Handy Utah. GHL (GoHighLevel) is the backend. Form submissions go through a Cloudflare Pages Function at functions/api/submit-form.js that forwards to the GHL Contacts API. Google Places Autocomplete on the address field. Sticky top bar with a Text Me Now CTA.
Go Kart Park. Real backend on the VPS (~/apps/claude-code-crm/, FastAPI). The website is marketing only. Centralized theme.ts holds colors, fonts, pricing, FAQ, and GHL embed URLs. Playwright tests for regression. No sitemap integration yet — add it.
Rule of thumb: Marketing only → plain static Astro. Payments, bookings, or dashboards → add a separate FastAPI backend, keep Astro for the marketing pages.
Known Gaps / TODOs
Section titled “Known Gaps / TODOs”- No template repo yet. Every new site reinvents the same
astro.config.mjs,_headers,robots.txt, font CSS. Worth building atms-site-templaterepo once the pattern is stable. - Sitemap integration missing on Go Kart Park. Add
@astrojs/sitemapand backfill. - No automated PageSpeed regression. Scores can drift after content changes. A weekly cron that hits PageSpeed Insights on every site and emails if any score drops below 95 would catch it.
- Image pipeline is manual. Exporting WebP at multiple sizes is a tedious pre-commit step.
@astrojs/imageor a pre-commit hook runningcwebpwould remove the friction.