Technology spike: findings and recommendation
Status: spike complete. All three planned SSGs (Astro, Next.js static export, Eleventy) have been built as full vertical slices — data model, search, filters, CMS, styling, markdown-page rendering — against the same shared data, to the same bar, so they are directly comparable. This document is the formal conclusion referenced by SPECIFICATION.md's "Progress so far" section.
Per-spike detail lives in each spike's own evaluation doc — this report synthesizes across them and adds the cross-cutting analysis (CMS future requirements, deployment) that no single spike owns.
spikes/astro/content-pages/evaluation.md(plussearch-engines.mdfor the 5-engine search comparison)spikes/nextjs/content-pages/evaluation.mdspikes/eleventy/src/content-pages/evaluation.md
Recommendation
Note: this section is the recommendation as written at spike close (by Claude Sonnet 5, which built the three spikes). A later, separately-attributed assessment — written after the post-spike code review and the follow-up rounds (second layouts/images, environment flags, index-driven pagination, full-record rendering) — revises it on new evidence: see "Going-forward stack: AI assessment" below. Both are preserved so the team can weigh how the evidence moved.
Eleventy, with Pagefind for search and Decap CMS for content editing.
Not because Astro or Next.js failed at anything tested — all three passed every functional requirement equally (see the comparison table below). The recommendation comes down to two things that matter more once this stops being a spike and becomes something Alliance members and non-technical editors depend on:
- Least moving parts for a content-heavy, low-interactivity site. This is fundamentally a data-driven listing with search and filters, not an application. Eleventy needed no bundler, no TypeScript, no client/server component boundary to reason about, and no framework-specific breaking-changes risk (see the Next.js AGENTS.md note below — real, not hypothetical). Every mechanism used (global data files, pagination, native markdown) is Eleventy's default, ordinary behaviour, not a workaround.
- The CMS story is best here, and it's the part with the longest tail of future work. Eleventy's native "any markdown file is a page" model is the closest match to what
SPECIFICATION.mdactually asked to prove ("test the CMS' ability to allow a user to drop a markdown file and have it render sensibly without your input") — Next.js needed an extra library and Astro needed a real content-collection concept to do the same thing. Since the CMS/non-developer-editing goal is central to replacing WordPress, the SSG that makes that goal require the least explanation is the safer long-term bet. One caveat worth carrying forward, found in this round's follow-up spike (see below): that native-markdown edge only holds for content living inside Eleventy's own input tree — for a folder shared across all three spikes' projects (like/markdown), Eleventy needed the same explicit image-path bridge Next.js did, and Astro's content collections handled that case better than either.
This is not a strong recommendation — it's a close call between three viable options, decided on secondary criteria once the primary ones came back tied. If the team's priority weighs differently (see "When this recommendation doesn't hold," below), Astro or Next.js are both legitimate alternate choices with different tradeoffs, not consolation prizes.
Comparison at a glance
| Astro | Next.js (static export) | Eleventy | |
|---|---|---|---|
| Data model access | glob loader + Zod schema (hand-synced with /schema) |
fs+yaml, hand-written TS interface |
fs+yaml in a global data file, no schema layer unless added |
| One-page-per-entry mechanism | getStaticPaths() |
generateStaticParams() (params is now a Promise — breaking change from older Next.js) |
pagination front-matter key |
| Markdown page rendering | Native (render() + <Content />) |
Needs an external library (marked) |
Native, zero-config directory convention |
| Client-side interactivity model | Plain <script>, reads the DOM |
Client Components ('use client'); had to deliberately avoid passing entry data as props to avoid double-shipping it |
Plain <script>, reads the DOM |
| Search bundler friction | Needed /* @vite-ignore */ on the dynamic Pagefind import |
Needed /* webpackIgnore: true */ on the same import |
None — no bundler touches JS files at all |
| Admin page layout isolation | Trivial — a page simply doesn't use the shared layout component | Not fully possible without restructuring into multiple root layouts; admin page inherits the site header | Trivial — .ignores + passthrough copy |
| Type-checking / build-time safety | Yes (TypeScript, opt-in strict) | Yes (TypeScript, default in scaffold) | No (plain JS; caught issues only surface in-browser) |
| "Drop a markdown file, get a page" verified | Yes | Yes | Yes (and the mechanism needed the least explanation of the three) |
| "Sangya" full-text test (Pagefind) | Pass | Pass | Pass |
| 8-facet filters + Go/Reset | Pass | Pass | Pass |
| Output is real static HTML, verified by reading the built file | Pass | Pass | Pass |
| CMS config portability | Baseline | Copy-paste from Astro's, path changes only | Copy-paste from Astro's, path changes only |
| Relative image paths in externally-located markdown | Resolved automatically (content collections) | Explicit rewrite + file copy needed | Explicit rewrite + file copy needed (native markdown doesn't help here) |
| Second, visually distinct page layout | New component, no restructuring | Full route-group restructure required | New template, no restructuring |
Build-time env/rebadge flag (SITE_ENV/SITE_BRAND) |
Plain process.env |
Needs next.config.ts's env field for client-shared code |
Plain global data file |
| Index-driven paginated listing (no render-all-then-hide) | Client script + existing JSON endpoint | React state + static route handler (needs force-static) |
Plain JS + .11ty.js JSON template |
Every row that says "Pass" for all three was independently verified — by curling built output, querying the built Pagefind index directly in Node, calling decap-server's proxy API directly, and (for the markdown-page claim) by actually dropping a throwaway file into each project's content folder and rebuilding, not by assuming success from reading the config. See each spike's evaluation doc's "What was actually run" section for the exact commands.
Search engine choice
Pagefind was the primary integrated engine in all three spikes. Before settling on it, the Astro spike also built and evaluated Lunr.js, FlexSearch, MiniSearch, and Orama side by side (spikes/astro/content-pages/search-engines.md) — full write-up there; headline findings:
- All five satisfied the hard requirement ("search MUST reach non-displayed attributes", tested via "Sangya" → Cohort Discovery Service through
contactTeamPerson). - Pagefind is the only one of the five that indexes built HTML directly (crawls
dist//out//_site/after build) rather than requiring an explicit JS document corpus — meaningfully less to keep in sync as entries evolve, at the cost of a postbuild CLI step the other four don't need. - Of the four JS-object-indexing libraries, MiniSearch had the least friction to integrate; FlexSearch had the most configuration surface (justified only at a much larger corpus than 13 entries); Orama's async/schema-typed API is the most "modern TypeScript library" of the four; Lunr's default matching is exact-term, not substring, which reads as broken search until you add a wildcard.
- None of this comparison needs to be repeated per SSG — the choice of search library and the SSG choice are independent decisions, and Pagefind integrated equally cleanly (modulo one bundler-specific ignore-comment) into all three.
CMS future-requirements evaluation
SPECIFICATION.md's "CMS gui like options" section lists five requirements beyond "can it edit an entry." All three spikes were evaluated against the same five:
| Requirement | Astro | Next.js | Eleventy | Status |
|---|---|---|---|---|
| Non-developer can navigate and update content (wording, images) | ✅ | ✅ | ✅ | Verified — folder collections for entries and pages, decap-server proxy API confirmed read/write paths for all three |
| Turn on/off + edit text/link for a banner | ✅ | ✅ | ✅ | Verified — one shared file (/data/site-settings.yaml), one shared JSON Schema, identical CMS singleton config in all three, renders correctly in all three |
| Separate CMS instances per Gateway environment | 🟡 | 🟡 | 🟡 | Partially tested. A build-time SITE_ENV/SITE_BRAND mechanism (see below) proves each SSG can render differently — an env ribbon and rebadged wordmark/title — per deployment target, and a real Decap articles collection was added and verified in all three alongside it. Still open: genuinely separate Decap backend config (separate repo/branch) per environment — this round only covers front-end rendering flags, not CMS routing. |
| Add images / embed video | 🟡 | 🟡 | 🟡 | Images now tested in all three — /markdown/outline.md's two image references render correctly end-to-end (see "Second layout, images, and environment rendering" below). Video embedding was not part of this round's scope and remains open. |
| Unique page layouts per page | ✅ | ✅ | ✅ | Now tested in all three — a second, visually distinct articles template (Lora serif / teal / warm paper, vs. the main portal's navy/orange Quicksand look) was built for each SSG and verified to render with none of the main site's chrome leaking in. Still open: a single collection choosing its layout per entry via a frontmatter field, rather than two separate folder collections each hardwired to one layout — this round used the latter, simpler shape. |
This table drove most of the earlier hesitation to recommend strongly — two of the three previously-open rows are now resolved. Images and unique layouts are verified working in all three SSGs (see below for how each did it, and the real differences between them). Genuine separate-backend CMS environments and video embedding remain open, and are smaller, more mechanical follow-ups than either of the two just closed.
Second layout, images, and environment rendering — what changed and what it revealed
Full detail in each spike's own evaluation doc; headline findings, since they materially affect two of the three previously-open rows above:
- A second layout was cheap in Astro and Eleventy (a new component/template, zero restructuring), and expensive in Next.js (a full
app/route-group restructure) — the only one of the three where "multiple independent root layouts" isn't the default shape and had to be deliberately built, per Next's own documented mechanism for it. This is a real, ongoing cost difference for a site that expects to grow more page templates, not just a one-off migration tax. - Astro's content collections resolve and optimize relative image paths in markdown completely on their own —
../images/foo.pngin a markdown file living entirely outsidesrc/was found, copied, converted to hashed.webp, and given explicitwidth/height/lazy-loading, with zero extra code. Next.js and Eleventy both needed an explicit, deterministic text-rewrite (../images/→ the correct absolute,BASE_PATH-aware path) plus a real file copy of the images into their ownpublic/imagesfolder — Eleventy's native markdown pipeline, despite being the headline reason it was recommended above, does not resolve relative asset paths any better than Next.js'smarked-based approach does, once the content lives outside the SSG's own input tree. This is a genuine correction to the earlier "Eleventy's native markdown is the strongest of the three" framing: true for content colocated inside the project, not true for content in a shared external folder like/markdown— worth weighing if HDC content will, in practice, live outside whichever SSG's own directory tree. - A build-time
SITE_ENV(dev/preprod/prod) +SITE_BRAND(rebadge) mechanism was added to all three, each via that SSG's own idiomatic "read an env var at build time" convention (Astro: plainprocess.envin frontmatter; Next.js:next.config.ts'senvfield, since client-shared code needs the value baked in explicitly; Eleventy: a plain global data file, same convention asentries.js). All three show an environment ribbon and rebadged wordmark/title whenSITE_ENV/SITE_BRANDare set, and default to unbadged "prod" behaviour when unset — confirming this class of "feature-flag-like" build-time variation is achievable in all three with no framework-specific blocker, differing only in how many extra hops each needs (Next.js needs one more than the other two, for the reason above).
Data model findings (recap)
Full detail in /schema/README.md and /schema/GAPS.md — headline points relevant to the SSG decision:
- A single JSON Schema (
/schema/hdc-entry.schema.json) and one converted dataset (/data/entries/*.yaml) served all three SSGs unchanged, plus the CMS config for all three — confirms the "design a schema once" approach was the right call, independent of which SSG is chosen. - HDC's own source data is a permanent subset of a full record (missing DOI/publication links, sparse
originatorType, etc.) — every SSG's UI needs to treat optional fields as normally absent, not as a gap to backfill. This doesn't vary by SSG and shouldn't be revisited per iteration. - Several data-quality issues in the source export (concatenated URLs with no delimiter, run-on sentences, placeholder link text) were found and, where possible, fixed at the data layer (
/data/entries/ducks-framework.yaml'slinksarray) rather than worked around per SSG — another point in favour of treating/data+/schemaas shared infrastructure rather than something each spike re-derives.
Deployment (documented, not executed)
No GCP credentials or a confirmed target bucket are available in this environment, so nothing was actually deployed — this section documents the intended release path per SPECIFICATION.md's "Release method" section, for whoever picks this up next.
All three SSGs build to a plain static folder (spikes/astro/dist/, spikes/nextjs/out/, spikes/eleventy/_site/) — no Node.js server required to serve any of them, satisfying this round's "everything MUST be HTML" requirement. The release shape is the same regardless of which SSG is chosen:
- Build —
npm run buildin the chosen spike's directory (each already runs the SSG build +pagefind --site <output>in one command). - Sync to a GCS bucket configured for static website hosting —
gcloud storage rsync --recursive <output-dir> gs://<bucket-name>(orgsutil -m rsync -r), with the bucket's website config (index.htmlas the main page,404.htmlas the 404 page — Next.js's static export already emits one). - Front with Cloud CDN — a global external HTTPS load balancer with a backend bucket and Cloud CDN enabled; invalidate the CDN cache (
gcloud compute url-maps invalidate-cdn-cache) as part of the same deploy step so updates aren't stuck behind a stale cache TTL. - Automate via GitHub Actions — a workflow triggering on push to
main(or on a schedule, perSPECIFICATION.md's "consider how we might support pre-scanning of entries" note), running steps 1–3, authenticated via a Workload Identity Federation service account rather than a long-lived key. - Content deposition: an Alliance member or editor uses Decap CMS's GitHub backend (replacing the
local_backend: trueused for local testing in all three spikes) to open a PR against/data/entriesorcontent-pages/; merging tomaintriggers the deploy above. No DevOps involvement per update, matching the spec's goal — but this specific flow (real GitHub backend, real OAuth client, real PR-based review) hasn't been tested in any spike, only thelocal_backendproxy mode. Flagged as the next concrete step, not assumed to work identically.
Presenting these findings without spinning up the repo
Two options exist now, for two different audiences:
- A polished, self-contained HTML version of this report, published as a Claude Artifact, for anyone who just wants the comparison table, recommendation, and open items in a browser, no setup at all: https://claude.ai/code/artifact/1046263e-26b6-4428-a78b-1382116d3087. Private until shared from the artifact page's share menu; can be updated in place if this report changes.
- The actual running sites, for anyone who wants to click through the real thing rather than read about it.
npm run buildfrom the repo root (seescripts/build-site.mjs) builds all three spikes and assembles them, alongside this report andSPECIFICATION.md, into one combinedsite/directory — a portal page at/, this report at/report/, the spec at/specification/, and each SSG's fully working build under its own subpath (/astro/,/nextjs/,/eleventy/), each with working search, filters, and its own evaluation page.npm run servebuilds and serves it locally on port 8000;npm run deploy -- gs://<bucket-name>(seescripts/deploy-gcs.sh) syncs it to a GCS bucket — not run in this environment, since no real bucket or credentials are available here, but ready to run once they exist.
Every subpath was verified independently: every page returns 200, every asset (logo, Pagefind bundle, CMS config) resolves under its own prefix rather than the wrong site's, and — the same bar as everywhere else in this spike — searching "Sangya" against each subpath's own Pagefind index correctly returns Cohort Discovery Service, three times, once per SSG, each pointed at its own subpath's URL.
Each subpath also has a second, visually distinct page template at /articles/ (e.g. /astro/articles/, /nextjs/articles/, /eleventy/articles/), demonstrating the "second layout + images" work above — click through to /articles/outline/ on any of the three to see the same source markdown and images rendered through a completely different look from the main portal.
Post-spike code review
After the spike concluded, an independent senior-developer/architect review pass was run over all three spikes' source (implementation by Claude Sonnet 5, claude-sonnet-5; review by Claude Fable 5, claude-fable-5). Each spike's evaluation doc now carries a "Post-spike code review" section with per-spike findings and next-stage questions.
One review finding has since been resolved across all three spikes rather than deferred: the homepages originally rendered every entry card and hid non-matches with client JS — the review asked whether that survives at 100s of entries, and the answer was to rebuild it. All three listings now render from the search index/corpus with pagination and a 5/10/20/50/100 page-size selector defaulting to 20 (see SPECIFICATION.md's "Search-index-driven listings with pagination" and each evaluation's section of the same name). That work also incidentally fixed several other findings: the Astro innerHTML XSS vector (client card rendering is now escaped everywhere), the fragile |-delimited data-* dataset encodings (gone in all three), the weaker dev-mode fallback search (now reaches never-displayed fields via the corpus's body), and the missing aria-live on result counts.
Four findings are cross-cutting — present in all three spikes, so they're product decisions rather than SSG-specific defects:
- Schema triplication: the JSON Schema, each SSG's own typing/field-list layer, and each Decap
config.ymlare hand-synced, with drift already present (Astro's Zod side lacks thecategoriesenum). The next stage needs a generate-from-schema strategy or a CI drift check. - CMS-authored content is trusted raw HTML end-to-end: no sanitization exists anywhere (marked in Next.js and the portal scripts doesn't sanitize; Astro and Eleventy's default markdown passes raw HTML through),
bannerLinkrenders unvalidated intohref, and Decap loads from unpkg with a floating version and no SRI hash. Acceptable only under a trusted-editors + PR-review policy — which must be an explicit decision, alongside pinning/self-hosting the Decap bundle. - Zero automated tests and no CI across all three spikes — the ajv schema validation
CLAUDE.mdflags as ad hoc, a "Sangya" search smoke test, and the deploy workflow are all unbuilt. The dedicated— resolved by removing those pages in all three spikes: user testing found the PagefindUI-based page non-functional, and once the homepages became full, paginated search surfaces it was redundant. Each site now has exactly one search surface — the listing itself — which is what the spec's "one search implementation per site" rule wanted all along. (Astro's four/search/pages carry 3 Pagefind facets, not the spec's required 8/search/<engine>/comparison demos remain, reachable from/search-engines/.)
Going-forward stack: AI assessment
This section is the AI's own assessment, clearly attributed as such for the team to weigh — not a team decision. It is written by Claude Fable 5 (claude-fable-5), the model that performed the post-spike senior-developer/architect review and implemented the follow-up rounds (second layouts and images, environment flags, index-driven paginated listings, full-record rendering, and the fixes that fell out of user testing). The original "Recommendation" section above was written at spike close by Claude Sonnet 5 (claude-sonnet-5), which built the three spikes. The two disagree, and that disagreement is evidence-driven, not stylistic — the follow-up rounds surfaced facts the original recommendation didn't have.
The pick
Astro + Pagefind + Decap CMS, deployed as plain static files to a GCS bucket behind Cloud CDN via a GitHub Actions workflow (Workload Identity Federation, no long-lived keys), exactly as the deployment section above already documents.
Why this revises the Eleventy recommendation
The original recommendation was decided on secondary criteria after a functional tie, and both of its tie-breakers weakened under later evidence:
- "Eleventy's native markdown is the best CMS story" didn't survive the alternative-layouts round. Its native pipeline only applies to files inside its own input tree; for the shared
/markdownfolder it needed the same hand-written fs + markdown-it + regex-rewrite bridge as Next.js — the image bridge the review flagged as this repo's biggest silent-failure risk (a CMS editor's next uploaded image 404s until a developer intervenes). Astro's content collections were the only implementation that handled externally-located, image-carrying markdown natively: relative image paths resolved, optimized to hashed.webpwith dimensions and lazy-loading, zero custom code. CMS editors dropping markdown with images is the centre of gravity of replacing WordPress — the SSG that handles that case best natively deserves the win, and it wasn't Eleventy. - "Least moving parts" inverted as the client-side surface grew. The index-driven paginated listing means every spike now ships real client logic (fetch, filter, paginate, render, escape). Astro's is TypeScript, build-checked, shared through one typed module reused by five pages. Eleventy's is a plain untyped script whose failures — as its own evaluation concedes — only surface in-browser; keeping it honest in production means deliberately re-adding the TypeScript/bundler layer, at which point the simplicity argument has consumed itself.
- Astro is the only one of the three that validates the data at build time. Its Zod layer rejects malformed entries during
astro build; Eleventy parses YAML unvalidated and fails opaquely; Next.js validates nothing. The observed schema drift (the missingcategoriesenum) is an argument for generating the Zod schema from/schema/hdc-entry.schema.json, not for discarding the only build-time gate any spike has. - Next.js remains third for this shape of site, and its one real advantage is hollow here. Three version-specific gotchas were hit in practice (async
params,force-static, bundler ignore comments), a second layout required restructuring the whole route tree, and HDR UK stack alignment buys little under static export — server components/actions, Tanstack, and Zustand, the pillars of that alignment, are all unusable or unnecessary in this architecture.
Astro also tied or matched Eleventy on everything the original recommendation valued: per-page layout opt-in made the second layout equally trivial, the env-flag mechanism is a plain process.env read (simpler than Next's), and the Decap config is identical across all three.
What would change this pick
- If the team weighs absolute minimum tooling above build-time safety and is disciplined about adding tests deliberately, Eleventy remains a legitimate choice — nothing in this spike shows it failing, only needing more bolted on as the product grows.
- If the organisation mandates single-framework consolidation on Next.js and accepts the upgrade-churn tax, Next.js works — every requirement passed — but expect ongoing ceremony for a site that uses almost none of the framework.
- Astro's real cost is that it's the stack with no prior team experience (the spec notes Eleventy experience exists in-team). If bus-factor on the chosen SSG matters more than the capability differences above, that's the argument to have — it's an organisational judgement this assessment can see but not make.
Rest of the stack, and conditions on the pick
- Pagefind stands regardless of SSG — the 5-engine comparison holds (only engine indexing built HTML; integration friction was one bundler comment), and the paginated listings all drive it identically.
- Decap CMS for now, with a time-boxed Sveltia trial before production commitment — it reads the same config format, so the switching cost is an afternoon, and it was in the spec's scope but never trialled.
- Non-negotiable first-sprint items whichever SSG is chosen (from the review, all still open): generate the Zod/TS/Decap-config layers from the JSON Schema (or CI drift-check them), sanitize the markdown render path and validate CMS-authored URLs, pin and self-host the Decap bundle, add an ajv build gate plus a Playwright smoke test (Sangya search, pagination, filters), and stand up the GitHub Actions build/deploy with a real Decap GitHub backend.
Open items for whoever picks this up next
- Test genuinely separate Decap backend configs (repo/branch) per Gateway environment, and video embedding — the two narrower items still open after this round's images/layouts/env-flag work (see above).
- Test a real GitHub-backed Decap deployment (OAuth client, real PR flow) rather than only the local proxy mode used throughout this spike.
- Decide on the Museo Sans Rounded font licensing question (
spikes/astro/src/styles/hdc-theme.css's header comment) — Quicksand was used as a stand-in throughout, in all three spikes. - Get more
HDC Details.csv-equivalent exports forhealth-data-research-gateway, the one entry still only at minimal (4-field) detail. - Decide whether the malformed source fields flagged in
/schema/GAPS.md(undelimited category cell, duplicated "Related or alternative capability" text) should be corrected at the HDC data-entry source, not just documented downstream.