Integrations Directory
The integrations directory is two routes over the config layer: /product/integrations/ draws the whole directory, and /product/integrations/<slug>/ draws a detail page. Sixty connectors ship across five categories — accounting, ERP, HRIS, banking and identity — twelve in each, producing 61 pages.
Why this is config and not a collection
The blog and the customer stories are markdown because their bodies are prose. An integration page has no prose body at all: it is a one-sentence summary, two lists of short rows, two paragraphs and a link — every one of them a named field the layout positions.
A collection would buy Zod validation over content that TypeScript already types, and cost sixty markdown files with nothing under the frontmatter. satisfies is the boundary check here, at build time, which is where the schema mistakes would have surfaced anyway.
Five modules, not one catalog
The connectors live in src/config/en/integrations/, one module per category:
src/config/en/integrations/
├── accounting.ts 12 connectors + its descriptor + its defaults
├── erp.ts 12
├── hris.ts 12
├── banking.ts 12
└── sso.ts 12
They were a single file once, and it was 1,962 lines — the largest hand-written file in the repository by four and a half times. The size was the smaller problem. The real one was the layout: a category’s defaults sat up to 1,500 lines from the twelve connectors that fall back to them, so you could not see an inherited value and its override on the same screen.
Each module now exports one IntegrationCategoryModule: its category descriptor, its defaults, and its connectors. integrationsData.json.ts composes them from one ordered list:
const GROUPS = [accounting, erp, hris, banking, sso];
That list is the directory’s render order, the chip row’s order and the catalog’s order, because all three are derived from it. A category id is therefore written once instead of three times, and a category cannot exist without defaults or defaults without a category.
The per-vendor / per-category split
This is the whole modelling decision, and it is what keeps sixty pages from being sixty copies of the same paragraph.
Written per vendor, because it genuinely differs: the blurb, the three-part summary, the six inbound and three outbound syncs rows, liveSince and setupMinutes.
Written once per category, because it genuinely does not: the two “before you start” requirements paragraphs and the shot copy for the product-screenshot band. Twelve accounting connectors have the same prerequisites and the same thing to say about what the sync will not do.
The category defaults carry {name} placeholders, filled per connector by fill() in @js/template:
requirements: [
"You need a {name} account with adviser or administrator rights…",
// …
],
A connector overrides a default only where it really differs. In the shipped catalog, exactly one does.
What is derived
@js/integrationFacts holds the pure arithmetic, and it is where every number on these pages comes from:
groupByCategorysplits the catalog into the directory’s five groups, in the categories’ own order.categoryOfresolves a connector’s category, throwing if the id is not a declared one rather than dropping the connector silently.metaRowbuilds the detail hero’s meta line from the four facts rather than storing it as a sentence.requirementsOfandshotOfapply the category default or the vendor override and fill{name}.
The sixty in “Sixty systems, both directions” is catalog.length. The per-category counts on the chips are computed. Nothing on these pages quotes a number that is also written down somewhere.
@js/integrationUtils is the impure half beside it — integrationHref and relatedIntegrations, both of which know about routes and the site. The split exists so integrationFacts.test.ts can run under bare Node with type stripping; see Project Structure.
No category routes
The blog and the customer stories both give their taxonomy real routes. The integrations directory deliberately does not: its chips are a native radio group filtering the wall in place, using the ChipRadio primitive, so the browser holds the state and no JavaScript is involved.
That is the same trade the customers page makes with its size select, decided the same way. Sixty connectors in five categories is a wall you scan, not an archive you browse, and five extra index pages listing twelve items each would be thin content pointing at pages that are already one click away from the directory.
The Chip primitive serves both patterns from one tv() config, and the focus variant is what lets it: a blog category chip is a link and takes active as a prop, while an integrations chip is a <label> over a visually hidden radio, so its focus ring hangs off the input beside it rather than off the label. The two used to be separate copies of the same config, and they had already drifted.
The vendor marks
Sixty logos live in src/components/svg/logos/integrationMarks.ts as inline SVG, extracted from one Figma export rather than sixty. The crop is a viewBox pointed at each mark’s box, not a translation of the path data, and each box is derived from the grid’s own geometry rather than transcribed — the slicer asserted every derived origin against the node’s own offsets before writing the file, and they agreed to within 0.6 pixels.
Being inline SVG in a TypeScript module means they resolve at build time and nothing lands in client JavaScript, the same bargain the icon registry makes.
Adding a connector
Add an entry to the right category module:
{
slug: "my-vendor",
name: "My Vendor",
category: "accounting",
blurb: "One clause on the card.",
summary: { before: "…", emphasis: "…", after: "…" },
liveSince: 2024,
setupMinutes: 8,
syncs: { inbound: [/* six rows */], outbound: [/* three rows */] },
}
The route, the directory tile, the count, the chip’s tally and the related row on other connectors’ pages all follow from that one entry. Add requirements or shot only if this vendor genuinely differs from its category; otherwise let it inherit.
Two failure modes are already closed for you. A connector naming a category that does not exist throws at build with the id named, rather than disappearing from a wall whose heading still says sixty. And if you add a mark for it, integrationMarks is keyed by slug, so a missing one is a build-time miss rather than a hole in the grid.