Skip to content
AstroCraft Docs
On this theme

Work & Case Studies

The work section is two routes over one collection. /work/ lists every case study in curated order; /work/<slug>/ draws a single study from its frontmatter. Six sample projects ship with the theme, and a third surface — the home page’s case rail — reads the same six.

That third consumer is why work is a collection rather than an array in a section file. It started life as a local constant inside Home/CaseStudies.astro, which was correct while that was the only reader. Two more surfaces landed and it moved.

The read layer

Everything both routes need is in src/js/work.ts, and it is short:

export const workHref = headerHref("Work");        // "/work/" — read from the IA, never spelled out

export async function getCases(): Promise<CaseStudy[]> {
  const cases = await getCollection("work", ({ data }) => data.draft !== true);
  return cases.sort((a, b) => a.data.order - b.data.order);
}

export function caseHref(entry: CaseStudy): string {
  return `${workHref}${entry.id}/`;
}

export function relatedCases(cases, current, limit = 3): CaseStudy[] { … }

Ascending by order, drafts dropped. That sort is an explicit frontmatter fact rather than a date sort, because the selected works are a sequence somebody chose and a case study in this design carries no publication date. Sorting on something that merely looks like an order would be inventing data.

Drafts are filtered here rather than per route, so an unfinished case cannot reach the index while still resolving from a card on the home page.

workHref is headerHref("Work") rather than the literal /work/, so the route prefix lives in the information architecture and nowhere else. If you move the section, one edit in navData.json.ts moves every link to it.

The index page

/work/ is a thin shell: a hero, the grid, the shared CTA band. It resolves the collection and passes it down as typed props, per the sections contract — the grid does not read the collection itself.

const cases = await getCases();
---
<WorkHero />
<WorkGrid cases={cases} />
<CallToAction />

WorkGrid renders three across. Internally it delegates to CaseGrid.astro, a sub-part shared with the related rail on the detail page: both drew the same grid classes, the same StaggerReveal, the same card and the same map, and differed only in their section shell and heading. Only the grid is shared — the two shells stay separate, because one needs an sr-only heading (the page masthead above it is already the heading) and the other a visible one, and merging them would have meant a headingVisible flag.

CaseGrid always renders an <ol>, with no prop to choose. An early draft took as?: "ol" | "ul" so the related rail could be “three suggestions rather than a ranking” — but the rail is drawn from the curated running order, by rotating through it, and renders in that order. The list is ordered whether or not the markup admits it.

The card surface differs between pages, on purpose

CaseCard has a surface variant, and the index uses recessed while the home page uses raised. That is the design’s own reading rather than a preference: the home rail draws its cards a step above the band behind them, and the index draws them a step below. The variant is paired per theme, because the light and dark neutral ramps are not parallel — one fixed colour cannot read the same over both grounds.

The case-study page

/work/<slug>/ is built entirely from the entry. Its section order is fixed:

<CaseHero entry={entry} />
<Metrics surface="background" />
<CaseNarrative entry={entry} />
{gallery && gallery.length > 0 && <CaseGallery entry={entry} gallery={gallery} />}
{quote && <CaseQuote client={client} quote={quote} />}
{related.length > 0 && <RelatedCases cases={related} />}
<CallToAction />

Three of those six are conditional, and the page’s band structure still resolves when they drop out. With no gallery, the narrative, quote and related rail become one continuous card slab, opened by the Metrics band’s seam and closed by the CTA’s. That is precisely why the section seams live on the sections rather than in the route shell — a shell that owned them would have to know which sections rendered.

There is deliberately no per-page JSON-LD

This is worth understanding, because the obvious move would be to add some.

The candidate builder is getArticleSchema, and it does not fit. A case study in this design has no author and no publication date, so the node would ship with two effectively-required fields either empty or invented — which is markup and structured data disagreeing, the one thing structured data must never do. BaseHead’s automatic Organization and WebSite graph still covers the page.

If your case studies gain a real date, adding a CreativeWork builder beside the others in @js/schema is the upgrade path. Do that rather than reaching for getArticleSchema and filling the gaps with plausible values.

The same reasoning governs getBreadcrumbSchema, which ships unused: it requires a visible breadcrumb nav, and this design draws none.

The narrative

narrative is an array of { label, title, body[] }, and it is frontmatter rather than the markdown body because the design draws it as structure. The label (“The challenge”) and the title (“Legacy debt meeting modern volatility”) are two halves of one heading sitting in two different columns — a body with ## headings cannot express the left-hand rail.

Each string in body renders as its own paragraph. There is no markdown parsing inside them, and .min(1) on the array makes an empty row unrepresentable.

The markdown body of a work entry is never rendered. The route never calls render(), so prose written below the closing --- produces nothing. All six shipped entries end there. Case-study copy goes in description and narrative[].body.

relatedCases takes the next three in the running order, wrapping past the end:

rotateAfter(cases, cases.findIndex((e) => e.id === current.id), limit)

The design mock draws Meridian’s own card in Meridian’s related row — the designer reused the index grid’s first row verbatim — and a page listing itself as related to itself is markup disagreeing with meaning, so some departure was required. Rotating is the version that costs nothing: taking the first three instead would have put the first entry on five of the six detail pages and the last entry on none.

The wrap arithmetic lives in src/js/rotate.ts rather than beside its only caller, and that placement is a pattern used throughout the theme. work.ts imports astro:content, which exists only inside the Astro build, so nothing in it can carry a bare-Node self-check. Index arithmetic that wraps is exactly the logic that wants one — so it moved to a module that imports nothing, with rotate.test.ts beside it:

rotateAfter(["a", "b", "c", "d"], 2, 2); // ["d", "a"]
rotateAfter(["a", "b"], 0, 5);           // ["b"] — never repeats, never includes the anchor

The cap is items.length - 1, not items.length. The anchor is excluded, so a four-item list can only ever yield three others however large the limit — without that, a wrap of length N returns the anchor again as its last element.

The home page rail

Home/CaseStudies takes the same cases array and slices it into two rows of three:

const rows = [cases.slice(0, 3), cases.slice(3, 6)].filter(…);

The slice is the point: the home page shows a selection, /work/ shows the archive. Add a seventh case study and the home page still draws six, while the index grows.

Adding a case study

  1. Create src/data/work/<slug>/index.md.
  2. Give it client, title, description, a distinct order, cardImage, heroImage and at least one narrative row. gallery, quote and draft are optional.
  3. Put the images under src/assets/images/work/ and reference them relatively.
  4. Build. The route, the card, the home rail and the related rotation all follow.

Watch the order values — duplicates are not detected and there is no secondary sort key. And remember the YAML colon rule: any body string containing : needs a >- folded scalar. See Content Collections for the full schema.

NEXT STEPBlog & RSS