Spike evaluation: Next.js (static export) + Pagefind + Decap CMS
Second SSG iteration through SPECIFICATION.md's open questions, built to the same bar as the Astro spike (spikes/astro/content-pages/evaluation.md) and reading the same shared /data + /schema, so the two are directly comparable rather than independently scoped. Pagefind was reused as-is rather than re-running the 5-engine search comparison a second time — that comparison doesn't change per SSG, see spikes/astro/content-pages/search-engines.md.
What this spike proves works
- Static export genuinely produces plain HTML, everything included.
output: 'export'innext.config.ts+trailingSlash: trueproducesout/entries/<id>/index.htmletc, each containing the entry's full rendered content in the raw document (verified by grepping the built file directly, not by trusting the config) — no client JS is required to see any page's content. React hydration payload scripts are also present (for fast client-side navigation once JS loads), but they're additive, not a requirement for the visible content to exist. - The same data model, read a different way. Next.js has no equivalent of Astro's content collections —
src/lib/entries.tsreads/data/entries/*.yamldirectly withfs/yamlat build time (process.cwd()-relative, notimport.meta.url-relative — see friction below), with a hand-written TypeScript interface mirroring/schema/hdc-entry.schema.jsoninstead of Astro's Zod schema. Same hand-sync tradeoff as Astro, different mechanism. - Filters, Pagefind search, and the "Sangya" test all carry over unchanged. Same 8 facets, same Go/Reset behaviour, same
FilterForm/DOM-driven filter-matching split as the Astro spike (reimplemented, since there's no code-sharing between two independent spike directories — seesrc/lib/domFilters.ts). Confirmed by directly querying the built Pagefind index in Node: searching"Sangya"returnscohort-discovery-service, viacontactTeamPerson— which at the time of this test appeared on no visible page text, identical to the Astro spike. (Since changed: SPECIFICATION.md's later "Render the whole record" requirement means every field,contactTeamPersonincluded, is now visible on the entry page; the Sangya test still passes as a full-record-coverage check.) - Client components don't need to embed the entry dataset. The interactive filter/search logic (
FilterInteractivity.tsx) is a Client Component, but it reads the DOM (data-*attributes already present in the server-rendered HTML) rather than receiving entries as a prop — avoiding shipping the full dataset a second time into the client JS bundle. This mirrors the Astro spike's architecture deliberately; a naive Next.js implementation (passentriesas a prop to a'use client'list component) would have embedded the entire corpus into the page's JS payload, which static export doesn't stop you from doing by default. - CMS wiring is nearly copy-paste from the Astro spike's config, confirming SPECIFICATION.md's "Progress so far" expectation.
public/admin/config.ymlneeded only path changes (spikes/nextjs/...instead ofspikes/astro/...) — sameentriesfolder collection, same genericpagesfolder collection overcontent-pages/, samesettingssingleton for the banner. Verified viadecap-server's proxy API directly:entriesByFolderreturned all 13 entries and both markdown pages;getEntryreturned the banner settings file. - "Drop a markdown file, get a page" holds here too. Dropped a throwaway
test-drop.mdintocontent-pages/with zero code changes, rannpm run build, confirmed a real/test-drop/page with the dropped heading andmarkdown-bodystyling, then removed it — same result as the Astro spike, via a different mechanism (marked, since Next.js has no native markdown rendering — see friction below). - The shared banner singleton (
/data/site-settings.yaml) renders identically here, confirming the CMS's "turn on/off and edit text + link for a banner" requirement (SPECIFICATION.md, "CMS gui like options") is satisfiable the same way regardless of SSG — the file and its CMS config are the shared part; each SSG only needs its own few lines to read and conditionally render it.
Friction encountered (and fixed)
import.meta.url-relative file reads break under Next.js's bundler, same mistake as Astro's, different cause. First instinct was to read/data/entriesrelative to the calling module's location. Usedprocess.cwd()-relative paths instead (path.join(process.cwd(), '..', '..', 'data', 'entries')) —process.cwd()is documented and stable as the project root during bothnext devandnext build, unlike a bundled module's own path. Worth calling out because this is the second SSG in a row where the naive "resolve relative to this file" approach breaks, for a different underlying reason each time (Astro: Vite relocates the compiled chunk; Next.js: no relocation issue, but there's noimport.meta.url-based convention that's safe to rely on across dev/build/export). Lesson: for any future SSG, assume file-system reads at build time need the SSG's own documented "project root" convention, not a path computed from the current module.next.config.ts's AGENTS.md warning was justified. This Next.js version (16.2.10) ships anAGENTS.md/CLAUDE.mdat the project root warning that APIs may have changed since training data and to checknode_modules/next/dist/docs/first. That check caught a real breaking change before it caused a bug:paramsin a dynamic route's page component is nowPromise<{ id: string }>, not a plain object — confirmed by reading the vendored docs rather than assuming the older synchronous-paramsshape.- The admin page can't fully opt out of the site's shared layout. Astro's
Base.astrois just a component a page chooses to wrap itself in — the admin page simply didn't use it, giving Decap CMS a bare page with no site header. Next.js's App Router has exactly one root layout applied to every route by default; genuinely excluding it needs a route-group restructure (multiple independent root layouts, each with their own<html>/<body>) that wasn't done for this spike, so/admin/inherits the site header, nav, and banner above the CMS UI. Functionally harmless — Decap mounts into its own#nc-rootdiv regardless — but a real deployment would probably want the route-group version for a cleaner admin experience. Difference worth weighing: Astro's per-page layout opt-in is more convenient here than Next's default-everywhere root layout. - No native markdown rendering. Astro's content collections render markdown via its own pipeline (
render()+<Content />) with zero extra dependencies. Next.js has nothing built in —marked(already used once, then removed, in the Astro spike's own false-start — see its EVALUATION.md) is used here instead, viadangerouslySetInnerHTML. Not a workaround so much as the actual, expected way to do it in Next.js; still a genuine capability gap worth naming directly when comparing the two frameworks for a CMS-driven content site. - Turbopack's dynamic
import('/pagefind/pagefind.js')needed the same "don't statically resolve this" treatment as Vite. Vite's magic comment is/* @vite-ignore */; the webpack/Turbopack equivalent used here is/* webpackIgnore: true */. Without it, the bundler would try to resolve a path that doesn't exist until the postbuild Pagefind step runs (same failure mode hit in the Astro spike, different bundler, same fix shape).
Second layout, images, and environment rendering (SPECIFICATION.md, "Demonstrate alternative layouts and images" / "Individual environments/rendering")
- A second, fully independent layout required a route-group restructure — this is where the earlier-noted "one root layout" limitation had to actually be resolved, not just flagged. Next.js's App Router applies exactly one root
layout.tsxto every route unless every top-level route belongs to a route group (a(name)folder that doesn't affect the URL). Confirmed via Next's own vendored docs (node_modules/next/dist/docs/.../route-groups.md): "If you use multiple root layouts without a top-level layout.js file, make sure your home route (/) is defined within one of the route groups." So the wholesrc/app/tree was restructured into(site)/(everything that existed before, unchanged in behaviour) and a new(articles)/group with its own independent<html>/<body>, own stylesheet (article-theme.css, not an import of(site)/hdc-theme.css), own header/footer. Verified no site chrome (c-header/c-banner) leaks into/articles/outline/. This is Next's own sanctioned mechanism for the requirement, not a workaround — but it's a materially bigger lift than Astro's per-page layout opt-in (a folder restructure of the whole project vs one new component). - No native markdown, confirmed a second time, now for content living outside the project entirely.
/markdown/outline.mdis read via plainfs(src/lib/articles.ts,process.cwd()-relative, same convention asentries.ts) and rendered withmarked— same library, samedangerouslySetInnerHTMLapproach ascontent-pages/. - Relative image paths in markdown need an explicit, deterministic rewrite — Next.js has nothing like Astro's automatic resolution.
getArticleMarkdown()does a plain string replace (../images/→${basePath}/images/articles/) before handing the markdown tomarked; the two PNGs are copied once intopublic/images/articles/(a real file copy, not a symlink or a build step). Verified correct under both the default build (/images/articles/amr_portal.png) and aBASE_PATH=/nextjsbuild (/nextjs/images/articles/amr_portal.png) — confirms the rewrite is BASE_PATH-aware, unlike a hardcoded absolute path would be. - Environment/rebadging flags need one extra hop compared to Astro:
next.config.ts'senvfield. Plainprocess.env.SITE_ENVreads work fine in Node-side code (src/lib/articles.ts), but client/shared code needs the value baked in at build time vianext.config.ts'senv: { NEXT_PUBLIC_SITE_ENV: process.env.SITE_ENV || 'prod', ... }, then read back asprocess.env.NEXT_PUBLIC_SITE_ENVinsrc/lib/deploymentConfig.ts. Verified:SITE_ENV=preprod SITE_BRAND="Gateway Preview" npm run buildshows the ribbon and rebadged wordmark/title in both(site)and(articles)layouts simultaneously — confirming the flag is genuinely global, not layout-specific, despite the two layouts sharing no other code. - Decap's
articlesfolder collection carried over with the same one-line-different shape as every other collection so far (folder: "markdown", notspikes/nextjs/..., since/markdownis shared like/data) — confirmed viadecap-server's proxy API.
Search-index-driven listing and pagination (SPECIFICATION.md, "Search-index-driven listings with pagination")
- The homepage no longer server-renders any entry cards, and the replacement is more idiomatic React than what it replaced. The first pass rendered all 13 cards into the HTML with the dataset re-encoded into
data-*attributes, then a Client Component mutatedcard.hidden— filtering React couldn't see, over a hand-synced parallel encoding. Nowsrc/components/EntryResults.tsxfetches/search-data.json, holds the matched set/page/page-size in React state, and renders the current page of cards as ordinary JSX — so React escapes CMS-authored fields itself (nodangerouslySetInnerHTML, no|-delimited attribute encoding, nodocFromCard). The oldFilterInteractivitycomponent anddocFromCardare deleted. - The corpus endpoint is a static route handler (
src/app/(site)/search-data.json/route.ts) — Next's documented mechanism for emitting non-HTML files underoutput: 'export'. One version-specific catch, found the usual way (build error, then vendored docs): this Next version requiresexport const dynamic = 'force-static'on the handler even though the static-export guide's example omits it. - Pagination + page size: previous/next with a page indicator, per-page selector 5/10/20/50/100 defaulting to 20, page resets to 1 on any query/filter/size change, Reset restores the default size. Pagination is pure derivation from state — page changes don't re-query Pagefind.
- The filter form stays a server-rendered component (shared markup with the other two spikes), so its inputs are still read via DOM listeners rather than converted to controlled components — a deliberate hybrid to keep the three spikes' filter forms directly comparable, noted here so it isn't mistaken for an oversight.
- The dev-mode fallback now substring-searches the corpus's full
bodyfield (which includescontactTeamPerson), so even pre-build dev search passes the "Sangya" test — it lacks Pagefind's ranking, and the mode label says so. - Trade-off accepted knowingly: the listing is client-rendered, so it's empty without JavaScript (a
<noscript>note says so). Entry pages remain fully static; Pagefind indexes those, so content search/SEO is unaffected. - The dedicated
/search/page (PagefindUI's default UI) was removed along with its nav link — user testing found it non-functional, and once the homepage became the full, paginated search surface it was redundant; the homepage is now this site's single search surface, which is what the spec's "one search implementation per site" rule wanted all along. Same removal made in all three spikes. - Verified: built
out/index.htmlcontains zero pre-rendered cards;out/search-data.jsoncarries all 13 entries withbodyincluding never-displayed fields; aBASE_PATH=/nextjsbuild produces correctly-prefixed corpus URLs.
CMS future-requirements read-through (SPECIFICATION.md, "CMS gui like options")
No different from the Astro spike's read on these, since Decap CMS's actual capabilities don't change per SSG — only the config file changes, and barely:
- Non-developer content/wording edits: works — folder collections for entries and pages, both editable through the Decap UI.
- Banner on/off + text/link: works — the shared singleton file, same as Astro.
- Separate instances per Gateway environment: partially addressed this round — see "Second layout, images, and environment rendering" above for the build-time
SITE_ENV/SITE_BRANDmechanism. Still not tested: genuinely separate Decapbackend/branch config per environment (this round only covers front-end rendering flags, not CMS backend routing). - Images/embedded video: images now tested (see above) — a real markdown-authored image reference, rewritten and rendered correctly. Video embedding was not part of this round's scope; still open.
- Unique page layouts per page: now tested —
/articles/*proves a second, visually distinct layout is achievable, via route groups. Still not implemented: alayoutfrontmatter field letting a single folder collection choose between layouts per entry (this round used two separate folder collections/layouts rather than one collection with a layout switch).
What was actually run (for reproducibility)
npx create-next-app@latest nextjs --typescript --eslint --app --src-dir --import-alias "@/*" --turbopack --no-tailwind --yes
npm install yaml marked
npm install -D pagefind decap-server serve
npm run build # next build && pagefind --site out
npm run preview # serve out — verified via curl
npm run cms # decap-server from repo root; queried its API directly for entries/pages/banner
Also run directly against the built output, not inferred from code:
- Grepped
out/entries/<id>/index.htmldirectly to confirm full entry content is present in the raw static HTML, not just in a hydration payload. - Queried the built Pagefind index directly in Node (
pagefind.search('Sangya')against the runningserveserver) — returnedcohort-discovery-service. - Dropped a throwaway markdown file into
content-pages/, rebuilt, confirmed a matching styled page, removed it.
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
- Triple hand-maintained schema, with no build gate.
/schema/hdc-entry.schema.json, theEntryinterface insrc/lib/entries.ts, and the enum options duplicated throughpublic/admin/config.ymlmust all agree, and nothing enforces it — no ajv validation runs duringnext build, so a bad YAML edit saved through Decap ships silently. Generate the TS types (json-schema-to-typescript) and Decap fields from the schema, and validate entries as part of the build. - The DOM-reading filter architecture was a deliberate tradeoff that needed re-examining, not inheriting. Avoiding double-shipping the dataset into the client bundle was real and correct for static export — but the result was filtering React couldn't see (
card.hiddenmutation), a hand-synced parallel structure between thedata-*write side and thedocFromCard()read side including an unescaped|delimiter, and a guard that silently disabled all filtering if one selector drifted. (Since replaced — see "Search-index-driven listing and pagination" above: the listing is now React state rendering JSX from a fetched corpus, which resolves this finding wholesale.) - Dead create-next-app scaffold survives:
public/{file,vercel,next,globe,window}.svgare unused andREADME.mdis still the stock template. Delete before anyone copies this directory as a starter. src/lib/filterOptions.ts:23—Math.min(...[])yieldsInfinitywhen no entry carriesnumberAdopting; latent NaN placeholders in the range inputs.- Accessibility: the result count in
FilterForm.tsxhas noaria-live, so screen-reader users get no feedback when filtering.
Architectural concerns
- The regex image rewrite (
src/lib/articles.ts) replaces../images/anywhere it appears in the source text — including inside code blocks or prose — and depends on a one-off manual copy of PNGs intopublic/images/articles/. Fine for two images; a product needs a build-time copy step and an AST-level rewrite (a marked renderer hook or remark plugin). - The dev-mode search fallback degraded to name/description-only substring matching when the Pagefind index was absent — the exact "two search implementations" failure
SPECIFICATION.mdbans. (Since improved — the fallback now substring-searches the corpus's fullbodyfield, so it reaches never-displayed attributes too; the remaining divergence is ranking/stemming, stated in the mode label.) - Org-standards divergence is deliberate and should stay recorded as such: App Router and server-components-first — aligned with the HDR UK handbook. Tanstack/Zustand — absent, and rightly so: a static export has no client data-fetching or shared state to manage, and server actions are impossible under
output: 'export'. This divergence shouldn't be "fixed" later by someone applying the handbook mechanically.
Security considerations
- Unsanitized
dangerouslySetInnerHTMLon both CMS-editable routes:src/app/(site)/[page]/page.tsxandsrc/app/(articles)/articles/[article]/page.tsxrendermarked.parse()output raw — marked does not sanitize, and no sanitizer exists anywhere in the dependency tree. The inline comment calls the markdown "trusted, repo-local", but the entire point of the Decappages/articlescollections is non-technical editors authoring it: a<script>tag saved through the CMS is stored XSS. Addrehype-sanitize/DOMPurify (or sanitize at build) before production — highest-priority fix. - Decap CMS loads from unpkg with a floating version range (
decap-cms@^3.0.0, no SRI hash) insrc/app/(site)/admin/page.tsx— an unpinned third-party script that will run with editors' GitHub tokens. Pin + hash, or self-host./admin/also ships in the public static output withlocal_backend: truecommitted — production needs an environment-split config. bannerLinkrenders intohrefunvalidated — a CMS editor can injectjavascript:URLs.
Questions for the next stage
- What generates the TS types and Decap config from the JSON Schema, and where does entry validation run — build gate, CI, or CMS-side?
- Decap's
githubbackend needs an OAuth proxy that a GCS bucket + CDN cannot host — what serves that in production? - Zero automated tests exist — what's the minimum harness (build + ajv gate + a Playwright "Sangya" smoke test) before iteration two?
- Is the
(site)/(articles)route-group split — two full root layouts to maintain — actually needed in a product, or was it spec-driven demonstration only? - Given this scaffold's own
AGENTS.mdbreaking-changes warning and the async-paramschange already encountered, who owns Next.js major-version upgrades — and does that maintenance cost tip the balance further toward the recommended Eleventy?