Skip to content

Pass the custom page quality gates

What this is

The quality contract for custom pages — what the platform checks between upload-custom-page and a live URL, and how to author a page that clears everything on the first publish. The contract in one line: build every custom page to score at least 95 in all four Lighthouse categories — performance, accessibility, best practices, and SEO. Custom pages ship your markup as authored, so the score is entirely in your hands; this page lists the checks and the tactics that pass them.

How it works

Quality is enforced in three layers:

1. Upload-time lint (advisory). upload-custom-page returns a lint_findings array. Every finding is a warning — findings never block the upload — but each one maps directly to Lighthouse points you would lose later. Treat a non-empty lint_findings as your pre-audit punch list.

2. Pre-publish Lighthouse audit. When the platform’s pre-publish quality gate runs for a publish, every pending custom page is audited with Lighthouse in headless Chrome across performance, accessibility, best-practices, and seo. Each category must score at least 95, or the publish fails: publish-status reports status: "failed" with one finding per failing URL —

Lighthouse pre-publish gate failed: https://www.brew.example/subscriptions scored below 95 (perf=88, a11y=96, bp=100, seo=100)

— plus a remediation list naming each failing audit. Whether the gate runs for a given publish is a platform decision; the authoring contract is unconditional: build to ≥95. publish-confirm accepts a force flag that carries a publish past failing scores; every forced pass is recorded, with its scores, in the project’s audit log. Use it for a genuine emergency, not as a habit. One honest note on measurement: this platform-side audit is the pre-publish gate, and there is no draft-audit endpoint — to see scores before publishing, render the body yourself and run Lighthouse locally; otherwise the first publish doubles as the first audit.

3. The opaque-render guarantee. Custom pages render through a dedicated opaque path: the body is embedded into the page shell (head, theme, chrome) and is never processed by the markdown pipeline, so classes, data-* attributes, inline styles, and scripts survive intact. The deploy verifies this for every custom page in the built site and aborts the publish if any custom page rendered through the markdown pipeline instead — a broken render never replaces a working page.

When to use it

  • Before uploading a vibe-coded export (Lovable, v0, Bolt) — to strip the habits that cost Lighthouse points.
  • A publish failed with Lighthouse pre-publish gate failed and you need to fix the named audits.
  • upload-custom-page returned lint_findings and you want to know which warnings matter.
  • You are setting AEO metadata — speakable_selector, schema_org_type, og_image — and want the defaults.
  • Your project enables CSP and you want pages that keep working under it.

The contract, point by point

Performance ≥ 95

  • One file, CSS inlined. Put critical CSS in a <style> block in the body. No render-blocking external stylesheets; if you must link one, use media="print" onload="this.media='all'" or a preload-then-apply pattern.
  • Defer, async, or delete JavaScript. Every <script> should carry defer (preferred) or async. The highest-scoring pages ship no JS at all — animation and interactivity go a long way on CSS alone. (type="module" scripts, JSON-LD, and templates are already non-blocking.)
  • System fonts, or font-display: swap. A system-ui font stack costs zero requests. If you load a custom font, every @font-face block needs font-display: swap, or text is invisible while the font loads.
  • Size every image. Explicit width and height attributes prevent layout shift (CLS).
  • Optimize every image. Host via upload-media — it returns Cloudinary URLs with f_auto,q_auto (modern formats, tuned quality). The platform never rewrites your URLs, so the optimization flags must be in the URL you author.
  • Lazy-load below the fold, keep the hero eager. loading="lazy" on every image except the LCP/hero image; mark the hero (or a wrapping element) with a data-hero attribute so the lint knows the eager load is deliberate.
  • Scroll-reveal: ship content visible, hide with JS. Never ship content at opacity: 0 by default. Have the script add the class that enables the hidden/reveal states, so no-JS visitors, reduced-motion users, and the Lighthouse crawl all see the full content.

Accessibility ≥ 95

  • The platform shell already renders a <main> landmark (with lang/dir) and the viewport meta around every custom-page body — do not add your own <main>, or the page fails accessibility with a duplicate landmark. Structure the body with <div> and <section> wrappers (plus <nav> where it earns its place), one <h1>, sequential heading levels — headings still start at <h1> inside the body.
  • Always paint your own backgrounds. The shell background is the project’s theme — which varies, and can be dark or a time-of-day gradient — so a section that does not set an explicit background can silently fail contrast.
  • alt text on every image (empty alt="" only for pure decoration).
  • Color contrast of at least 4.5:1 for body text.
  • Real interactive elements — <a> and <button>, not click-handler <div>s — with visible :focus-visible states.

Dynamic content accessibility. Calculators and other interactive widgets need three things: render results into an aria-live (or role="status") region so the update is announced, set aria-invalid on inputs that fail validation, and move focus to the first invalid field.

Best practices and SEO ≥ 95

  • The platform shell supplies the document head: your title (10–60 chars) and description (50–160 chars) become the meta tags, and canonical URLs, hreflang, and the sitemap are generated for you. Supply a title and description worth ranking, and the SEO category mostly takes care of itself.
  • Use absolute https URLs for all external assets.

Upload-time lint rules

rulefires whenfix
body-too-largebody over the 500 KB soft cap (2 MB is a hard rejection)move inline assets to upload-media URLs or static hosting
no-eager-script<script> without defer/asyncadd defer
img-not-lazy<img> without loading="lazy" and no nearby data-herolazy-load it, or mark the hero with data-hero
blocking-stylesheet<link rel="stylesheet"> without media or preloadinline the CSS, or use the media="print" pattern
no-font-display-swap@font-face without font-display: swapadd font-display: swap;
cloudinary-not-autoCloudinary URL missing f_auto/q_autoinsert f_auto,q_auto/ after /upload/ — it is never auto-injected

