Skip to content

SOPs

How We Build Websites

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/vite plugin (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.txt in 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.

Follow in order. Do not skip steps.

  1. Buy the domain. Cloudflare Registrar or Namecheap. If Cloudflare, the zone is already there.
  2. Create the Astro repo locally:
    Terminal window
    npm create astro@latest -- --template minimal [project-name]
    cd [project-name]
    npm install @tailwindcss/vite tailwindcss @astrojs/sitemap
    git init && gh repo create --private
  3. astro.config.mjs essentials:
    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()] },
    });
  4. package.json build 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"
  5. Initialize build.txt with echo 1 > build.txt.
  6. public/_headers for 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, immutable
  7. public/robots.txt with sitemap pointer:
    User-agent: *
    Allow: /
    Sitemap: https://yourdomain.com/sitemap-index.xml
  8. Self-host the font. Download Inter (or your chosen font) as woff2 into public/fonts/. Declare it in src/styles/global.css with font-display: swap and unicode-range subsetting for Latin only. Never <link> to fonts.googleapis.com.
  9. Base Layout.astro with viewport meta, canonical link, meta description, and a <slot />. Wrap main content in <main>.
  10. 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.
  11. Push to main. First deploy takes about two minutes.
  12. Add the custom domain in Cloudflare Pages → Custom domains. DNS happens automatically if the zone is on Cloudflare.
  13. Verify the site is live — curl returns 200, browser loads the page.
  14. Run the agentic PageSpeed loop (next section) until mobile and desktop both hit 100.
  15. 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.bot skips 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**
  • Action: skip remaining rules in the current ruleset
  • Expression: (cf.client.bot)
  • Why: Googlebot, Bingbot, the verified Facebook crawler, Slackbot, etc. need through. cf.client.bot is true only for crawlers Cloudflare has cryptographically verified (reverse-DNS + IP-range match), so it cannot be spoofed by a User-Agent string.
  • 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.
  • 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.

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:

Terminal window
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 phase
RID=$(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 rules
curl -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.json

Save the JSON above as local-site-rules.json next to wherever you run the command from.

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.

  • 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.
  • 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.

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.

  • 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.
Terminal window
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.tool

Three rules, in the order above, all enabled: true. Then watch the next day’s digest — bot percentage should drop to single digits.

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.

  1. Add site to Google Search Console via the /add-to-gsc skill. Submit the sitemap.
  2. Add site to the tms-internal Web Properties sidebar and stub a page.
  3. 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.

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.

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 not
stop until mobile and desktop are both 100. Show me the score after
every iteration.
  1. Claude Code hits the PageSpeed Insights API for the live URL.
  2. Reads the top opportunity from the response (image format, unused CSS, render-blocking resources, layout shift, whatever).
  3. Edits the code. One fix per commit. Do not batch five fixes into one build.
  4. Bumps build.txt, commits with Build X: short summary, pushes.
  5. Waits for Cloudflare Pages to publish (about 90 to 120 seconds).
  6. 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”
BuildFixWhy
16Convert hero + gallery images to WebP, add fetchpriority="high" to heroLCP was the hero photo
17Darken primary color from #0d9488 to #0f766e for AA contrastAccessibility a11y score
18–19Self-host Inter font with unicode-range subsetting, font-display: swapRemove Google Fonts render-blocking chain
20Add width and height to every <img>Eliminate layout shift (CLS)
21build.inlineStylesheets: 'auto' in astro.config.mjsInline critical CSS to unblock LCP
22Drop the font preloadWas competing with the hero image for bandwidth
23Replace mobile hero image with a pure CSS gradient + textText 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.

  1. WebP images with responsive sizes (15–25 points). Export every photo as WebP at quality 70. Keep a JPEG fallback. Use srcset for responsive sizes (800 for mobile, 1600 for desktop). Always include width and height. Mark the LCP image with fetchpriority="high".
  2. Inline critical CSS (10–15 points). build.inlineStylesheets: 'auto' in astro.config.mjs. External CSS is render-blocking; inlining unblocks LCP.
  3. 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.
  4. 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.
  1. 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.
  2. Semantic HTML and aria labels (5–8 points). <main>, <header>, <nav>, <footer>. aria-label on every icon-only button.
  1. Cache-Control: max-age=31536000, immutable on /fonts/, /images/, /_astro/ via _headers.
  2. JSON-LD structured data in the Layout head (LocalBusiness, Service, HouseCleaningBusiness, whatever fits).
  3. Canonical <link> on every page to stop duplicate indexing.

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; }
});
})();

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-label on every icon-only button
  • Minimum 44px touch targets (Tailwind py-3 px-6 or larger on CTAs)
  • Input font size minimum 16px (prevents iOS zoom-on-focus)
  • alt text on every image
  • Dark text on light or light text on dark. No gray-on-gray.
  1. npm run build locally to catch errors.
  2. npm run preview to sanity check the production build.
  3. echo $(($(cat build.txt) + 1)) > build.txt
  4. git commit -am "Build X: short summary" and git push origin main.
  5. Cloudflare Pages auto-deploys in 90 to 120 seconds.
  6. bash ~/apps/cc/verify-deploy.sh in the background — it polls the live site until the build number matches, then opens the page. Do not say “deployed” until it confirms.

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.

  1. Create a D1 database for the project via the Cloudflare API (or dashboard). Name it {project}-pageviews. Note the database_id it returns.
  2. Create the pageviews table. Schema lives in scripts/d1-schema.sql in the site repo. Key columns: ts, path, country, region, city, postal, lat, lon, ua, visitor_hash, referrer. The visitor_hash is sha256(ip + ua + YYYY-MM-DD) so daily uniques work without storing PII.
  3. Bind D1 to the Pages project as DB on both production and preview deployment configs via the Pages API (or dashboard). Without the binding, the Function cannot see the database.
  4. Add functions/api/track.js — POST endpoint that filters bot UAs + non-page paths, then inserts one row per pageview. Reads geo from request.cf.
  5. Add a fire-and-forget client snippet to the base Layout.astro, inside <body>, wrapped in requestIdleCallback so 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>
  6. Build the daily digest as a Python script in scripts/ that queries D1 via the Cloudflare HTTP API. Filter by region = '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.
  • Use Global API Key auth (X-Auth-Email + X-Auth-Key) for the D1 HTTP API. Scoped Bearer tokens with D1 Read + D1 Write permission groups return 401 on the /query endpoint as of April 15, 2026. This is Cloudflare weirdness, not a permissions bug — we already tested it.
  • wrangler.jsonc is not the source of truth for Pages bindings. Pages projects wired to GitHub auto-deploy read bindings from the dashboard config, not from wrangler.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.

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.

  • No template repo yet. Every new site reinvents the same astro.config.mjs, _headers, robots.txt, font CSS. Worth building a tms-site-template repo once the pattern is stable.
  • Sitemap integration missing on Go Kart Park. Add @astrojs/sitemap and 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/image or a pre-commit hook running cwebp would remove the friction.