Comparison pages
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 |
|
Comparison | One page: its kind, the products and criteria it selects, a ranking, a verdict, local patches, band order | the page's |
Prose | The written parts: introduction, verdict, why teams switch, how to switch, questions, methodology | the page's sections |
Look | How each band draws |
|
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:
The kind of site — product site (head-to-heads lead), directory (search and a product grid first) or review publication (roundups lead).
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.
Your rivals — add each by its website.
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 | |
|---|---|
| the id is a slug and an address; it never follows a rename |
| logos are served from our own store, never hot-linked |
| the brand colour ( |
|
|
|
|
| facts shown on the product hub |
| lists, one line each |
| the default verdict on it, and how to move away from it |
| printed in the disclaimer |
| third-party only: |
| a plan is |
| when the product was last checked, and where each fact was read |
| 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 | |
|---|---|
| the id is unique across groups |
|
|
|
|
|
|
| 0–10, default 1; 0 keeps it out of every score |
| a key criterion shows "at a glance" and on a |
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 | |
|---|---|
| your own product; absent on a roundup site |
|
|
| default 90 days; show the date, a relative date or nothing |
| the default disclaimer is generated in the reader's language |
| patterns per kind with |
|
|
| read another Comparisons space's catalog and patch it, so a product site and a review site share one truth |
| 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_labeland.verifiedcolumns 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
selectcannot 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 | |
|---|---|
|
|
| the columns, or the ranking — in this order (at least one) |
| the highlighted column. Unset: |
| the rival a versus or alternatives page is about |
| which criteria: only these groups, these always, these never, key ones only |
| the line on each product of an alternatives page or roundup |
| the verdict, structured |
| page-local overrides of the catalog (below) |
| the page's bands: their order and which are hidden |
| the page's call to action. Unset: the featured product's own |
| the |
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 |
|---|---|
| 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 |
| a card per product: winner badge, "best for", price |
| a scorecard of where the products differ (the clean look), or the key criteria |
| the full table |
| plans side by side, a month/year toggle, the cheapest plan with a feature |
| scores per group (only when scores are on) |
| pros, cons, best for, not for |
| the ranked list with a blurb on each — alternatives and roundups |
| the prose sections |
| the call to action |
| other comparisons that share a product |
| numbered footnotes and when each was verified |
| 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 |
|---|---|
| the look, the page width and paper, the space between bands, order/hide/align/heading size and style/divider/tinted bands/anchors/contents list |
| the page-wide look: product marks, the one button system, the card surface every card starts from, how much wears the brand (table below) |
| layout ( |
| layout, columns, winner badge and highlight, logo, tagline, best for, not for, score, price, CTA, verdict, card |
| layout, row count, group, notes, winners, link to the matrix, card |
| 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 ( |
| layout, month/year toggle and default, annual saving, which plans, highlights, currency display, per-seat, cheapest-with-feature, free badge, trial, source |
| layout ( |
| their layouts, what each row shows, jump list, numbering, sponsored label, sticky mobile CTA, disclaimer position |
| width, heading size, and each prose band's layout ( |
| the hub: layout, columns, page size, featured, by kind, product grid |
| the page per product: facts, pricing, ratings, comparisons, alternatives |
| the matrix on a phone — column picker (choose two), stacked cards or horizontal scroll — and what to hide |
| structured-data and indexing switches (below) |
| the print stylesheet and the |
| 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 |
|---|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| the theme's | — |
|
|
|
|
| a chevron after a button's words | off | on |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| — |
|
|
|
|
| bands drawn on a tinted panel, e.g. | none | — |
|
|
|
|
|
|
|
|
|
|
|
|
| each product's tagline in its tile | on | on |
| the verdict's summary under the title (then the quick verdict leaves it out) | off | on |
|
|
|
|
| "Verified …", the oldest product check, beside the updated date | off | on |
| the summary over the cards | on unless the hero shows it | — |
|
|
|
|
| leave the band out when the matrix is on the page | off | — |
| 1–6, the most | 3 | 3 |
| 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 |
|
|
|
|
|
|
|
|
| a key to the glyphs the table uses, under it | off | on |
| a row lights up under the pointer | off | on |
|
|
|
|
|
|
|
|
| "Our pick" on the featured card | off | on |
| each product's button at the foot of its card | off | on |
|
|
|
|
| 1–4, never more than there are products | 3 | 3 |
| the product's mark beside its name | off | on |
|
|
|
|
| 1–4 cards a row | 3 | 3 |
| the hub's title block |
|
|
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;roundupandmatrix— 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 markednoindex. A comparison with fewer than two products can be kept out of the index (seo.noindexThin).Structured data:
WebPageorArticle, anItemListofSoftwareApplication(orProduct) entries withoffersbuilt from the pricing,FAQPagefrom the questions, andBreadcrumbList. Disable any of them intheme.compare.seo, or replace the lot with the page's owncustomization.jsonLd.No self-serving ratings.
AggregateRatingis built only from a product's third-partyratings(G2, Capterra, an app store), only whentheme.compare.seo.ratingsInSchemais 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 untilscoring.showis 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:
dateModifiedis 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 inllms.txt, carrieshreflangtwins 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=1returns the bare matrix for an<iframe>; an oEmbed card is advertised on every comparison.rss.xmlandfeed.jsonlist new and re-verified comparisons.CTA clicks send a first-party
compare_ctaevent (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 filecompare.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 |
|---|---|
| a product's head-to-head "us vs them" pages |
| an "alternatives to X" hub with a directory front page |
| a review publication's category roundups and matrix (no product of its own) |
| 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 |
|---|---|---|---|
| Get a comparison space's catalog |
| |
| Replace a comparison space's catalog |
| |
| Change part of a comparison catalog |
| |
| Export the comparison matrix as CSV |
| |
| Import the comparison matrix from CSV |
| |
| List the cells that are past their re-verify date |
| |
| Mark cells as verified today |
| |
| Generate draft comparison pages from the naming patterns |
| |
| Read a product’s details from its website |
| |
| A comparison resolved, drafts included |
|