AEO metadata

  • description does triple duty: meta description, schema.org description, and the page’s entry in /llms.txt. Write it for a reader deciding whether to click.
  • speakable_selector — comma-separated CSS selectors marking the sentences voice assistants should read. Default: [data-speakable], h1 + p. The easy path: tag your two or three best answer-sentences with a data-speakable attribute; or set a per-page selector at upload.
  • schema_org_type — defaults to WebPage; allowed values: WebPage, Article, AboutPage, ContactPage, CollectionPage, ProfilePage. Set AboutPage/ContactPage on those pages.
  • og_image — absolute https URL; 1200×630 works everywhere. Used for og:image and Twitter cards.
  • /llms.txt — live custom pages get their own CUSTOM PAGES section (and full marker-resolved bodies in /llms-full.txt) only when the custom-pages module’s access level is public.
  • Sitemap entries, priority, change frequency, lastmod (from updated_date), and hreflang alternates are all generated by the platform.

Cache behavior

Published custom pages are served with:

Cache-Control: public, max-age=300, s-maxage=86400, stale-while-revalidate=604800

Browsers revalidate after five minutes; shared caches hold a copy for up to a day and may serve it stale for up to a week while refreshing in the background. Design for it: custom pages suit content that tolerates minutes of staleness after a republish — not anything real-time.

CSP and security

  • Projects can opt in to Content-Security-Policy headers, applied to custom-page URLs only — never site-wide. The policy is assembled from per-project directives (default-src, script-src, style-src, img-src, connect-src, frame-src) in the site settings.
  • Strict script-src/style-src directives govern your inline <script> and <style> blocks — a policy without 'unsafe-inline' blocks them. Plan the page and the policy together; self-contained pages with few external origins keep the policy short and the page working.
  • Custom pages also ship standard security headers (HTTPS enforcement, content-type sniffing protection).
  • href values using javascript:, data:, or vbscript: schemes — including entity- or whitespace-obfuscated forms — are rejected at upload. src attributes are unaffected, so data: inline images pass.

Example

A single-file page skeleton that clears 95 in all four categories:

<style>
.page { font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
background: #ffffff; color: #111827; }
.hero { max-width: 64rem; margin: 0 auto; padding: 4rem 1.5rem; }
.hero h1 { font-size: 3rem; line-height: 1.1; margin: 0 0 1rem; }
.cta { display: inline-block; padding: 0.9rem 1.8rem; border-radius: 0.5rem;
background: #14532d; color: #ffffff; text-decoration: none; }
.cta:focus-visible { outline: 3px solid #14532d; outline-offset: 3px; }
</style>
<div class="page">
<section class="hero">
<h1 data-t-key="hero.title">Coffee subscriptions, delivered weekly</h1>
<p data-speakable data-t-key="hero.subtitle">Freshly roasted beans from small farms, at your door every Monday.</p>
<a class="cta" href="/signup/" data-t-key="hero.cta">Start your subscription</a>
<img src="https://res.cloudinary.com/demo/image/upload/f_auto,q_auto/tf/hero.jpg"
width="1200" height="800" alt="A bag of freshly roasted coffee beans" data-hero>
<img src="https://res.cloudinary.com/demo/image/upload/f_auto,q_auto/tf/farm.jpg"
width="800" height="600" alt="Coffee farm on a hillside at sunrise" loading="lazy">
</section>
</div>

Why it passes: inline CSS (zero render-blocking requests), system fonts (no font download), zero JavaScript, both images sized (no layout shift) and Cloudinary-optimized, hero eager and marked data-hero, the below-fold image lazy, alt text, a high-contrast button with a visible focus state — and no <main> of its own, because the shell already provides the landmark. Every selector is class-prefixed under the .page wrapper, and the wrapper paints an explicit background rather than trusting the shell theme behind it. Never style bare element selectors (body, img, h1…) — the body renders inside the platform shell, so bare selectors restyle the shell, not just your page. The title and description you supply at upload complete the SEO category — the platform shell renders them with canonical, hreflang, and sitemap handled.

Uploading it returns "lint_findings": []. If a publish does fail the gate, the failure names what to fix:

Lighthouse pre-publish gate failed: https://www.brew.example/subscriptions scored below 95 (perf=88, a11y=96, bp=100, seo=100)

Each failing URL carries a remediation list of the specific failing audits. Fix them, re-upload, publish again.

Common errors

Lighthouse pre-publish gate failed: … scored below 95 (perf=…, a11y=…, bp=…, seo=…) — the publish failed; nothing went live. Work through the remediation list (typical culprits: eager scripts, unsized or unoptimized images, render-blocking stylesheets, low contrast), re-upload, publish again. force: true on publish-confirm overrides a failing score, and the override is written to the audit log.

body_source is 2400000 bytes — above the 2 MB hard cap. Move large inline assets out of body_source (Cloudinary / static). — the upload was rejected outright. Base64-inlined images are the usual cause: host them with upload-media and reference the URLs. (Above 500 KB you get a body-too-large warning first.)

Blocked href scheme. An href using javascript:, data:, or vbscript: — even obfuscated with entities or whitespace — rejects the upload. Replace it with a real https URL or a relative path. data: URLs in src attributes (inline images) are unaffected.

Custom-page render guard aborted the publish. The deploy found a custom page rendered through the markdown pipeline instead of the opaque path and stopped — the live site keeps serving the previous version. This indicates a platform-side rendering fault, not an authoring mistake: re-run the publish, and report it if it persists.

Non-empty lint_findings after a successful upload. Not a failure — findings are advisory and never block. But each one is a Lighthouse point-loser; fix them before publishing rather than after a failed gate.

Last updated: