---
title: "Pass the custom page quality gates"
description: "The quality contract for custom pages: the Lighthouse 95 bar, tactics to pass it first try, AEO metadata, cache behavior, and CSP."
url: "https://comstack.ai/docs/custom-pages/quality/"
---
## 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

| rule | fires when | fix |
|---|---|---|
| `body-too-large` | body 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`/`async` | add `defer` |
| `img-not-lazy` | `<img>` without `loading="lazy"` and no nearby `data-hero` | lazy-load it, or mark the hero with `data-hero` |
| `blocking-stylesheet` | `<link rel="stylesheet">` without `media` or preload | inline the CSS, or use the `media="print"` pattern |
| `no-font-display-swap` | `@font-face` without `font-display: swap` | add `font-display: swap;` |
| `cloudinary-not-auto` | Cloudinary URL missing `f_auto`/`q_auto` | insert `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:

```html
<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.

## Related

- [Custom pages overview](/docs/custom-pages.md)
- [Translate custom pages with markers](/docs/custom-pages/translation.md)
- [Page types: knowledge vs custom](/docs/mcp/page-types.md)
- [Publishing: dry-run, confirm, status](/docs/mcp/publishing.md)
- [MCP error reference](/docs/mcp/errors.md)
