- Jun 7, 2026
- 7 min read
Shipping Custom Sites: The Practices I Keep Across Every Project
After building the website audit tool I went back through every site I run: a solver’s marketing site, a technical blog, a consulting landing page, an ebooks library, the audit tool itself and this site. Each one had grown its own habits. Some were excellent, some were template leftovers nobody had looked at since the first commit.
This post keeps the excellent ones. It’s the checklist I now apply to every custom site I ship, for myself or for a client, with the real snippet behind each item.
1. Static first, and deploy only what you committed
Every site here is Astro with output: 'static', served by Cloudflare Pages.
No server to patch, nothing to scale, and a page is a file.
Two habits make that safe:
- Build from a clean checkout. A working folder collects drafts. For
direct-upload projects I build from a
git worktreeof the commit, runnpm cithere (which also proves the lockfile is complete), and deploy thatdist/. Details in deploying direct-upload Pages from a git worktree. - Tag before a migration.
git tag -a pre-<change>on the last commit before a big change makes “put the old site back” a single checkout.
2. One canonical address
Pick apex or www, redirect the other with a 301, and redirect the
*.pages.dev hostname too, or analytics and search engines split one site
into three. I keep a small script that curls every variant and fails on a
wrong status.
(apex vs www and pages.dev redirects)
3. Legal pages as Markdown
The best version I had was on the consulting site: privacy and terms live in
an Astro content collection, rendered by one LegalLayout.
---
import LegalLayout from '../layouts/LegalLayout.astro';
import { getEntry } from 'astro:content';
const privacy = await getEntry('legal', 'privacy');
const { Content } = await privacy.render();
---
<LegalLayout title={privacy.data.title} lastUpdated={privacy.data.lastUpdated}
description={privacy.data.description}>
<Content />
</LegalLayout>
Why Markdown wins for legal text:
- It’s reviewable. A policy change is a diff, with a date in frontmatter.
- It travels. The same file structure works on every site; the audit
tool simply imports its
privacy.mdinto a page. - It can be checked before deploy. The solver site starts from a template
with
{{BUSINESS_NAME}}-style placeholders, andmake deployruns this first:
// Fail the deploy if legal pages still contain {{PLACEHOLDER}} tokens.
for (const file of await readdir(dir)) {
const text = await readFile(path.join(dir, file), "utf8");
for (const token of new Set(text.match(/\{\{[A-Z_]+\}\}/g) ?? [])) {
console.error(`check-legal: ${file} still contains ${token}`);
missing++;
}
}
if (missing) process.exit(1);
Two content rules matter more than the format: write the policy against the code, not the plan (every retention period and processor should exist in code), and use one contact address that actually reaches you.
4. Forms without third parties
Every form posts to a same-origin endpoint (/api/forms/submit) handled by
one Worker, and every form works without JavaScript: a real <form action>
that redirects back with ?form=sent, enhanced with fetch when scripts
load. A hidden honeypot field catches dumb bots; optional consent is its own
unticked box; subscriptions go through double opt-in.
(one forms Worker for every site)
The payoff: no survey widget, no email relay, no third-party script on any page with a form, which is every article page here.
5. Analytics without cookies
Cloudflare Web Analytics, cookieless, at the zone or Pages level. When I switched, the lesson was to remove the old analytics code, not just stop configuring it: env-driven snippets for a dropped tool were still in four layouts.
6. Headers in one file
Cloudflare Pages reads public/_headers verbatim. The strongest one I have,
from the ebooks site:
/*
Strict-Transport-Security: max-age=31536000; includeSubDomains
X-Frame-Options: DENY
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=()
Content-Security-Policy: default-src 'self'; object-src 'none'; frame-ancestors 'none'; …
And from the solver site, the caching half:
/_astro/*
Cache-Control: public, max-age=31536000, immutable
/fonts/*
Cache-Control: public, max-age=31536000, immutable
Hashed build assets can be cached forever; HTML can’t. For an installable
web app, two more rules saved me hours: serve the manifest as
application/manifest+json (some CDNs send octet-stream and the install
prompt vanishes) and send the service worker with Cache-Control: no-cache
(or an old app version sticks around). One warning: _headers must be
committed, or a clean build ships without it.
7. Content features that pay for themselves
| Feature | Where | Why |
|---|---|---|
Sitemap (@astrojs/sitemap) | solver, ebooks | Search engines find every page |
RSS (@astrojs/rss) | solver | Readers and aggregators |
| Open Graph images rendered with headless Chrome | solver | Every page looks deliberate when shared |
| Pagefind search | ebooks | Full-text search with no server |
KaTeX math (remark-math + rehype-katex) | ebooks | Equations render at build time |
| Mermaid diagrams | this site, ebooks | Diagrams live next to the text, in the repo |
Code samples executed in CI (verify-snippets.py) | solver | Every snippet on the site actually runs |
| Scheduled rebuilds | blog | Future-dated posts appear on their day |
For diagrams, validate before you push: render each block with
mermaid-cli and look at the PNG.
(validating Mermaid diagrams)
8. Quality gates
- Type-check with
astro check. - Lighthouse CI with real thresholds. The consulting site fails the build below 0.9 on performance:
"assert": { "assertions": {
"categories:performance": ["error", { "minScore": 0.9 }]
}}
- Test the UI states you can’t easily trigger, like “check your inbox”, with Playwright route mocking against the built site. (route mocking for form states)
- Audit the result like a stranger would. Run your own site through the free audit tool: it’s humbling.
9. Copy as data
The consulting landing keeps hero text, FAQ, process steps and social proof
in content collections; the audit tool keeps all of its copy (including the
consent wording) in one site.md frontmatter file. Changing a headline or a
call-to-action is a content edit, not a component change, and the copy
stays reviewable in one place.
10. One mark, from one clean source
Generate every icon size from one high-resolution file: favicon.ico
(16/32/48), the Apple touch icon (180) and the app icons (192/512). The trap
I found: a template’s default SVG favicon left in place, which browsers that
prefer SVG show instead of your logo.
11. Optional features behind a setting
The consulting chatbot and the audit tool’s database are both off by
default, behind PUBLIC_CHATBOT_ENABLED and PB_ENABLED. The code stays,
tested and documented, and turning a feature back on is one line in .env
instead of an archaeology project.
The checklist
| # | Practice | Done when |
|---|---|---|
| 1 | Static build, deployed from a clean checkout | dist/ comes from a commit, never a working folder |
| 2 | One canonical address | apex/www and pages.dev all 301 to one host |
| 3 | Legal pages as Markdown | A content collection + a pre-deploy check, policy matches code |
| 4 | Forms without third parties | Same-origin endpoint, works without JS, honeypot, separate consent |
| 5 | Cookieless analytics | One tool, old snippets deleted |
| 6 | Headers in _headers | HSTS, CSP, nosniff, Referrer/Permissions-Policy, immutable assets, committed |
| 7 | Content features | Sitemap, OG images, search/math/diagrams where they help |
| 8 | Quality gates | astro check, Lighthouse CI thresholds, UI states tested |
| 9 | Copy as data | Headlines and CTAs editable without touching components |
| 10 | Icons from one source | No template leftovers |
| 11 | Optional features behind settings | Off by default, one line to enable |
Takeaways
- Your best practices are probably already in one of your projects; the work is making them the default everywhere.
- Legal text, headers and copy are code: put them in the repo, review them as diffs, check them before deploy.
- Template leftovers (an analytics snippet, a default favicon, an
uncommitted
_headers) are where good sites quietly regress.