Integrations Directory
The integrations directory is three routes over one collection: /integrations/ draws the whole directory, /integrations/<slug>/ draws a detail page, and /integrations/category/<slug>/ filters it. Twenty-four entries ship in nine categories.
Like /customers/, this route was promised long before it existed — the Product mega menu, the footer, the home page’s integrations orbit and a home differentiator all point at it. Four surfaces advertised the URL; the page is what stops them 404ing.
No pagination, on purpose
Unlike the blog and the customer stories, the directory has no pager. A collection of posts grows without bound; a directory of integrations is a fixed set a reader wants to scan in one go, and the design draws all 24 in a single grid.
The filter chips are the navigation instead. They run in the directory’s own reading order rather than alphabetically, because the chips and the grid below them are the same list under two presentations. Each chip carries a live count from categoryCounts — derived, never authored, because a hand-written “4” beside a chip is a number that goes stale the first time an entry lands.
export const CATEGORY_LABELS = [
"Communication",
"Identity & SSO",
"Ticketing & work",
"CRM & procurement",
"Finance & spend",
"Docs & evidence",
"Engineering",
"Cloud & security",
"Automation",
] as const;
Nine chips, plus “All”.
The four invariants
assertDirectory runs once, on the index route, and throws. It exists because Zod validates each entry alone, and everything that goes wrong in a directory is a relationship between entries — each of which is valid frontmatter, a valid build, and silent in its own way:
A duplicate order. Two cards claim one slot, so the grid’s reading order becomes whichever way the sort happened to break the tie — a different page each time the loader changes its mind. The order is authored rather than derived, precisely because the design’s own sequence (Jira, Linear, Asana, Zendesk) is not a rule a comparator could recover.
The wrong number of featured. The “most connected” row is built for three across. A fourth wraps alone onto a second row; a second leaves a hole. Neither is a type error. FEATURED_COUNT is exported from integrationData rather than inlined because two files depend on the same three — the index renders them, and integrationData.test.ts asserts exactly that many entries carry the flag.
A category with no entries. getStaticPaths builds a filter page for every label in the taxonomy, so an unused label ships an empty listing that the chip row links to.
An entry naming a category outside the set. Caught by z.enum() at the schema, before the directory is even read.
Each throw names the entries involved rather than just the rule:
integrations: "slack" and "jira" both claim order 3 — the directory's reading
order is authored, so it has to be unique.
A detail page
/integrations/<slug>/ composes seven sections, and every one of them is structured data rather than prose — which is why this is the collection with an empty body on every entry.
| Section | Drawn from |
|---|---|
DetailHero |
name, description, tags, and the category from category |
ApiPanel |
the spec rows, with a Category row prepended from category |
StatStrip |
stats — optional, four figures, CountUp-tweened |
Capabilities |
the three capabilities.cards, each with a validated icon |
SlackThread |
only when showcase: "slack-thread" — a hand-built product mock |
Permissions |
the reads / writes / never scope lists |
SetupSteps |
the shared four steps, or the entry’s own setup override |
Two of those are worth expanding.
The masthead draws the category from category, and tags deliberately omits it. So the pill beside the H1 and the breadcrumb cannot disagree about which listing the entry belongs to. The spec panel does the same with its Category row.
never in the permissions block is authored rather than derived. It is a promise about what is not touched, which no complement of the other two lists can express.
The placeholder templates
The security card, the setup steps, the related-integrations heading and three separate counts are one string each in integrationData, rendered across up to 24 pages. They carry {name} and {count} placeholders, filled by fill in @js/integrations:
fill("Revocable in {name}", { name: "Slack" }) // "Revocable in Slack"
The design writes “24” in three places and “Slack” in five. Hand-maintaining those copies is how a directory ends up claiming a size it no longer has, so the count is read from the live collection and substituted.
Unknown placeholders are left alone rather than replaced with undefined — a template that says {plan} should render as a visible mistake, not silently swallow the word.
Logos
logo is a file stem under src/assets/logos/, and the mark is inlined rather than served as an <img>. The five home-page wall logos are drawn monochrome, so their paths carry fill="currentColor" and follow the theme into dark mode — which only works if the SVG is part of the document. The orbit marks keep their owners’ brand colours (the one documented exception to the tokens-only rule) and are inlined through the same door, so there is one mechanism rather than two.
logoSvg throws if the stem has no file, because a missing logo must fail the build rather than render a silent gap; integrationData.test.ts catches the same mistake earlier, on pnpm test.
One detail of that helper is worth knowing before you add your own exports. Figma names the ids in an SVG export by position, so all of these files declare clip0_0_1, and most declare a clip1_0_1 and a gradient id too. Inlined into one document those become duplicate ids, and every url(#clip0_0_1) on the page resolves to whichever logo rendered first — so most marks get clipped by a stranger’s clipPath. logoSvg namespaces every internal id to its file, which means a re-export from Figma cannot reintroduce the bug.
Adding an integration
- Create
src/data/integrations/<slug>/index.mdwith an empty body. - Fill the required frontmatter.
capabilitiesneeds exactly three cards,permissionsneeds at least one entry in each of its three lists, andspecneeds at least one row. - Drop the vendor’s SVG into
src/assets/logos/and pointlogoat its stem. - Give it an
orderno other entry uses. - Pick a
categoryfrom the nine. If you need a tenth, add it toCATEGORY_LABELSand give it at least one entry, orassertDirectorywill fail on the empty listing.
The icon names in capabilities.cards[].icon are validated against the live registry — Icons explains why that check lives in the schema rather than at the render site, and how to find a name that exists.
On the shipped demo entries: they carry twenty-four real companies’ trademarked marks as sample content. Drop any you do not actually integrate with before you ship, and read THIRD-PARTY.md — none of those logos is the template licence’s to grant.