Skip to content
AstroCraft Docs
On this theme

Customer Stories

Customer stories are four routes over one collection. /customers/ lists page one, /customers/page/<n>/ continues it, /customers/<slug>/ draws a single story, and /customers/industry/<slug>/ filters by industry. Seven sample stories ship across five industries.

The route is /customers/ rather than /case-studies/ because that is the href the navbar and the footer have pointed at since the chrome was built. Two surfaces already promised the URL; the page is what stops them 404ing.

The routes

Route File Builds
/customers/ customers/index.astro one page — six equal cards
/customers/page/<n>/ customers/page/[page].astro one per page from two upward
/customers/<slug>/ customers/[slug].astro one per published story
/customers/industry/<slug>/ customers/industry/[industry].astro one per label, always

Pagination works exactly as the blog’s does, through the same restPages helper and for the same routing reason: customers/[...page].astro beside customers/[slug].astro would put two patterns on /customers/<x>/, and the dynamic segment wins — /customers/2/ would resolve to the story route.

There is no featured card above the grid, unlike /blog/. The design draws six equal cards and nothing else, so the stories are ordered newest-first, full stop. There is no featured flag in the schema either: a layout that draws one row of equals gives no reason to add one. The seventh story exists precisely so page two is real rather than theoretical.

The industry routes exist for a breadcrumb

This is the one real difference from the blog’s category routes, and it is worth understanding before you decide whether to keep them.

The blog builds category routes because its design draws a tab row. The customers section builds industry routes because two things need somewhere to point: the story page’s breadcrumb has an industry crumb, and every card carries an industry chip. A crumb that leads nowhere is worse than no crumb, and getBreadcrumbSchema needs a URL per item or the schema and the markup stop agreeing.

Same mechanism, advertised differently — these pages pass no tail to Global/ListingMasthead, so they are reached from a chip or a crumb rather than from a nav.

As with categories, a page is built for every label including one no story has claimed, and slugs are derived by the same slugify that industryHref uses, so the links and the params cannot disagree about what “Public sector” is called.

export const INDUSTRY_LABELS = [
  "Healthcare",
  "Fintech",
  "Marketplace",
  "Public sector",
  "B2B SaaS",
] as const;

What a story page draws

/customers/<slug>/ composes StoryHeader, ResultsStrip, StoryBody with StoryAside, and RelatedStories. Almost all of it comes out of frontmatter, which is why the schema is the longest of the four.

The header carries the title, the standfirst, the industry crumb and the customer contact — a person at the customer, not a site author. When a story ships no contact.avatar, the header falls back to the monogram circle the cards draw; monogram() in @js/caseStudies derives it from the company name.

The results strip is the dark metrics card: exactly three figures, each tweened by the CountUp primitive when the strip scrolls into view, plus a methodology caption saying what was measured over what window. prefix and suffix sit outside the counted span deliberately — a “%” ticking through values reads as a glitch — and from lets a figure count from a before-value rather than from zero.

Its shape is StoryResults, declared once in src/config/types/configDataTypes.ts and asserted against the Zod schema with satisfies. The same shape backs the index’s aggregate band, so one component renders both and the two rows that are meant to look identical cannot quietly stop doing so.

The aside is the atAGlance fact list — free-form label/value rows, because every story counts a different thing and an enum of row labels would be a schema for one company.

The foot of the article is the about card, then a related-stories rail of three: same industry first, then whatever else is newest, through the same relatedBy rule the blog’s strip uses.

The reference-call band

Story pages and the customers index carry a different footer band from the rest of the site. Where most pages close with the CTA pair and the blog area closes with the newsletter sign-up, these close with a reference call form — a lead that mails a person rather than filing an address in an audience.

The band is threaded through BaseLayout’s footerBand prop rather than decided inside the Footer, so a page can see what its own chrome is asking visitors to do:

<BaseLayout title={} description={} footerBand={{ kind: "reference", story }}>

The band carries its own payload, which is why the layout forwards one value rather than renaming a companion prop at every hop. Like every form in the theme it ships inert with a real server half behind it — Forms & Email covers connecting it.

Adding a story

  1. Create src/data/case-studies/<company-slug>/index.md. The folder name is the URL and the breadcrumb’s last crumb, so use the company slug — that is what somebody pasting the link is looking for.
  2. Fill the required frontmatter: title, description, industry, company, resultMetric, contact, pubDate, heroImage, heroImageAlt, results (exactly three metrics), atAGlance (at least one row) and about.
  3. Drop the hero into src/assets/images/case-studies/.
  4. Write the body.

The story appears on the index, in its industry listing, in llms.txt and in the sitemap immediately. draft: true holds it back from all of them.

The one thing to keep an eye on is industry: it must be one of the five labels, or the build fails naming your entry. Adding a sixth industry means adding it to INDUSTRY_LABELS in src/config/caseStudyData.json.ts, which is also what builds its route.

NEXT STEPIntegrations Directory