Comparison pages

Embed
Preview of the card
<a href="https://forumulate.com/guides/comparison-pages"><img src="https://forumulate.com/guides/_api/public/orgs/046a9bc0-d530-43ae-9bae-af7945008cda/spaces/36aa4df0-1e95-4cef-8ae9-795169441345/pages/ca4a74c1-bbcd-4483-b3b8-f22fc4fbbfb0/social-card.svg?v=3e7ae33eec8b" alt="Comparison pages" width="600" height="315"></a>

A Comparisons space is the page every product site writes — "Us vs Them", "The best X alternatives", "The best tools for Y" — built so that a fact is written once. The products, the criteria and the value of each product on each criterion live in one catalog; a comparison is a page that picks from it. Correct "Coda has SSO" in the catalog and every page that shows the cell is corrected, the freshness list knows when each fact was last checked, and the structured data a search engine reads is built from the same numbers the table shows.

Create one like any space:

POST /orgs/{orgId}/spaces
{ "name": "Comparisons", "slug": "compare", "type": "COMPARE", "visibility": "PUBLIC", "preset": "product" }

The type's name is COMPARE. GET /space-types/COMPARE publishes everything the type declares — its presets, the written sections, the settings it reads and its reader prefix. On your own domain it answers at its slug (acme.com/compare); on our address its prefix is /vs/.

The shape of it

Part

What it is

Where it lives

Catalog

Products, criteria, the matrix of values, scoring and freshness rules, naming patterns

settings.compare on the space

Comparison

One page: its kind, the products and criteria it selects, a ranking, a verdict, local patches, band order

the page's metadata.comparison

Prose

The written parts: introduction, verdict, why teams switch, how to switch, questions, methodology

the page's sections

Look

How each band draws

theme.compare

A comparison is an ordinary page: revisions, drafts, scheduling, translations, SEO fields and the sitemap come with it.

Set it up in the studio

The first section of a new Comparisons space is Set up your comparisons. Four answers:

  1. The kind of site — product site (head-to-heads lead), directory (search and a product grid first) or review publication (roundups lead).

  2. Your product — paste its website and the name, logo, tagline and description are read from the site's own metadata (nothing is invented; what was found is listed). Tick "I have no product of my own" for a roundup site.

  3. Your rivals — add each by its website.

  4. What buyers weigh — name the category and start from a set of criteria (business software, project management, developer tools, e-commerce, marketing) or from nothing. Running it again adds only what is missing.

It can also draft a versus page for each rival. Everything it writes is ordinary catalog data, so nothing here is a mode you are stuck in.

The catalog

