Spike evaluation: Astro + content collections + Pagefind + Decap CMS
First full vertical slice through SPECIFICATION.md’s open questions, covering one option from each category (SSG, search, CMS, styling) end-to-end rather than a shallow scaffold of everything. Verified by running the actual build/dev/preview flows locally and querying the built Pagefind index directly in Node, not just by reading code — see “What was actually run” below.
What this spike proves works
- Single source of truth for data.
/data/entries/*.yaml(repo root) is read directly by Astro’s content collection loader (globloader with abasepointing outsidesrc/content) and by Decap CMS’s local backend proxy (decap-serverrun from the repo root). Neither tool needed a copy of the data — a real concern for a spike meant to compare multiple SSGs against the same canonical dataset. - HDC visual styling reused, not reinvented. Per
SPECIFICATION.md’s “Styling” section, colours, the logo, category-chip colours, and the readiness-tier “signal dot” icon were extracted directly from the live site’s own stylesheets and SVG assets (seesrc/styles/hdc-theme.cssheader comment for exact source URLs) rather than designed from scratch. The one thing not reused is the body font (Museo Sans Rounded) — it’s a commercially licensed webfont self-hosted by HDR UK, so copying or hotlinking it wasn’t appropriate for a spike; Quicksand (Google Fonts, open license) stands in until licensing is confirmed one way or the other. - Filters cover all 8 facets in the current
SPECIFICATION.mdFilters section: Categories, Readiness tier, License, Intended users, Primary purpose, Cost model, Support level (all checkbox facets, options generated from whatever values actually appear in/data/entriesrather than hardcoded — several are absent on many entries, per the “subset by design” note inschema/README.md), plus Number adopting as a min/max range filter (entries with no recorded value are excluded once either bound is set, since “in range” can’t be determined for them). A “Go” button next to the free-text box triggers an explicit search (checkbox/range filters still apply live on change), and “Reset” clears every filter, the range inputs, and the free-text box in one click. (An earlier dedicated/search/page built on Pagefind’s default UI exposed only 3 of these as native Pagefind facets — it was removed once the homepage became the full search surface; see “Search-index-driven listing and pagination” below.) - Search reaches fields never displayed anywhere, everywhere search is offered. At the time of this test,
contactTeamPerson(“Sangya Pundir”) was never rendered as visible text on the Cohort Discovery Service entry page, only carried viadata-pagefind-meta, yet searching “Sangya” returned that entry — confirmed by: (Since changed: SPECIFICATION.md’s later “Render the whole record” requirement means every field,contactTeamPersonincluded, is now visible on the entry page — the hiddendata-pagefind-metaspans are gone, and the Sangya test still passes as a full-record-coverage check, re-verified against the rebuilt Pagefind index.)- Decompressing a generated
.pf_fragmentand finding “Sangya Pundir” in both the indexedcontentand structuredmeta. - Directly calling
pagefind.debouncedSearch('Sangya')against the built index in Node and getting backcohort-discovery-service. - This now holds on both search entry points, not just one — see the “found the hard way” note below for why that took two passes.
- Decompressing a generated
- Git-backed CMS wired to the same data — including a genuinely “drop a file” pages collection, not just structured entries.
public/admin/config.ymldefines a 32-fieldentriesfolder collection pointed atdata/entries, and apagesfolder collection (create: true) pointed atcontent-pages/. Runningdecap-serverfrom the repo root and callingentriesByFolderagainst both returned real content — confirms the wiring without needing a GitHub OAuth app, using Decap’slocal_backend: truemode. Becausepagesis a folder collection rather than a fixed list of named files, clicking “New Site Page” in the CMS creates a brand-new.mdfile with no config change, and Astro’s single dynamic route (src/pages/[page].astro) picks it up automatically — verified directly by dropping a throwaway markdown file intocontent-pages/and confirmingnpm run buildproduced a matching, correctly-styled page with zero code changes, then removing it. The admin UI itself (/admin/) loads and resolves its config correctly under both Decap’s hash router and a bare/adminURL — see friction notes below for what that took to get right. /summary/and/evaluation/render through Astro’s own content pipeline, not a bolted-on markdown step. Both are entries in apagescontent collection, fetched withgetEntry()and rendered with Astro’srender()+<Content />— the same mechanism a “real” markdown page in this SSG would use, and the same two files Decap CMS edits. Verified end-to-end: built pages return real headings (with Astro’s auto-generated heading anchors) and themarkdown-bodystyling class, anddecap-server’s proxy API returns the same file content Astro rendered.- All 4 other spec-listed search libraries integrated as full, filter-equipped comparisons, not toy demos. Lunr.js, FlexSearch, MiniSearch, and Orama each got a dedicated page (
/search/lunr/,/search/flexsearch/,/search/minisearch/,/search/orama/, all reachable from/search-engines/) indexing the same 13 entries from a shared/search-data.jsoncorpus, each carrying the same 8-facet filter set as the homepage (shared viaFilterForm.astro+matchesFilters(), not reimplemented per engine). All 4 passed the same “Sangya” test Pagefind passed, verified both as standalone library calls and combined with filter state in Node against the real corpus — seesearch-engines.mdfor the per-engine integration write-up.
Friction encountered (and fixed)
- Two successive wrong shapes for
/summary/and/evaluation/before landing on the one the spec actually asks to test. First pass: a rawfs/markedstep outside Astro’s content pipeline entirely — didn’t exercise Astro’s own markdown handling at all. Second pass: fixed to use a realpagescontent collection andrender()+<Content />, but with a hardcoded loader pattern (glob({ pattern: '{SUMMARY,EVALUATION}.md' })) and one hand-written.astropage per markdown file — which is precisely the “custom create pages for each file” anti-patternSPECIFICATION.md’s “Demonstrate Markdown rendering” section calls out as not what’s being tested. Corrected by: moving the two docs into a dedicatedcontent-pages/folder, changing the loader to a genericglob({ pattern: '*.md', base: 'content-pages' })(any file, not a named list), and replacing both hand-written pages with one dynamic route (src/pages/[page].astro) that renders every entry in the collection viagetStaticPaths(). Decap’s config changed to match — a folder collection (create: true) overcontent-pages/, not two named files — so adding a page through the CMS and dropping a file by hand exercise the identical path. The glob loader lowercases filenames for entry IDs by default (summary.md→ id"summary") — not documented anywhere obvious, found by temporarily logginggetCollection('pages')during a build rather than guessing. - A documented data gap was left rendering as a broken link on the actual page — documenting a gap in
GAPS.mdis not the same as fixing its user-facing effect. DUCkS Framework’sLinkfield had 3 URLs concatenated with no delimiter; this was noted inGAPS.mdearly on, but the entry page still rendered it as one non-functional<a href>— only caught when the built page was actually clicked, not from reading the data or the gap note. Fixed properly (not just re-documented) by splitting the 3 URLs on their unambiguoushttps://boundary and changing the schema field from a singlelink: stringtolinks: string[]everywhere, since the source can genuinely hold more than one URL per entry — not just for this one record. Lesson: a data-quality note without a corresponding UI check is easy to forget actually breaks something for a user; click through the pages a gap affects, don’t just log it. - The homepage’s free-text box was scoped too narrowly on the first pass. It only matched each card’s visible
name+description, so searching “Sangya” there found nothing — reproducing the spec’s own example as a bug./search/(the dedicated Pagefind page) worked correctly the whole time; the homepage quick-filter didn’t, because it was a second, weaker, hand-rolled substring search instead of using the same index. Fixed by having the homepage script load/pagefind/pagefind.jsdirectly and callpagefind.debouncedSearch(), mapping result URLs back to carddata-ids to drive the existing show/hide filtering. It falls back to the old substring match only when Pagefind’s index doesn’t exist yet (astro dev, pre-build), and the UI now states which mode is active next to the result count. Lesson for the next iteration: a page offering “search” should have exactly one search implementation behind it, not a full one on a dedicated page and a lesser one wherever else a search box appears. - Decap’s default “load config.yml relative to the current page” resolution is trailing-slash-sensitive, and Decap’s own hash-based router (
/admin#/...) makes this worse:location.pathnameis/admin(no trailing slash) regardless of the#/fragment, so the browser resolves the relative./config.ymlagainst/instead of/admin/and 404s. This is what produced the “Error loading the CMS configuration” / “Failed to load config.yml (404)” errors seen when testing this spike. The first fix attempt was itself incomplete: a<link rel="cms-config-url" href="/admin/config.yml">tag with notypeattribute is silently ignored by Decap. Traced into the actual bundle (decap-cms@3.14.1/dist/decap-cms.js) and found it only trusts the tag oncetypematches{"text/yaml": ..., "application/x-yaml": ...}. The working tag is<link href="/admin/config.yml" type="text/yaml" rel="cms-config-url">(src/pages/admin/index.astro) — verified against the bundle source directly, not by assumption. - Astro’s dev server does not resolve directory-index requests (
/admin/→/admin/index.html) for files dropped straight intopublic/. Only the literal/admin/index.htmlURL worked;/admin/and/admin404’d. Fixed by moving the CMS bootstrap HTML into a real route (src/pages/admin/index.astro) instead ofpublic/admin/index.html. Worth re-checking against the eventual GCP bucket + CDN host, since some static hosts auto-resolve directory index and some don’t. globloader’sbaseis relative to the Astro project root, not the config file’s own location. Cost one failed build to discover (../../../data/entriesvs the correct../../data/entries).- Vite tries to statically resolve
import('/pagefind/pagefind-ui.js')at build time even though that file doesn’t exist until the postbuild Pagefind step runs. Needed an explicit/* @vite-ignore */comment on every dynamic import of a Pagefind asset (the homepage’spagefind.jsimport needed the same treatment as/search/’spagefind-ui.jsone). - Root-absolute links inside markdown content pages silently break under the combined subpath build.
search-engines.md’s links to the four engine demo pages were written as/search/lunr/etc. — correct standalone, but under the combined build (BASE_PATH=/astro) they resolved to the deployment root and 404’d. Markdown-authored content never passes through thebase-aware link machinery that.astrotemplates use, so absolute paths in it can’t be rewritten. Fixed by making them relative (../search/lunr/), which resolves correctly at any mount point — and only caught when a user actually clicked them, months of “every page returns 200” verification notwithstanding: checking that pages exist is not the same as checking that the links between them go to the right place. The same rule applies to anything a CMS editor writes: relative links in markdown, never root-absolute. Astro content collections validate with Zod, not JSON Schema —src/content.config.ts’s Zod schema is a hand-maintained mirror of/schema/hdc-entry.schema.json. For 32 fields this was mechanical but tedious; a future iteration should generate one from the other (e.g.json-schema-to-zod) rather than maintain both by hand. - Pagefind’s index only exists after a full build —
astro devhas no search results at all, onlynpm run build && npm run previewdoes. This is inherent to how Pagefind works (it indexes static HTML output), not an Astro-specific issue, but it means “does search work” can’t be checked during normal dev iteration — worth factoring into whichever SSG is chosen, since this is a Pagefind property, not an Astro one. It’s also why the homepage box needs an explicit dev-mode fallback rather than just failing silently. - No CMS-specific friction beyond the config being sizeable (32 fields, mirroring the schema) — most of the effort was mechanical field-by-field transcription from
/schema/hdc-entry.schema.json, same category as the Zod duplication above. - jsdom can’t execute these pages’ real bundled module scripts — attempted a genuine end-to-end browser check (load the built
/search/lunr/page, type into the real input, read the real rendered results) rather than trusting logic tests alone. jsdom loaded the page but the<script type="module">bundle never ran (status stayed on its static placeholder text), consistent with jsdom’s known incomplete support for ES module script execution. Fell back to the next-best verification: calling the exact same library API sequences used in each page’s script directly in Node against the real/search-data.jsoncorpus fetched from the running preview server — confirms the logic is correct, just not that Vite’s bundled output executes identically in a real browser. Worth a proper headless-browser tool (Playwright/Puppeteer) in a future iteration if this class of check matters more. - Filters were built once for the homepage, then had to be retrofitted onto 4 more pages. Adding them page-by-page from scratch would have meant 5 near-identical copies of the same fieldset markup and matching logic. Refactored into a shared
FilterForm.astrocomponent (renders fromcomputeFilterOptions(), given real entries) and a sharedmatchesFilters()/readFilterState()/clearFilterForm()set insrc/lib/searchDemo.ts, then wired all 5 pages (homepage + 4 engines) to it — each engine only supplies its own “which IDs match this text query” step. Lesson: when a UI requirement (filters, in this case) is going to appear on more than one page, build the shared piece before the second page, not after the fourth.
Second layout, images, and environment rendering (SPECIFICATION.md, “Demonstrate alternative layouts and images” / “Individual environments/rendering”)
- A second, genuinely distinct page template was added with no changes to
Base.astroor any existing page —src/layouts/ArticleLayout.astro(Lora serif headings, teal accent, warm paper background) is a completely separate layout component, used only bysrc/pages/articles/[article].astro. Every other page keeps usingBase.astrounchanged. Astro’s per-page layout opt-in (already noted above as more convenient than Next’s default-everywhere root layout) is exactly what made this trivial: no routing restructure was needed, unlike the Next.js spike. - Astro’s content collections resolve and optimize relative image paths in colocated markdown automatically — genuinely surprising, and the best result of the three SSGs. A second content collection (
articles,src/content.config.ts) was pointed at the shared repo-root/markdownfolder via theglobloader’sbaseoption (exactly like the existingpages/entriescollections point outsidesrc/).outline.md’sreferences were rendered through Astro’s ownrender()+<Content />pipeline with zero path-rewriting or manual image copying — Astro found the images relative to the source markdown file (even though that file lives outsidesrc/entirely), copied them into the build, converted them to hashed.webpfiles, and added explicitwidth/height/loading="lazy"attributes, all without being asked. Confirmed by grepping the built output:<img src="/astro/_astro/amr_portal.BA8Nf4_l_Z136jFc.webp" ...>. Next.js and Eleventy both needed an explicit text-rewrite + image-copy step to achieve the same result (see their own EVALUATION.md sections) — this is a genuine, material capability difference, not a styling preference. - Build-time environment/rebadging flags (
SITE_ENV,SITE_BRAND) are plainprocess.envreads, no framework mechanism needed.src/lib/deploymentConfig.tsreads them directly in frontmatter (Astro components run as server code at build time by default) and bothBase.astroandArticleLayout.astroimport from it independently. Verified:SITE_ENV=preprod SITE_BRAND="Gateway Preview" npm run buildproduces a “preprod build — not production” ribbon and the rebadged wordmark/title on every page, including the differently-styled article layout, with no ribbon/default branding at all when the vars are unset. This is Astro’s simplest possible answer to “feature-flag-style rendering per deployment target” — no separate config files or build profiles needed, just env vars read once and threaded through. - Decap’s
articlesfolder collection over/markdownneeded no new concepts — same folder-collection shape aspages, just a differentfolder:/media_folder:pair. Confirmed viadecap-server’s proxy API returningmarkdown/outline.md.
Search-index-driven listing and pagination (SPECIFICATION.md, “Search-index-driven listings with pagination”)
- The homepage no longer server-renders any entry cards. The first pass rendered all 13 cards into the HTML (with the dataset re-encoded into
data-*attributes) and used client JS to hide non-matches — wrong at the 100s-of-entries scale the spec targets. Now the client fetches the same/search-data.jsoncorpus the engine demo pages already used, applies free text (Pagefind, or a corpus-substring fallback pre-build) plus the 8 structured filters against corpus objects, and renders only the current page of results through one shared card template (entryCardHtmlinsrc/lib/searchDemo.ts). - Pagination + page size: previous/next controls with a page indicator, and a per-page selector offering 5/10/20/50/100, defaulting to 20. Any change to the query, filters, or page size returns to page 1; Reset also restores the default page size. Pager controls that can’t be used aren’t rendered at all — no Previous on page 1, no Next on the last page (user feedback: a button that goes nowhere shouldn’t exist, disabled or otherwise).
- All four
/search/<engine>/comparison pages share the exact same paginated view as the homepage — oneResultsList.astromarkup component plus onecreateResultsView()controller insearchDemo.ts, so pagination behaviour can’t drift between the five search surfaces (user feedback caught the initial inconsistency: the homepage paginated, the engine pages didn’t). This replaced the oldrenderResults()helper outright. - Several review findings fell out of this for free: the duplicated card markup (server + client template strings) collapsed into one escaped client template; the fragile
|-delimiteddata-*corpus encoding is gone entirely; and the dev-mode fallback now substring-searches the corpus’s fullbodyfield — which includescontactTeamPerson— so even pre-build dev search passes the “Sangya” test (it lacks Pagefind’s ranking, and says so in the mode label). - Trade-off accepted knowingly: the listing is now client-rendered, so it’s empty without JavaScript (a
<noscript>note says so). Entry pages remain fully static HTML, and Pagefind’s index is built from those pages, so search/SEO of the actual content is unaffected — the homepage was already JS-dependent for anything beyond reading the full list. - Verified: built output contains zero pre-rendered cards, the corpus endpoint carries all 13 entries with
bodyincluding never-displayed fields, and the pagination math (13 entries at size 5 → 3 pages, empty result → 1 page) was checked directly in Node against the built corpus.
Open questions for comparing against Next.js / Eleventy
- Astro’s content collections +
globloader made “read data from outsidesrc/” straightforward. Confirm whether Next.js (via a custom data-fetching layer, since it has no first-party content-collections equivalent) and Eleventy (via its own data cascade) are as easy, harder, or easier. - Astro required zero framework/hydration for the filter UI (plain JS, including the Pagefind wiring). If a future page needs richer interactivity, check the cost of adding an island (React/Svelte/etc) vs Next.js’s default client-first model.
- Decap’s config is SSG-agnostic (it only cares about a folder of YAML/Markdown files) — expect this same
config.ymlshape to carry over almost unchanged to the Next.js/Eleventy iterations, which is a point in favour of deciding on the data folder layout once (done in/data+/schema) rather than per-SSG. - Whatever page(s) offer search in the next iteration, wire them all to one query implementation from the start — don’t let a “quick filter” on a listing page and a “real search” on a dedicated page drift into different capabilities, as happened here.
- Not yet tried: Sveltia (the other CMS candidate), or Lunr/FlexSearch/MiniSearch/Orama as search alternatives, or a real GitHub-backed (non-local) Decap deployment requiring an OAuth client.
What was actually run (for reproducibility)
npm create astro@latest -- astro --template minimal --no-install --no-git --typescript strict --yes
npm install
npm install -D pagefind decap-server
npm run build # astro build && pagefind --site dist
npm run preview # served /, /entries/*, /search/ — verified via curl
npm run cms # decap-server from repo root; queried its API directly to confirm it lists data/entries
Also run directly against the built output, not inferred from code:
- Schema validation of all 13 converted YAML entries against
/schema/hdc-entry.schema.json(ajv, draft 2020-12) — all passed. pagefind.debouncedSearch('Sangya')called directly in Node against the builtdist/pagefindindex (viapagefind.js’s browser API, pointed at the running preview server over HTTP) — returnedcohort-discovery-service, both before and after the homepage free-text fix.- Decap’s admin bundle (
decap-cms@3.14.1/dist/decap-cms.js) fetched and grepped directly to confirm the exactcms-config-urllink-tag contract, rather than guessing from documentation alone.
Post-spike code review (senior developer/architect pass)
An independent review of this spike’s code, conducted after completion, from the perspective of a senior developer/architect deciding what carries into the next stage of development. The spike passed its functional bar; the findings below are the patterns that must not be copied verbatim into a product, plus the questions to answer before the first production sprint.
AI declaration: original implementation by Claude Sonnet 5 (claude-sonnet-5); this review by Claude Fable 5 (claude-fable-5), based on a full read of every source file in this spike plus the spec, report, and canonical schema for cross-checking. Every finding below was verified against the actual code, not inferred.
Code-level improvements
- The three-way schema duplication has already drifted.
/schema/hdc-entry.schema.json, the Zod schema insrc/content.config.ts, and the Decap fields inpublic/admin/config.ymlare hand-synced — andcategoriesis an 8-value enum in the JSON Schema and CMS config but plainz.array(z.string())in Zod (content.config.ts:16), so an invalid category already passes the Astro build undetected. Generate the Zod schema and CMS options from the JSON Schema (or add a CI drift check) before this pattern is productised. - The entry card is rendered twice: server-side in
src/pages/index.astroand again as a template string inrenderResults()insrc/lib/searchDemo.ts— two implementations of the same markup to keep in sync. - ~50 lines of identical DOM/event wiring are copy-pasted across all four engine demo pages (
src/pages/search/{lunr,minisearch,flexsearch,orama}.astro); only the index/query step differs. Also worth deciding explicitly: do the four losing engines andsearch-data.json.tssurvive into a product at all, now Pagefind has won? - The
data-*filter corpus uses|as an unescaped delimiter (index.astrowrites,docFromCard()reads) — any future field value containing a pipe silently corrupts filtering. It’s also a second parallel encoding of the corpussearch-data.json.tsalready provides. computeFilterOptions()(src/lib/filterOptions.ts:40) yieldsInfinityplaceholders if no entry carriesnumberAdopting— latent with today’s data, guaranteed once optional-field sparsity grows.
Architectural concerns
- Dev and production search behave differently by design: the homepage free-text silently degrades to name/description substring matching when the Pagefind bundle 404s — exactly the “weaker second search” failure
SPECIFICATION.mdbans, resurrected as a fallback. A broken deploy would quietly serve worse search rather than fail loudly. - The dedicated
/search/page didn’t meet the spec’s own filter requirement: it exposed 3 PagefindUI facets (category/readiness/license), not the full 8-facetFilterFormwith Go/Reset — the spec says every search surface MUST carry all filters. (Since resolved by removal — user testing also found the page non-functional, and once the homepage became the full, paginated search surface it was redundant; the page and its nav link are gone, making the homepage the single search surface. The four/search/<engine>/comparison demos remain, reachable from/search-engines/.) - Accessibility: the result count and re-rendered result lists have no
aria-live/role="status"— screen-reader users get no feedback when filtering. Fine for a spike; must be fixed before real users. - Filter/search state is not reflected in the URL, so filtered views can’t be bookmarked or shared.
Security considerations
- Stored XSS in
renderResults()(src/lib/searchDemo.ts): entryname,description, tier, categories, and URL were interpolated intoinnerHTMLunescaped. These fields are CMS-authored — a description containing markup would execute on every engine page. (Since fixed — see “Addressed since this review” below.) - Decap CMS loads from unpkg with a floating version range (
decap-cms@^3.0.0, no SRI hash) — an unpinned third-party script that will run with editors’ GitHub tokens in a real deployment. Pin and self-host. Relatedly,/admin/ships in the public static output andlocal_backend: truesits in the committed config — production needs an environment-split CMS config, which doesn’t exist yet. bannerLinkand entrylinksrender intohrefunvalidated — a CMS editor can injectjavascript:URLs; the schema’sformat: uridoesn’t restrict scheme.- Astro’s markdown pipeline passes raw HTML through by default, so CMS-authored pages are trusted HTML end-to-end — acceptable only if editors are trusted and every CMS change goes through PR review; that’s a policy decision to make explicitly, not inherit.
Questions for the next stage
- What is the single source of truth for the data model, and what generates the Zod schema, Decap config, and filter facets from it — codegen, a CI drift check, or accepted manual sync?
- Zero automated tests and no CI exist. What’s the minimum harness before iteration two — ajv schema validation as a build gate, a Playwright “Sangya” smoke test, a deploy workflow?
- Sveltia (the other CMS candidate) was never trialled despite being in scope, and Decap’s real GitHub backend (OAuth app, PR-based editorial workflow, per-environment config) is untested — when do these get decided?
- HDR UK convention gap: no MIT/copyright headers exist in any new source file, per
AGENTS.md— sweep before this goes public.
Addressed since this review
The “Search-index-driven listing and pagination” round (see that section above) resolved several of the findings listed here, verified by rebuild: the stored-XSS innerHTML interpolation in searchDemo.ts (all client-rendered card fields now pass through an escapeHtml helper), the duplicated entry-card markup (one shared entryCardHtml template now), the fragile |-delimited data-* corpus encoding (removed with the server-rendered cards), the weaker dev-mode fallback search (now substring-matches the corpus’s full body, so “Sangya” passes pre-build too), the missing aria-live on the result count (added), and the review’s original question about render-everything-and-hide at scale (answered — the listing is now index-driven and paginated, per the spec’s new section). Separately, the 3-facet /search/ page was removed outright after user testing found it non-functional and redundant against the now-complete homepage search. Still open from this review: the schema-sync strategy, tests/CI, CMS backend decisions, Infinity in computeFilterOptions, and URL-reflected filter state.