A Website Requirements Checklist Where Every Line Can Fail
Seven requirements written as pass/fail acceptance criteria, each with the number this storefront actually measured — and the defects that changed the list.
A website requirements checklist is only useful if every line can fail. "Fast", "accessible" and "secure" are wishes; "Lighthouse accessibility 100 on every route", "one <h1> per page" and "no external script host that is not named in the CSP" are requirements, because a build can check them. Below is the list we hold this storefront to, with the measured result next to each line.
Most requirements checklists are written before the site exists and read once. That is the wrong order of operations. The useful ones are written as acceptance criteria — a number, a command, a pass/fail — and they are edited every time the site teaches you something. Ours has been edited by defects: a blog index with no <h1>, a 255 KiB image in a 33 KiB slot, a database client shipped to pages with no login. This post is the version of the list that survived.
If you are past requirements and about to ship, use the launch checklist instead — it is the executable pre-deploy pass. This one is the spec that comes first.
Requirements as acceptance criteria
Each row below has a category, the requirement as a testable statement, and the evidence from this codebase. The last column is the point: a requirement with no evidence is a hope.
| Category | Requirement, stated so it can fail | Measured here |
|---|---|---|
| Accessibility | Lighthouse accessibility 100 on every indexable route, desktop and mobile | 100 on all 8 sampled page types, both presets |
| Structure | Exactly one <h1> per route, no skipped heading levels | Sweep of 16 sampled routes: all pass |
| Performance | Lab performance 100 desktop, 89+ mobile; CLS 0 | Desktop 100 on all 8; mobile 89–99; CLS 0 |
| Weight | No image served at more than ~2× its rendered size | Cover art 255 KiB → 33 KiB |
| Search | Per-page title, description, canonical, sitemap entry, robots rule | src/app/sitemap.ts, src/app/robots.ts |
| Security | A Content-Security-Policy in which every allowed host is named and justified | 11 directives in next.config.ts |
| Content truth | Every link target and file reference resolves | Enforced by npm test |
Two things to notice. Every threshold is a number or an existence check, and none of them is "looks good". And the measured column is honest about the misses: mobile performance is 89–99, not 100, and the reason is documented rather than hidden — see the Lighthouse accessibility write-up for how we treat the score versus the underlying defects.
Accessibility: the requirement that found real bugs
The requirement is "100 on every indexable route". Getting it on the pages that existed at launch was easy. The value came from re-running it on pages added later, which turned up three defects:
/bloghad no<h1>at all. The index title usedSectionHeading's defaulth2, so the page's subject was unstated for crawlers and screen-reader users. The fix was one prop:as="h1", with card titles dropped fromh3toh2to keep the outline contiguous./docsjumped fromh1toh3. The first section heading inDocsContentis nowh2.BlogTagmissed AA contrast by a hair.text-primary(#465fff) onbg-primary-25(#f2f7ff) at 12px measures 4.49:1; AA needs 4.5:1. Moving the text totext-primary-600fixed it.
Nothing in that list is exotic, and none of it was caught at design time. Each was caught because the requirement was "every route" rather than "the pages we looked at". Write the scope into the requirement.
Performance: state the budget per asset, not per page
A page-level score tells you something is wrong; it does not tell you what. The requirements that moved our numbers were per-asset:
| Asset | Before | After | Requirement it implies |
|---|---|---|---|
| Product cover in a card | 255 KiB, 2120×1325 | 33 KiB WebP, 1060w | Serve a variant sized for the slot |
| Site logo (every page) | 70 KiB, 681×239 | 9 KiB, 272×95 | Source pixels ≤ ~2× rendered size |
| Hero art | 87 KiB | 45 KiB, plus an 800w srcset alternate | Offer a smaller candidate for small screens |
| Marketing images (19) | 1793 KiB | 550 KiB | Right format for the content |
The rule underneath is that the original stays for the job that needs it. The cover JPEG is still full size because it is the og:image and the product schema image; components render generated -card.webp and -view.webp derivatives instead. The build step is one command:
npx tsx scripts/images/build-variants.ts
npm test fails if a product is missing its variants, so the requirement cannot rot when someone adds product number 112.
The single largest win was not a compression setting. The first card on /templates was loading="lazy", and that card is the page's LCP element. The browser could not discover the image until layout ran, which cost 1.67 seconds of load delay. TemplateCard now takes a priority prop that the first card of a page-opening grid sets; every other card stays lazy. Requirement: the LCP element is never lazy-loaded. It is a one-line check that a component review can hold.
The requirement you only find by reading the bundle
Both the site header and the ownership lookup imported the Supabase client at module scope. That put roughly 68 KiB gzipped (255 KiB parsed) of auth code in the initial bundle of /, /blog, /docs and /pricing — pages with no account UI. The fix is src/lib/supabase/lazyClient.ts, which imports the client dynamically and skips it entirely when no sb-*-auth-token cookie exists.
The requirement it produced: a page with no feature X does not ship the code for feature X. It sounds obvious. It is rarely written down, and it is the sort of thing a requirements list is for. Server-side gates such as RLS and authorizeDownload are unchanged, because a loading shortcut must never be the thing that protects data.
Security: name every host
A generic "site must be secure" requirement fails the moment someone asks how you would know. The testable version is a Content-Security-Policy where each allowed origin is justified. In next.config.ts the policy is built from an explicit list, and the comment block above it records why each host is present: the LemonSqueezy overlay script, its checkout iframe, the Vercel analytics host, the preview iframes, and the Supabase origin derived from an environment variable rather than hard-coded.
The policy also states its own compromise. 'unsafe-inline' is allowed on script-src and style-src because Next.js injects an inline runtime script and this app has an inline theme bootstrap. A nonce-based policy would force every route to render per request and break static generation. Write that trade-off into the requirement instead of pretending the policy is stricter than it is. Two more lines are cheap: frame-ancestors 'none' with X-Frame-Options: DENY as the old-browser fallback, and Strict-Transport-Security set. preload is left off on purpose — it is a hard-to-reverse commitment that only takes effect after manual submission.
Search: crawlability is a requirement, not a plugin
The search requirements are short and all of them are files:
src/app/robots.tsallows/and disallows the account routes (/dashboard,/login,/signup,/forgot-password,/reset-password,/auth), mirroring thenoindexmetadata on those pages.src/app/sitemap.tslists every indexable route with alastModifiedthat is a real date, never the build clock. Telling a crawler that 100+ URLs changed on every deploy is a checkable false claim, andnpm testasserts every sitemap date is midnight UTC — something a clock reading never is.- A page that is
noindexis also absent from the sitemap. A URL you ask Google not to index and also submit is a contradiction.
Per-page titles and descriptions belong here too; the meta tags guide covers the field limits.
How to write your own list
- Start from user-visible failures, not framework features: cannot read it, cannot find it, cannot pay, cannot trust it.
- State a threshold and a scope. "Accessibility 100 on every indexable route" beats "accessible".
- Attach a command. Lighthouse for scores, a grep for headings, a test for file existence. If you cannot name the command, the requirement is a wish.
- Record the measured value beside it, including the misses.
- Add a line every time a defect ships. The list should grow from evidence.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Lighthouse 100 locally, 96 Best Practices | Analytics beacon 404s off-platform | Re-measure on the deployed domain; the audit is errors-in-console |
Accessibility passes but a page has no <h1> | Score audits do not require exactly one heading | Add a heading-outline sweep across all routes |
| LCP image loads late | It is loading="lazy" | Set priority on the first card of the grid |
| Big JS bundle on static pages | Auth or SDK client imported at module scope | Import dynamically inside an effect, gated on a cookie |
| Sitemap dates change on every deploy | new Date() in lastModified | Use declared or content-derived dates |
| Small text fails contrast by ~0.01 | Brand colour on a tint at 12px | Use the next colour step down |
Frequently asked questions
How many requirements should a website checklist have? Few enough that each one has an owner and a command. Seven to ten categories with a measurable line each beats a fifty-item document nobody reruns.
Should the checklist include design requirements? Only the testable ones: contrast ratio, minimum tap target, a maximum rendered image size. "Looks modern" belongs in a brief, not a checklist.
Is a Lighthouse score a requirement or a symptom?
Both. The score is a fast alarm; the requirement is the underlying property, such as one <h1> per page or an LCP image that is not lazy. Hold the property, and use the score to notice regressions.
Does buying a template satisfy these? It gives you a measured baseline to start from, not a pass. Re-run every line against your own content and your own images, because that is where the defects arrive.
Templates that start with a baseline
The templates below ship with real routes, per-page metadata, sitemap and robots files, and accessible markup, so the requirements above start from a passing state instead of from zero.