GET|PATCH /orgs/{orgId}/spaces/{spaceId}/compare/catalog reads and writes it. PATCH merges per top-level key (a key sent as null is removed) and answers 422 naming the product or criterion that does not resolve; ?dryRun=true shows the diff without writing. The answer carries catalog (this space's own), effective (what readers see, after catalogFrom) and source.

Products — products[] (up to 200)

Key

id, name

the id is a slug and an address; it never follows a rename

url, logo {url, darkUrl, alt}

logos are served from our own store, never hot-linked

color

the brand colour (#rrggbb) of the monogram tile drawn when there is no logo; unset, a stable hue is derived from the name, and your own product wears your brand. The same mark is drawn wherever the product appears: every band, the index and product-hub cards, the embed, and related comparisons naming products the page itself does not carry

cta {label, url, sponsored}

sponsored: true draws rel="sponsored nofollow" and a disclosure

tagline, description, category

category groups roundups and "alternatives" lists

founded, hq, platforms, openSource, freeTier, trialDays

facts shown on the product hub

pros, cons, bestFor, notFor

lists, one line each

verdict, switching

the default verdict on it, and how to move away from it

trademark

printed in the disclaimer

ratings[]

third-party only: {source, value, scale, count, url, asOf}

pricing {url, currency, plans[]}

a plan is {id, name, price {amount, period}, annual, contactSales, highlights, url}

lastVerifiedAt, sources[]

when the product was last checked, and where each fact was read

labels.<locale>

name, tagline, description, verdict, switching, pros, cons… in another language

A price period is month, year, once, seat-month, seat-year, usage or custom. A plan with amount: null is "no public price" — never read as free. Prices are made comparable (a monthly equivalent per seat) but are never converted between currencies.

Criteria — criteria.groups[]

A group is {id, label, description, labels, criteria[]} (up to 50 groups and 500 criteria). A criterion is:

Key

id, label, help, labels

the id is unique across groups

kind

boolean, level, text, number, rating, select, price or list

unit, options[]

options are listed worst first, so a later option wins when better is higher

better

higher, lower or none (not compared). Unset: lower for a price, none for text, else higher

weight

0–10, default 1; 0 keeps it out of every score

key

a key criterion shows "at a glance" and on a keyOnly page

Values — values.<product>.<criterion>

A cell is {level, value, note, plan, source {label, url}, verifiedAt, labels}. level is yes, partial, no, addon, planned or unknown; value is typed by the criterion's kind (a boolean, a number, an option id, words or a list); plan is the cheapest plan that has it ("from Pro"). An empty cell is "unknown", and says so. A fact with no source and no date is an opinion, so the studio asks for both beside the value.

The catalog's rules

Key

self

your own product; absent on a roundup site

scoring {method, scale, show, perGroup}

none, weighted or count; scale 5, 10 or 100. show is off by default (see below)

freshness {verifyEveryDays, showVerified, staleBadge}

default 90 days; show the date, a relative date or nothing

disclaimer, methodology {text, pageSlug}

the default disclaimer is generated in the reader's language

naming {title, slug}

patterns per kind with {self} {rival} {products} {n} {year} {category}

autoPages {versus, alternatives}

off or draft — what "generate" makes by default

catalogFrom

read another Comparisons space's catalog and patch it, so a product site and a review site share one truth

labels.<locale>

the disclaimer and methodology in another language

Default naming: {self} vs {rival} ({year}) at {self}-vs-{rival}, and The {n} best {rival} alternatives in {year} at {rival}-alternatives. The slug carries no year on purpose: the title says 2026, the address stays the one every backlink already points at when the year turns.

The matrix as a spreadsheet

Competitor research starts in a spreadsheet, so the matrix round-trips through one. GET …/compare/catalog.csv exports it and PUT …/compare/catalog.csv ({ "csv": "…" }) imports it; ?dryRun=true answers how many cells it would write or clear, which rows it cannot read, and the diff. The studio previews every import before it applies it.

One row per criterion, in catalog order:

group,group_label,criterion,label,kind,unit,better,weight,key,tasklane,tasklane.note,boardwise
planning,Planning,gantt,Gantt and timeline,level,,,,true,partial,Paid add-on,no
pricing,Pricing,starting-price,Starting price,price,/seat/mo,lower,,true,10,,6
  • <product> holds the cell: a level word, true/false, a number, an option id (or its label), words, or a list joined with |. A cell with both a level and a value puts the value there and the level in <product>.level.

  • .level, .note, .plan, .source, .source_label and .verified columns are written only when some cell of that product uses them, and read when present.

  • An absent column keeps what is stored; an empty field in a present column clears it. A product with no column, or a criterion with no row, is untouched, so a sheet of ticks never erases the notes.

  • A new row adds a criterion to the group it names. A select cannot be added this way (its options need declaring), and an existing criterion's kind is never changed by a sheet.

  • A leading apostrophe means "literally this text". The export writes one before words that would read as a level, a boolean, a number or a formula (=, +, -, @), so a competitor's name scraped from a web page cannot run in anybody's spreadsheet.

Comparison pages

metadata.comparison on a page of the space (write it with PATCH …/pages/{id}/metadata or comparison: in front matter):

Key

kind

versus, alternatives, roundup or matrix — sets defaults only: the band order, the title pattern, the shape of the structured data

products

the columns, or the ranking — in this order (at least one)

featured

the highlighted column. Unset: self, when it is on the page

subject

the rival a versus or alternatives page is about

criteria {groups, include, exclude, keyOnly}

which criteria: only these groups, these always, these never, key ones only

ranking[] {product, blurb, bestFor, badge}

the line on each product of an alternatives page or roundup

verdict {winner, summary, perProduct}

the verdict, structured

patch {products, values}

page-local overrides of the catalog (below)

bands {order, hide}

the page's bands: their order and which are hidden

cta {product, label, url, sponsored}

the page's call to action. Unset: the featured product's own

year

the {year} in the title: auto (the year it is read in) or a number

Every product, group and criterion a page names must exist in the catalog; the write is refused with 422 naming the one that does not.

Patch, never copy. patch is laid over the catalog when the page is read and never written into it. "On this page call it Coda 2.0 and show SSO as an add-on" cannot quietly rename the product on every other page, and when the catalog changes the page follows.

Front matter

---
title: Acme vs Tasklane
comparison:
  kind: versus
  products: [acme, tasklane]
  subject: tasklane
  featured: acme
---
Tasklane and Acme both plan team work, but they trade off differently…

## Our verdict
…

The written parts

The page's prose is its sections: intro (required; it is also the page's description), verdict, why-switch, migration (steps), faq (each ### question and the words under it become FAQPage markup) and methodology. Everything structured — the matrix, prices, pros and cons, ranking — is generated from the catalog and never written as prose, so nothing is stated twice.

Bands

A page is a column of bands. Each draws only when it has something to say (a page with one product has no matrix; a catalog with no prices has no pricing band). The order and what is hidden are metadata.comparison.bands, and each kind starts in the order its readers expect:

Band

What it draws

hero

the products facing each other as tiles (or a row of chips), the title, the verdict's summary, your button and "See the full comparison", updated and verified dates

quickVerdict

a card per product: winner badge, "best for", price

atAGlance

a scorecard of where the products differ (the clean look), or the key criteria

matrix

the full table

pricing

plans side by side, a month/year toggle, the cheapest plan with a feature

scorecard

scores per group (only when scores are on)

prosCons

pros, cons, best for, not for

ranking

the ranked list with a blurb on each — alternatives and roundups

intro, verdict, whySwitch, migration, faq, methodology

the prose sections

cta

the call to action

related

other comparisons that share a product

sources

numbered footnotes and when each was verified

disclaimer

the disclaimer, affiliate disclosure and trademark notices

A versus page leads with the verdict, an alternatives page and a roundup with the ranked list, and a matrix page with the table.

The look — theme.compare

Every band has its own keys; none depends on another. The families:

Namespace

Keys it holds

look, page, density, bands

the look, the page width and paper, the space between bands, order/hide/align/heading size and style/divider/tinted bands/anchors/contents list

logos, buttons, cards, accentUsage

the page-wide look: product marks, the one button system, the card surface every card starts from, how much wears the brand (table below)

hero

layout (versus, list, banner, minimal), arrangement, background, tiles, logos and their size, the "vs" mark, taglines, summary, updated and verified dates, counts, category, lede, breadcrumb, primary and secondary CTA, ratings, tone, picture, backdrop

quickVerdict

layout, columns, winner badge and highlight, logo, tagline, best for, not for, score, price, CTA, verdict, card

atAGlance

layout, row count, group, notes, winners, link to the matrix, card

matrix

sticky header and first column, group collapse, differences-only toggle and its default, criterion search, featured-column and winner highlights, zebra, density, borders, level glyph set (check, circle, text, pill) with text labels, notes (inline, tooltip, footnote), plan badges, help, units, weights, group scores, source references, stale flags, header (logos, names), column widths, max height

pricing

layout, month/year toggle and default, annual saving, which plans, highlights, currency display, per-seat, cheapest-with-feature, free badge, trial, source

scorecard

layout (bars, table, rings, stars), total, groups, method, display, colour, sort

prosCons, ranking, cta, related, sources, disclaimer

their layouts, what each row shows, jump list, numbering, sponsored label, sticky mobile CTA, disclaimer position

prose

width, heading size, and each prose band's layout (faq as accordion, migration as steps or timeline, verdict as box or quote)

index

the hub: layout, columns, page size, featured, by kind, product grid

productHub

the page per product: facts, pricing, ratings, comparisons, alternatives

mobile

the matrix on a phone — column picker (choose two), stacked cards or horizontal scroll — and what to hide

seo

structured-data and indexing switches (below)

print, embed

the print stylesheet and the ?embed=1 matrix

labels

every word the reader draws, overridable per language

The design keys

Every key below is independent and works under every look; the "clean" column is what the default look sets (the plain paper look sets none of them). Layering is theme default → theme.compare → a band's own key (<band>.card beats cards key by key) → a page's customization.theme.compare.

Key

Values

Unset

clean

logos.shape

rounded, square, circle

rounded

rounded

logos.style

tint, solid, outline, plain (the bare letters) — a product with no logo

tint

tint

logos.size

sm, md, lg — every mark on the page, scaled

md

md

logos.letters

auto (two for a two-word name), one, two

auto

auto

logos.frame

tile (a real logo in a neutral tile), none

none

tile

buttons.style

outline, soft, solid — the secondary buttons; your product's is always the filled primary

outline

outline

buttons.radius

none, sm, md, lg, xl, pill

the theme's components.button

—

buttons.size

sm, md, lg (every size keeps a 40px tap target)

md

md

buttons.arrow

a chevron after a button's words

off

on

cards.radius

none, sm, md, lg, xl

lg

lg

cards.shadow

none, sm, md, lg

sm

sm

cards.border

none, hairline, strong

hairline

hairline

cards.fill

none, surface, tint

surface

surface

cards.hover

none, lift, border, glow — a linked card under the pointer

lift

lift

accentUsage

subtle, standard, strong — how strong the brand washes are

standard

standard

page.background

plain, wash (the brand fading down from the top)

plain

—

bands.headingStyle

plain, rule, accent, underline

plain

plain

bands.tinted

bands drawn on a tinted panel, e.g. ["pricing"]

none

—

hero.arrangement

centered (products above the title), split (side by side), stacked (products under the title)

centered

centered

hero.background

none, tint, gradient, glow, grid — under the words when there is no image; tone: brand wins over it

none

glow

hero.tiles

card, plain — a versus hero's products

card

card

hero.showProductTaglines

each product's tagline in its tile

on

on

hero.showSummary

the verdict's summary under the title (then the quick verdict leaves it out)

off

on

hero.secondaryCta

matrix ("See the full comparison"), rival (the other product's site), none

none

matrix

hero.showVerified

"Verified …", the oldest product check, beside the updated date

off

on

quickVerdict.showSummary

the summary over the cards

on unless the hero shows it

—

atAGlance.rows

differences (where they differ, key rows first), key, first

key

differences

atAGlance.hideWithMatrix

leave the band out when the matrix is on the page

off

—

atAGlance.columns

1–6, the most cards a row; rows are balanced (6 cards are 3 + 3, never 5 + 1)

3

3

matrix.maxHeight

px; above it the table scrolls in its own box with the header stuck to that box. Unset, the whole table is on the page and its header sticks to the viewport; it scrolls sideways only when wider than the page

unset

unset

matrix.align

center, start — cells and headings

start

center

matrix.featuredStyle

tint, bar (tint and a brand bar on the heading), ring

tint

bar

matrix.legend

a key to the glyphs the table uses, under it

off

on

matrix.hover

a row lights up under the pointer

off

on

matrix.groupStyle

band, caps, plain — a group's heading row

caps

band

pricing.highlightStyle

ring, tint, raised, none — the featured card

ring

ring

pricing.pickBadge

"Our pick" on the featured card

off

on

pricing.showCta

each product's button at the foot of its card

off

on

pricing.featureIcons

check, dot, none — before a plan's highlights

none

check

prosCons.columns

1–4, never more than there are products

3

3

prosCons.showLogo

the product's mark beside its name

off

on

cta.background

flat, gradient, glow

flat

gradient

related.columns

1–4 cards a row

3

3

index.hero.align, .background, .size

the hub's title block

start, none, lg

center, glow, lg

The level glyphs are a check (yes), a half-hatched ring (partial), a price tag (add-on), a clock (planned) and a cross (no); each carries its word for screen readers, and matrix.legend prints the words under the table.

Five looks — clean (the default), paper (the plain reader), bold, editorial, dense — are only patches of those keys, laid under yours and never stored, so everything a look does is available to the others and every key stays independently set. A layout preset (product, directory, editorial) patches the same keys and the hub's band order. A single comparison can carry customization.theme.compare of its own. Click an element in the studio preview and the panel opens on the key that draws it. The outlets compare-hub-above, compare-hub-below, compare-before-matrix, compare-after-matrix and compare-page-end take your own HTML (theme components).

Generate pages

POST …/compare/generate with { "kinds": ["versus", "alternatives"], "rivals": ["tasklane"], "dryRun": true } plans the pages the catalog implies and, without dryRun, creates them as drafts from the naming patterns:

  • versus — {self} against each rival (none on a roundup site: there is no "us");

  • alternatives — one per rival, about it, with {self} first and then the rival's category peers;

  • roundup and matrix — one per category with at least two products, with your own product leading its category's roundup.

Pages that already exist are listed as such and left alone, so running it again after adding a rival creates only the new pages. Nothing is published: a generated page still wants a verdict a person wrote. With autoPages set to draft, "generate" with no kinds makes those kinds.

Freshness

Every cell carries verifiedAt. GET …/compare/stale lists the cells past freshness.verifyEveryDays, never-checked first and then the most overdue. POST …/compare/verify with { "cells": [{ "product": "tasklane", "criterion": "sso" }] } (or "all") stamps them verified now — only a person who has re-read the source should press it. A daily sweep emits the compare.stale webhook, and a catalog write emits compare.catalog.updated (webhooks). The studio's Freshness section is the same list with a "Verified" button on each row, and the bulk "mark selected verified today" in the matrix grid stamps a block.

Readers see the date as a date, a relative date or not at all (freshness.showVerified), and a stale cell wears a badge unless freshness.staleBadge is off.

Editing the matrix

The matrix grid in the studio behaves like a spreadsheet: arrows move, Shift+arrows select a block, Y P N A L U set Yes / Partial / No / Add-on / Planned / Unknown on the whole selection, Backspace clears it, and Enter opens the cell's form (value, note, plan, source, verified date). Groups and criteria reorder by dragging or with Alt+Up / Alt+Down on the row's handle.

SEO notes

  • Titles and descriptions come from the naming pattern (with {year}) and the introduction; canonical, Open Graph and Twitter tags are generated, and a page can be marked noindex. A comparison with fewer than two products can be kept out of the index (seo.noindexThin).

  • Structured data: WebPage or Article, an ItemList of SoftwareApplication (or Product) entries with offers built from the pricing, FAQPage from the questions, and BreadcrumbList. Disable any of them in theme.compare.seo, or replace the lot with the page's own customization.jsonLd.

  • No self-serving ratings. AggregateRating is built only from a product's third-party ratings (G2, Capterra, an app store), only when theme.compare.seo.ratingsInSchema is switched on, and never from a score this site awards. Search engines treat a rating a business gives its own product as self-serving and penalise it, so there is no key that publishes one. Scores are computed but not shown until scoring.show is on, for the same reason: a number you award your own product reads as a sales claim, and the least surprising page is the one that only states facts.

  • Affiliate links are drawn rel="sponsored nofollow" and disclosed on the page; they are marked in the studio on each product and on the page's CTA.

  • Dates: dateModified is the latest verified date on the page, so a re-checked fact is a visible freshness signal.

  • A product with at least one published comparison has its own hub at /vs/{org}/{space}/product/{id} and is listed in the sitemap; a product with none is not.

  • Every page has a Markdown twin (.md: the matrix as a table, the sources as footnotes), is listed in llms.txt, carries hreflang twins for each translated language, and has a generated social card showing the logos and names (social cards). Languages are in the path (/vs/…/es/acme-vs-tasklane), never a query (translations).

  • ?embed=1 returns the bare matrix for an <iframe>; an oEmbed card is advertised on every comparison. rss.xml and feed.json list new and re-verified comparisons.

  • CTA clicks send a first-party compare_cta event (product and page), consent-gated like the rest (measurement).

From forumulate.yaml

spaces:
  - path: compare
    space: compare
    create: { type: COMPARE, name: Comparisons }
    compare:
      catalog: compare.yaml     # the catalog's keys, in a file beside the manifest
      matrix: matrix.csv        # the matrix as a spreadsheet
      scoring: { show: false }  # any catalog key inline wins over the file

compare.yaml holds the catalog (self, products, criteria, values, scoring, freshness, naming, disclaimer, …). Only the top-level keys that differ from what the space stores are sent, so a run that changes nothing writes nothing; null clears a key. The matrix file is read by the same importer as the CSV door after the catalog, previewed first, and a row it cannot read stops the run naming the row. The comparison pages are the folder's Markdown, with comparison: in their front matter. In a version 2 file (forumulate plan / apply) the same block sits on the space and matrix is sent as the CSV text (plan and apply).

Templates

Four kits start a space with a sample catalog of fictional products, pages drawn from it and a Your Brand placeholder:

Template

For

vsline

a product's head-to-head "us vs them" pages

altlist

an "alternatives to X" hub with a directory front page

shortlist

a review publication's category roundups and matrix (no product of its own)

dealmark

an affiliate roundup site: sponsored links marked, a dense matrix and a "how we earn money" page

Every sample product carries a brand color, so its monogram tile shows an identity until you upload its logo. Every sample fact carries a sample verified date. Re-check each against its real source and re-date it (the Freshness section lists them) before you publish.

In the API

Every operation below is also reachable from the other surfaces without a dedicated command. forumulate api <Operation> calls it from the CLI, the JavaScript SDK has a typed method for each one (the last column names it; the Python and Go SDKs are generated from the same spec), and an agent reaches the organization-scoped ones through the MCP agent server — its api_* family tools, or call_api. Where a CLI command or a hand-made MCP tool exists too, the last column names it. Operation names link to the API reference.

Compare

Operation

Request

What it does

Shortcuts

Compare_catalog

GET /orgs/{orgId}/spaces/{spaceId}/compare/catalog

Get a comparison space's catalog

client.compare.catalog()

Compare_setCatalog

PUT /orgs/{orgId}/spaces/{spaceId}/compare/catalog

Replace a comparison space's catalog

client.compare.setCatalog()

Compare_patchCatalog

PATCH /orgs/{orgId}/spaces/{spaceId}/compare/catalog

Change part of a comparison catalog

client.compare.patchCatalog()

Compare_catalogCsv

GET /orgs/{orgId}/spaces/{spaceId}/compare/catalog.csv

Export the comparison matrix as CSV

client.compare.catalogCsv()

Compare_importCsv

PUT /orgs/{orgId}/spaces/{spaceId}/compare/catalog.csv

Import the comparison matrix from CSV

client.compare.importCsv()

Compare_staleCells

GET /orgs/{orgId}/spaces/{spaceId}/compare/stale

List the cells that are past their re-verify date

client.compare.staleCells()

Compare_verify

POST /orgs/{orgId}/spaces/{spaceId}/compare/verify

Mark cells as verified today

client.compare.verify()

Compare_generate

POST /orgs/{orgId}/spaces/{spaceId}/compare/generate

Generate draft comparison pages from the naming patterns

client.compare.generate()

Compare_prefill

POST /orgs/{orgId}/spaces/{spaceId}/compare/prefill

Read a product’s details from its website

client.compare.prefill()

Compare_preview

GET /orgs/{orgId}/spaces/{spaceId}/compare/preview

A comparison resolved, drafts included

client.compare.preview()

The Forumulate teamUpdated