Compare engine
The /compare section, where each document is a product fact sheet rather than a page - the frontmatter it carries, the direction its pair URLs read, and the quality gate that refuses to publish a comparison nobody made.
Each document in packages/content/{locale}/compare/ is a product fact sheet, one file per product including your own, copied from the _template.md beside them. Nothing serves /compare/{product}: the section publishes a hub and one page per pair - four products make six pairs, five make ten, and each publishes only once it clears the gate below. This section owns the {brand} vs {brand} query; /alternatives owns "alternatives to X" and links across rather than restating it.
The demo sites show the boilerplate's own sheets. A generated project ships the templates and two
worked, _-prefixed examples instead - _example-product.md (a complete sheet with placeholder
pictures under /images/examples/) and _example-index.md (the hub's listicle head) - so nothing
about another category is ever live on your site. Copy, rename without the underscore, replace
every value.
Frontmatter
Fields marked * are required; a missing one is a guard error that noindexes the product and drops it from every pair, never a build failure. Nothing below the frontmatter renders, and a filename containing -vs- is a guard error - the routes already generate that address.
title: "Product Name" * # collection-enforced, with `date` - omit either and the build FAILS
date: "2026-01-01" *
name: "Product Name" * # the vendor's own spelling; every heading and pair URL label uses it
url: "https://example.com" * # absolute for a rival, a site path ("/") for your own product
tagline: "What this product is, in one line." * # never a sentence about your own product
bestFor: "Who should pick it, in a dozen words." * # the hero's shortlist card is built from this
priceFrom: "$0 one-time" * # entry price in the vendor's terms, short enough for a chip
order: 2 * # YOUR RANKING, and the only one: the table's columns, the blocks,
# the shortlist and the default pair direction. Give yours 1
pros: ["..."] * ; cons: ["..."] * # 1-5 short lines each; real trade-offs in `cons`
facts: * # >= 1 band, >= 1 row. One vocabulary across every sheet
- label: "Tech stack"
rows:
- { label: "Frameworks", status: "yes", text: "Next.js" } # either alone is fine
- { label: "Refund policy", status: "no", text: "All sales final",
footnote: "..." } # `footnote` reaches the .md rendition, never the table
docsUrl ; demoUrl ; changelogUrl ; pricingUrl # the Verify links on a pair page. Each absence
# is a FINDING, so never invent one - leave the line out
positioning: "One claim, a dozen words." # under the heading on the hub, and on every card
# pointing at a page about this product
summary: ["...", "..."] # 1-3 paragraphs under 70 words, beside the screenshot on the hub
tiers: ["Solo $249", "Pro $499"] # <= 5. What the ladder costs, which `priceFrom` cannot say
lastVerified: "2026-01-01" # pairs stop publishing 180 days after this. Rendered as a YEAR
description ; dateModified ; logo
screenshot: "/images/compare/example-home.webp" # a 16:9 VIEWPORT capture, 800x450 WebP, shot at
# 1440x810 scrolled to the top: the top edge and both sides fit by
# construction, so the row compares products rather than photography.
# Every surface reads it from here, roundup blocks included
us: true # exactly ONE file in the folder. Wears the "That is us" badge
methodLine: "Read from each vendor's site and docs in 2026. A cross means their docs and site
do not show it." # under the table on a pair page, in the hub's method section, and
# in the roundups' .md rendition. A YEAR, never a month
versus: # THE HAND-AUTHORED VERDICT, one entry per pairing
- other: "other-product-slug" # authored on the LOWER-ORDERED of the two documents
chooseA: ["...", "...", "..."] # THIS document's product; 3 lines to publish, 5 max
chooseB: ["...", "...", "..."] # `other`; same
reverse: true # name `other` first in the URL instead - see Pair direction
tldr: "What this choice turns on, in one sentence." # above the two cases
rulings: [{ group: "Pricing & licence", text: "..." }] # <= 8, one per band, NAMING both
oursPitch: ["...", "..."] # REFEREE pairs only - your case against BOTH of them, 2-3
# lines. Never on a pair containing your own product
faq: [{ q: "...", a: "..." }] # <= 4, 40 words per answer, about THIS product onlyDates render as a year, never a month: lastVerified still carries a full date, and so do the JSON-LD, sitemap and dateTime attributes, but nothing a reader sees narrows it further. Write no month name into a cell, an answer or any other prose - a rendered-months test sweeps every comparison surface and fails on one.
The quality gate
There is no derived verdict anywhere in the code. A pair failing any condition renders nothing - no page, no hub card, no sitemap entry, no .md rendition - and the build summary names the pair and every reason.
| Condition | Note |
|---|---|
| Both name 10+ of the same fact rows | The intersection, not the union the table draws |
An authored versus verdict, 3 lines per side | 1-5 validate, so a thin verdict blocks the pair rather than the product |
Both quote priceFrom | The line every reader checks first |
Both lastVerified within 180 days | A stale comparison is a wrong one |
Pair direction
order fixes which way round a pair URL reads by default: the lower-ordered product is named first, /compare/{a}-vs-{b} is the page, and /compare/{b}-vs-{a} 308s to it. Equal orders fall back to alphabetical slug, and it is a URL rule rather than a verdict.
| Which way round | |
|---|---|
| Between two rivals | Check both spellings' search volume; the direction people type wins. reverse: true names other first |
| A tie | The bigger brand - their name carries the query |
| Any pair with your own product | Yours first, always: order: 1 and no reverse |
versus:lives on the lower-ordered document whichever way the URL reads; a block on the higher-ordered one is a guard error naming the file it belongs in.chooseAalways means that document's own product, and the page attaches each case to the product the URL names - so flipping a pair never swaps its argument.- A blocked pair's reversed slug 404s rather than redirecting into nothing.
Adding a competitor
Copy _template.md to packages/content/en/compare/{product}.md. That alone puts the product
in the hub and in every table.
Add one versus: block per pair that should exist. A new product has the highest order, so
those blocks go into the existing lower-ordered files, one small entry each.
Locales
Write the English sheet and let the translation pipeline do the rest - no comparison document opts out of it.
| Translated | Pinned in every language |
|---|---|
title, description, tagline, bestFor, positioning, summary, intro, criteria, methodLine, tldr, pros, cons, bottomLine, and every text, footnote, value, when, because, q and a at any depth | Dates, slugs, URLs, marks and figures: status, product, order, reverse, name, pick, tiers, priceFrom, primaryKeyword - plus label and a ruling's group, the two keys the page matches rows on |
- The pinned values are restored from your source after the translation runs, so a model rewriting one cannot split a row or lose a verdict.
- Inside a translated sentence, product names, version numbers and figures with their currency stay exactly as written.
- A row's display name is a separate message, so a table reads in the reader's language while its keys stay put - see comparison tables.
Hub and pair pages
What these sheets publish: the hub's two modes and everything a head-to-head page prints.
Writing comparison pages
The editorial rules: sourcing a claim, which pages earn their place, and the shape that gets cited.
Comparison tables
The one table these sheets feed: columns, marks, verdict rows and the method.
Alternatives pages
The roundup and conquest pages the compare hub links across to.
Editorial checklist
What a page needs before you publish it.
SEO kit
The JSON-LD graph and the markdown renditions these pages serve.
Conquest pages
The roundup anchored to a rival at /alternatives/{rival} - the flag that makes one, the fields only it carries, and what the guard refuses so a near-duplicate never publishes.
Hub and pair pages
What a compare section actually serves - the hub as a router or a ranked listicle, the index.md that switches between them, and everything a generated head-to-head page prints.