← HDC technology spike

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.

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:

  1. 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.
  2. 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.md actually 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:

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:

Data model findings (recap)

Full detail in /schema/README.md and /schema/GAPS.md — headline points relevant to the SSG decision:

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:

  1. Buildnpm run build in the chosen spike's directory (each already runs the SSG build + pagefind --site <output> in one command).
  2. Sync to a GCS bucket configured for static website hostinggcloud storage rsync --recursive <output-dir> gs://<bucket-name> (or gsutil -m rsync -r), with the bucket's website config (index.html as the main page, 404.html as the 404 page — Next.js's static export already emits one).
  3. 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.
  4. Automate via GitHub Actions — a workflow triggering on push to main (or on a schedule, per SPECIFICATION.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.
  5. Content deposition: an Alliance member or editor uses Decap CMS's GitHub backend (replacing the local_backend: true used for local testing in all three spikes) to open a PR against /data/entries or content-pages/; merging to main triggers 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 the local_backend proxy 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:

  1. 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.
  2. The actual running sites, for anyone who wants to click through the real thing rather than read about it. npm run build from the repo root (see scripts/build-site.mjs) builds all three spikes and assembles them, alongside this report and SPECIFICATION.md, into one combined site/ 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 serve builds and serves it locally on port 8000; npm run deploy -- gs://<bucket-name> (see scripts/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:

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:

  1. "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 /markdown folder 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 .webp with 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.
  2. "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.
  3. 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 missing categories enum) 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.
  4. 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

Rest of the stack, and conditions on the pick

Open items for whoever picks this up next