GenerateSaaS
ContentCompare engine

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 only

Dates 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.

ConditionNote
Both name 10+ of the same fact rowsThe intersection, not the union the table draws
An authored versus verdict, 3 lines per side1-5 validate, so a thin verdict blocks the pair rather than the product
Both quote priceFromThe line every reader checks first
Both lastVerified within 180 daysA 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 rivalsCheck both spellings' search volume; the direction people type wins. reverse: true names other first
A tieThe bigger brand - their name carries the query
Any pair with your own productYours 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.
  • chooseA always 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.

Read the build summary: it lists every pair still waiting and the file to edit.

Locales

Write the English sheet and let the translation pipeline do the rest - no comparison document opts out of it.

TranslatedPinned 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 depthDates, 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.

On this page