Skip to content
AstroCraft Docs
On this theme

Customer Stories

Customer stories are three routes over one collection. /customers/ lists them all, /customers/<slug>/ draws a single story, and /customers/industry/<slug>/ filters by industry. Ten sample stories ship across five industries, producing 16 pages.

The index

/customers/ promotes the newest story into a featured band and hands the rest to a bento grid, alongside a logo wall, an aggregate-results band and a filter bar.

There is no pagination, and that is the design’s own decision rather than an omission: ten customer stories is a roster, not an archive, and a second page of it would hide the newest story behind a click on a page whose whole job is to show them. The blog index paginates because it is an archive; this one does not because it is not.

The featured story is excluded from the grid for the same reason the blog’s is — showing it twice on one screen reads as a bug rather than as emphasis.

The filter bar, and the two kinds of filtering

The filter bar draws two controls, and they work in different ways on purpose.

Industry chips are links to real routes. Each is a /customers/industry/<slug>/ page: crawlable, linkable, JavaScript-free, and generated from the live set of industries via storyIndustries. The design draws the chips as a control with no destination, so the route is a decision — the same call the blog’s category archives record.

The company-size select filters in place. Three bands, no routes:

export const SIZE_BANDS = [
  { id: "small", label: "Under 250 people", max: 249 },
  { id: "mid",   label: "250–1,000 people", max: 1000 },
  { id: "large", label: "Over 1,000 people" },
];

The reasoning is written into the route file: six industries × four size bands is twenty-four pages over ten stories, which is index bloat rather than information architecture. So one axis gets routes and the other gets a client-side filter.

The bands themselves are computed from headcount, not stored. sizeBandOf(610) returns "mid". A sizeBand: "medium" field in frontmatter would be a value that can contradict the headcount sitting two lines above it. max is inclusive and the last band is open-ended, so every positive headcount lands in exactly one band and the lookup cannot miss.

A story page

src/pages/customers/[slug].astro calls render() itself and passes both halves down — the <Content /> component for the narrative and the headings array for the reading rail — which is the same split blog/[slug].astro records, and for the same reason.

The story page draws a header, a facts band, the narrative against a reading rail, a “what they use” product shot, a related-stories row and the site closing CTA. The narrative is markdown because it is prose: three h2 sections with a pull quote, which is exactly what ui/_prose.css and ui/toc-rail already render for a blog post. Nothing about the narrative needed a new field.

What the story page prints that a post does not is the facts rail, and almost none of it is stored as prose:

  • “COMPANY SIZE” is built by companySize(story, locale) from headcount and financeHeadcount, not written as a sentence.
  • The card’s meta line — “Marketplace · 610 people” — is industry plus formatHeadcount, which goes through Intl so the thousands separator follows the locale.
  • The three metrics come straight from frontmatter, where .length(3) guarantees there are exactly three. The featured card’s metric row and the facts band both draw three tiles; two or four renders as a broken row, and the schema makes that unrepresentable.

resultFigure — the card’s big number — is a string rather than a number, because half of the sample figures are transitions like “Day 19 → day 4”.

The logo wall

Ten client marks, and none of them is an uploaded asset. The schema stores a shape and a casing:

logoMark: z.enum(["square", "circle", "none"]).default("square"),
wordmark: z.string().optional(),   // defaults to `client`

The reasoning is that these two things are both true: ten identical grey squares would be a wall of one logo, so the mark genuinely is content — but these are invented companies, and an invented SVG trademark is a bigger claim than a placeholder. So the wall draws a silhouette plus a set wordmark, and wordmark exists only for the marks the design draws in capitals.

The story count the wall prints is derived from the collection, not written down.

orderRelated is the same generic helper the blog uses, called with different accessors:

const related = orderRelated(allStories, story, {
  identity: (entry) => entry.id,
  group: (entry) => entry.data.industry,
});

Same-industry stories come first, then the rest, with the current story removed. Writing it generically is what let one function serve the blog’s related row, the stories’ and the integrations directory’s.

SEO on a story page

Case studies get their own JSON-LD node. getCaseStudySchema in @js/schema builds it, paired with a BreadcrumbList and a visible breadcrumb, and heroImage — required in the schema — is passed as the page’s image, so every story has a real social card with real dimensions.

This is the pattern the SEO rule requires of any new page type: a new page type brings its own structured data and its own entry in the curated crawl surfaces. The Article node covers posts, JobPosting covers careers adverts, and the case-study node covers these.

Adding a story

Create src/data/customers/en/my-client/index.md. The folder name is the URL slug. Fill the frontmatter — it is the largest schema in the theme, and the build will name any field you miss — put the hero photograph beside it, and write the narrative as markdown with h2 sections so the reading rail has something to track.

Three fields catch people out. metrics must have exactly three entries. description and standfirst are different strings doing different jobs. And industry is one string, not an array — the design draws exactly one everywhere it appears, so an array would make zero or three representable when neither can render.

If the industry is new, the archive route, the filter chips and the header’s “By industry” mega-menu column all pick it up on the next build, because all three derive from the collection rather than from a list.

The full schema is in Content Collections.

NEXT STEPIntegrations Directory