Per-Template Baselines for CMS Sites
A content-managed site publishes thousands of URLs but ships only a dozen or so distinct page shapes. Every article renders through one template, every landing page through another, every category listing through a third. This how-to, part of the Segmenting Baselines by Page Type reference, shows you how to attach a performance baseline to the template rather than the URL, so an editorial team can publish ten thousand articles without minting ten thousand budgets. You fingerprint templates from the rendered output, pick one representative URL per template, store a single baseline per template, and drive a CI job that samples those representatives on every pull request.
Per-URL baselines break the moment a CMS grows. A new article appears with no history, so a naive gate either skips it or compares it against nothing. Per-template baselines solve that inheritance problem structurally: the new article inherits the article template's budget the instant it is published. That inheritance mechanism is covered in depth in Baseline Inheritance for New Routes; here we focus on defining the templates themselves and wiring them into a gate.
Why Per-Template Baselines Beat Per-URL Baselines
The economics are simple. A baseline is a stored expectation — an LCP, INP, and byte figure you compare each new build against. Storing one per URL means the store grows linearly with content, and most entries hold a single noisy sample that no percentile-based threshold tuning can stabilise. Storing one per template means the store stays fixed at the number of templates, and every entry aggregates thousands of pages, giving you a dense sample to compute a stable P75 from.
Templates also match how regressions actually happen. When a developer changes the article header partial, every article regresses together; when they change the product gallery component, every product page regresses together. The blast radius of a code change is the template, not the URL. A gate keyed to templates therefore catches the regression on the first representative URL it measures, instead of hoping the one URL that happened to change is in your sample set.
Fingerprinting Templates From Rendered Output
You cannot trust the CMS's own template names — they drift, get aliased, and rarely map one-to-one to a rendered shape. Instead, derive a template fingerprint from the DOM the browser actually paints. A fingerprint is a stable signature computed from structural signals that survive content changes: the set of landmark selectors present, the count of major layout slots, the presence of hero media, and the body's data-template attribute if the CMS emits one. Hash those signals into a short digest and pages that render the same shape collapse to the same fingerprint.
A workable fingerprint function runs in the browser context during a synthetic run. It reads structural landmarks, normalises them, and returns a short hash. Keep it deterministic — sort selector sets, ignore text content, and never fold in the URL path, or you defeat the whole point.
// fingerprint.js — evaluated in the page context during a Lighthouse/Puppeteer run
function templateFingerprint() {
const slots = [...document.querySelectorAll('[data-slot], main, aside, [role="banner"]')]
.map((el) => el.getAttribute('data-slot') || el.tagName.toLowerCase())
.sort();
const signals = {
declared: document.body.getAttribute('data-template') || 'unknown',
hasHero: Boolean(document.querySelector('.hero, [data-hero]')),
slotCount: slots.length,
slots: slots.join(','),
hasArticleBody: Boolean(document.querySelector('article .prose, .article-body')),
hasProductGrid: Boolean(document.querySelector('.product-grid, [data-listing]')),
};
const raw = JSON.stringify(signals);
let hash = 0;
for (let i = 0; i < raw.length; i += 1) {
hash = (hash * 31 + raw.charCodeAt(i)) >>> 0;
}
return { template: signals.declared, digest: hash.toString(16).padStart(8, '0') };
}
Run this across a crawl sample of a few hundred URLs, group by digest, and name each cluster. You will typically find that 95% of your traffic lands in three to six clusters. Those clusters are your templates.
Choosing a Representative URL Per Template
A representative URL is the single page you will actually measure in CI to stand in for the whole template. Pick it deliberately, because a bad representative silently widens or narrows the budget for thousands of pages. Choose a URL near the median content weight for its cluster, not the lightest stub and not the heaviest outlier. For an article template that means an article with a typical body length, one hero image, and the usual embed count — not a 200-word news brief and not a 6,000-word investigation stuffed with galleries.
Anchor the representative on stable content so its own bytes do not drift week to week. Prefer an evergreen article over today's homepage, and a fixed demo product over a seasonal one. If a single URL cannot represent the cluster's spread, promote two representatives — a "typical" and a "heavy" variant — and budget them separately. Selecting the median rather than the mean matters because content weight is right-skewed; a handful of enormous pages drag the mean upward and would inflate every budget in the cluster.
A Baseline Per Template
Store one baseline row per template, computed from the P75 of the field or synthetic distribution for that cluster. Percentiles are non-negotiable here — a template baseline built on the mean hides the slow tail that real users feel, and the reasoning for P75 over the harsher P90 is laid out in Choosing Between P75 and P90 Budget Targets. The table below shows a concrete starting matrix. Every ceiling is a P75 figure captured on mid-range mobile over Fast 3G, the device-and-connection profile you calibrate with device and network emulation weighting; desktop budgets belong in a separate matrix as covered in Mobile vs Desktop Budget Divergence.
| Template | Device + connection | LCP P75 (ms) | INP P75 (ms) | JS transfer P75 (KB) |
|---|---|---|---|---|
| article | Mid-range mobile, Fast 3G | 2800 | 200 | 180 |
| landing | Mid-range mobile, Fast 3G | 2500 | 180 | 150 |
| listing | Mid-range mobile, Fast 3G | 3200 | 240 | 260 |
| product | Mid-range mobile, Fast 3G | 3000 | 220 | 240 |
| article | Desktop, cable | 1800 | 120 | 180 |
| landing | Desktop, cable | 1600 | 110 | 150 |
Treat these as opening figures, then let historical baseline calibration tighten them once you have a few weeks of green builds. A template whose article P75 LCP settles at 2400 ms on mid-range mobile over Fast 3G should have its ceiling ratcheted down toward that figure, not left slack at 2800 ms forever.
A CI Job That Selects Sample URLs Per Template
The gate reads a manifest that maps each template to its representative URL and its baseline, runs Lighthouse against each representative, and asserts the measured P75 against the template ceiling. Wire it into the same pull-request flow described in Running Lighthouse CI on Every Pull Request. Start with the manifest.
{
"device": "mid-range-mobile",
"connection": "fast-3g",
"templates": [
{ "name": "article", "url": "https://example.com/blog/how-caching-works", "lcpP75": 2800, "inpP75": 200, "jsKbP75": 180 },
{ "name": "landing", "url": "https://example.com/features", "lcpP75": 2500, "inpP75": 180, "jsKbP75": 150 },
{ "name": "listing", "url": "https://example.com/category/guides", "lcpP75": 3200, "inpP75": 240, "jsKbP75": 260 },
{ "name": "product", "url": "https://example.com/store/demo-widget", "lcpP75": 3000, "inpP75": 220, "jsKbP75": 240 }
]
}
The workflow iterates the manifest so adding a template is a one-line manifest edit, never a pipeline change.
name: per-template-budget-gate
on: pull_request
jobs:
gate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- name: Install runner
run: npm install -g @lhci/[email protected]
- name: Measure each template representative
run: node scripts/gate-templates.mjs templates.json
The gate script runs each representative several times, takes the P75 of the runs to damp single-run noise, and fails the job if any template exceeds its ceiling. Running multiple passes per URL is what makes the assertion trustworthy on shared CI hardware.
// scripts/gate-templates.mjs
import { readFileSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
const manifest = JSON.parse(readFileSync(process.argv[2], 'utf8'));
const RUNS = 5;
function p75(values) {
const sorted = [...values].sort((a, b) => a - b);
const rank = 0.75 * (sorted.length - 1);
const low = Math.floor(rank);
const high = Math.ceil(rank);
return sorted[low] + (sorted[high] - sorted[low]) * (rank - low);
}
function measure(url) {
const lcp = [];
for (let i = 0; i < RUNS; i += 1) {
const out = execFileSync('lighthouse', [url, '--quiet', '--output=json', '--only-audits=largest-contentful-paint', '--chrome-flags=--headless'], { encoding: 'utf8' });
lcp.push(JSON.parse(out).audits['largest-contentful-paint'].numericValue);
}
return p75(lcp);
}
let failed = false;
for (const t of manifest.templates) {
const measured = Math.round(measure(t.url));
const status = measured <= t.lcpP75 ? 'PASS' : 'FAIL';
if (status === 'FAIL') failed = true;
console.log(`${status} ${t.name}: LCP P75 ${measured}ms (ceiling ${t.lcpP75}ms)`);
}
process.exit(failed ? 1 : 0);
Handling Template Changes
A template baseline is only valid while the template's shape is stable. When a developer ships a redesign — a new hero, a restructured header, a swapped grid component — the fingerprint digest changes, and the old baseline no longer describes the page. The gate must detect that and refuse to compare against a stale expectation. Recompute the fingerprint on every run and compare it to the digest stored beside the baseline. If it drifted, quarantine the baseline: stop enforcing the old ceiling, re-measure a fresh distribution, and promote a new baseline only after main goes green, exactly as automated baseline promotion workflows prescribe.
Distinguish an intentional redesign from an accidental structural change. If the fingerprint shifts on a pull request that never touched the template's markup, that is a warning worth surfacing — a shared component may have leaked a new landmark into the page. Log the old and new digests on every quarantine so a reviewer can confirm the change was deliberate before the new baseline is promoted.
Verification
Before you trust the gate, verify three things. First, coverage: confirm your representatives' clusters account for the bulk of production traffic by joining the fingerprint digests against your analytics, and add a template if any un-fingerprinted shape carries more than a few percent of pageviews. Second, stability: run each representative ten times off-peak and confirm the P75 LCP spread stays inside roughly 10% on mid-range mobile over Fast 3G — a wider spread means the representative or the runner needs the noise controls from your calibration work. Third, sensitivity: deliberately regress one template locally, such as by adding a large unoptimised hero image to the article representative, and confirm the gate fails that template and only that template.
# Verify the gate fires on an injected regression and stays green elsewhere
node scripts/gate-templates.mjs templates.json && echo "baseline: all templates within budget"
# Inject a heavy hero into the article representative, rebuild, then re-run:
node scripts/gate-templates.mjs templates.json || echo "gate correctly failed the regressed template"
A gate that fails on the injected regression and passes on the clean build is a gate you can leave switched on for the whole team. From there, feed the per-template P75 figures into your dashboards so editors and engineers see each template's headroom drift over time, and let calibration tighten the ceilings as the site improves.
Frequently Asked Questions
How many templates should a typical CMS site end up with?
Most content sites collapse to three to six templates that cover 90 to 95 percent of traffic: article, landing, listing, and often product or author pages. If you fingerprint your corpus and find dozens of clusters, your components are leaking structural variation; consolidate them before you write budgets, or you will be maintaining baselines no one can reason about.
What happens to a brand-new page that has no baseline yet?
It inherits its template's baseline the instant it is published, because the gate keys on the fingerprint, not the URL. A new article is measured against the article ceiling immediately. The full inheritance mechanism, including how sub-templates fall back to a parent, is covered in Baseline Inheritance for New Routes.
Why measure a representative URL instead of every page in the template?
Measuring every URL is prohibitively slow and adds no signal, because pages in one template regress together when the shared code changes. One representative near the median content weight catches template-wide regressions on the first run. Promote a second heavy representative only when a single URL cannot capture the cluster's spread.
Should the ceilings use P75 or P90?
Start with P75 to match how Core Web Vitals are assessed in the field, then reserve P90 for templates where the slow tail is business-critical, such as checkout. Every threshold in the tables above is a P75 figure on mid-range mobile over Fast 3G. The trade-off is worked through in Choosing Between P75 and P90 Budget Targets.
How do I know when a template redesign has invalidated its baseline?
Recompute the fingerprint digest on every run and compare it to the digest stored beside the baseline. When it drifts, quarantine the old ceiling, re-measure a fresh distribution, and promote a new baseline only after main goes green. Log both digests so a reviewer can confirm the structural change was intentional.