GenerateSaaS

Comparison tables

Every comparison table on the site is derived from the compare fact sheets - one source for the hub, each head-to-head and every alternatives roundup, with one row vocabulary, one set of marks and one method line.

Every table on /compare, on each /compare/{a}-vs-{b} and on every /alternatives page is derived from the same files: the product fact sheets in packages/content/{locale}/compare/. No page authors a table of its own, so a price corrected in one file is correct on all three surfaces at once.

What each surface decides

Only which product leads belongs to the page. The order behind it, the rows, the cells and the vocabulary all belong to the sheets.

SurfaceColumns, left to right
/compare hubYours, then the rivals by order
/alternativesThe same order
/alternatives/{rival}The anchor (competitor.slug), yours second, the rest by order
/compare/{a}-vs-{b}The two products the URL names, then the rest by order - yours third on a referee pair. The page opens on its own two columns, three on a referee pair so yours is visible, and the rest are one chip away
  • order is the only ranking. Columns, blocks and shortlists all run in it, so the third column across is the third block down - a reader who scans the table and then reads the write-ups meets one list, once, in one sequence.
  • Every column gets a block. An alternatives page whose table columns a sheet its items write no block for is a guard error: the page publishes noindexed and the build summary names each missing product. The page's own anchor is the exception - it heads the table as the reference and carries no block.
  • The row set is identical everywhere: the union of every sheet's rows, banded and ordered by order, never by the order a surface columns them in.
  • A row one sheet omits renders as a cross in that column, never as a missing line.
  • A row's footnote never prints on a page - nothing sits under a table but its legend. The alternatives .md rendition folds it into the row it qualifies, which is where an agent quoting that cell reads the caveat.
  • The hub prepends one unbanded Starting price row, read from each sheet's priceFrom.

The marks

MarkMeans
yes / partialShips it, or ships part of it
noA checked absence, and it must carry text an omission could not: no - paid tiers only. A bare cross says exactly what leaving the row out says
unknownNot evidenced on the vendor's public pages - the only honest answer about a closed codebase

A row is all prices or all marks: a mark says nothing about a price, and a price says nothing about support. What a mark has to be sourced against is in writing comparison pages.

Bands and labels

Eight band names carry the shipped sheets, in the order the ranked sheets declare them: Pricing & licence, Tech stack, Billing features, Core features, Marketing & growth, Operations, AI, Tooling & updates.

RuleWhy
Spell every band and row label identically in each sheetAudit log here and Audit logs there compares nothing and draws two rows
Plain words: Multiple languages, not InternationalisationA first-time reader has one second per label
Rows worth carrying on every sheetPricing model, Projects per licence, Client work, White-label, Refund policy, Deploy targets

Each row draws an icon: its own from apps/web/components/content/matrix-icons.tsx, else its group's, else a neutral default - so a row you invent never renders iconless.

Row labels in other languages

The authored label is a key, not copy: it joins one row across every sheet and picks its icon, so it is never translated. The display name is a separate message.

  • It comes from content.compare.labels.<key>, the label lowercased with each run of non-letters collapsed to _: Pricing & licence reads pricing_licence.
  • A label with no message falls back to itself, so a row you invent reads in your own words in every language rather than as a missing-message marker.
  • The union, the icons and the verdict rows all still read the authored label.

Verdict rows

A pair page's rulings sit in no section of their own: each renders as a Verdict row closing the rows it rules on, inside the table, across the visible columns - and only where both products name those rows.

How a reader narrows it

  • Every column is visible. The row-label column is frozen, so a wide table is read by sliding it sideways rather than by hiding most of it.
  • From three product columns up, a chip per product appears (MIN_PICKER_COLUMNS). Tapping one hides or shows that column, yours included; the last remaining chip locks, so the table can never be emptied.
  • Hidden columns are CSS-hidden, never dropped: the .md rendition, the JSON-LD and every crawler read the complete table.

Where the method goes

SurfacePrints
/compare listicleA How we compared section above the FAQ: the index's criteria as numbered lines, then the methodLine
/compare/{a}-vs-{b}The methodLine under the table, beside the legend. No section - a pair page weighs no criteria of its own
/alternatives pagesNothing on the page; the authored methodLine reaches the .md rendition under the matrix

That one line is what licenses dates and version numbers inside cells - they stay honest exactly as long as it does, and there are no per-column date stamps. The mark key lives under the table as the legend, and only there.

One authored in an article

A Best {category} tools blog post writes its own table in its body, with no import and no fact sheets behind it.

<ComparisonMatrix
  legend
  columns={[{ name: "Acme", logo: "...", href: "/" }, { name: "Nimbus", href: "https://..." }]}
  groups={[{ label: "Pricing & licence", rows: [
    { label: "Starting price", cells: [{ text: "$19" }, { text: "$25" }], footnote: "..." },
    { label: "Self-hosting", cells: [{ status: "yes", text: "Any host" }, { status: "no" }] }
  ] }]}
/>

Optional props: featureLabel, statusLabels, legendLabels, controlLabels. An MDX body passes no translator, so those labels fall back to English - a non-English project sets them by hand. Rows still draw their icons from the registry.

When a table does not render

These apply to an alternatives page, the one surface whose items name the sheets they column.

ConditionResult
The page authors a matrix: blockThe build fails, naming the file and the compare directory to edit instead - a table you maintain and nothing renders is silent loss
An item's product: or a competitor.slug names no sheetThe build fails, naming every miss and the exact file to create
Fewer than two fact sheets existNo table, and one build-summary line saying how many it found of the two it needs

On this page