Skip to content

Roadmap — SSG (Build-Time-Only CMS)

Status: Proposal / NOT PLANNED AS OF NOW. This page is the roadmap for transitioning the LMS so the CMS is required only at build time, while authentication and student progress tracking continue to work at runtime.

It records the agreed architecture, the decisions behind it, and the phased work items. Treat it as the discovery surface for this initiative; subsystem detail lands in the relevant reference/ pages as the work ships.

Goal

At runtime the deployed LMS must never call the CMS. All CMS-derived content and assets are produced at build time. Auth.js, server actions, and /api/* keep running against the database so authentication and progress tracking are unaffected.

Two consequences of the chosen design:

  • Personalized routes stay dynamic SSR per request, but they read CMS content from a local build-time snapshot (not the live CMS).
  • Public, non-personalized routes become true static HTML, which requires moving the small amount of session-dependent chrome out of the server-rendered root layout.

Decisions log

Decision Choice
Runtime topology Hybrid: keep the Next.js Node server deployed for Auth.js + server actions + /api/*; make page rendering avoid the CMS at runtime. (Not a pure static export, which cannot run auth/DB.)
CMS assets Download at build into a local store; gated course assets keep their access-checked proxy but read from the local store.
Personalized routes Server-rendered per request, sourcing CMS content from the snapshot.
Snapshot storage Single read-only SQLite artifact for all CMS content.
Catalog scale Large / growing — indexed SQLite chosen for scale.
Snapshot location apps/lms/data/cms-snapshot.sqlite (git-ignored).
Deployment target Vercel serverless (bundle the .sqlite + native binary; watch function size).
Documentation scope Rewrite all docs (normative + historical) to the new architecture.

Why a snapshot is required (not optional)

Server actions read CMS content at runtime, independent of how pages render:

  • submitQuizAttemptAction re-fetches the entry server-side to grade quizzes (the client cannot be trusted for grading).
  • enrollInCourse checks published status before writing the enrollment.

These run at runtime with no CMS reachable, so a local runtime-readable CMS source is mandatory regardless of page rendering strategy. The snapshot is that source.

Architecture

flowchart TB
  subgraph build [Build time only]
    CMS[(CMS on :3099)]
    Snap[snapshot generator]
    CMS --> Snap
    Snap --> SnapDb[("CMS snapshot (read-only SQLite)")]
    Snap --> Assets[local asset store]
  end
  subgraph runtime [Runtime - no CMS]
    Pages[Static + dynamic pages] --> SnapDb
    Actions[Server actions / api] --> SnapDb
    Actions --> DB[(Postgres / SQLite app DB)]
    Pages --> DB
    Proxy[gated asset proxy] --> Assets
  end

Snapshot storage (read-only SQLite)

A single read-only SQLite snapshot holds all CMS content, sized for a large/growing catalog. This reuses the already-bundled better-sqlite3 (the LMS DB driver) and Kysely query patterns, gives indexed lookups by slug/id/locale, scales to thousands of courses / tens of thousands of entries, and reads with low memory.

  • Artifact: cms-snapshot.sqlite written to apps/lms/data/cms-snapshot.sqlite (generated, git-ignored), bundled with the LMS deployment and regenerated every build.
  • Tables (locale-scoped, indexed): site_meta (schema version, generated-at, content checksums), pages (by id + slug), page_hierarchy, courses (by slug), entries (by course slug + entry slug), assets (metadata + local path), site_theme.
  • Opened read-only at build + runtime via a dedicated Kysely instance (separate from the app DB), with a module-level singleton + small in-memory LRU per the repo's "Hoist Static I/O to Module Level" rule.
  • Stored rows are the post-validation domain objects the CMS client already produces (validated with @open-learning-hub/widget-wire-schemas + LMS domain schemas at generation time); the loader returns the same domain types so callers and server actions are unchanged.

Production suitability (read-only SQLite on Vercel serverless)

  • The snapshot is read-only and immutable at runtime (regenerated only at build), which is SQLite's ideal profile: in-process reads, unlimited concurrent readers (no write locks), and horizontal scale for free since each serverless instance reads its own bundled copy. This keeps content reads off the Postgres user-data DB (which stays dedicated to auth/progress) and pins content to the deploy.
  • Vercel-specific work:
    • Add outputFileTracingIncludes in apps/lms/next.config.ts for both the better-sqlite3 native binary and apps/lms/data/cms-snapshot.sqlite so they ship in the function bundle.
    • better-sqlite3 is already an LMS dependency, but production currently uses pg at runtime; confirm the native binary is built for the Vercel runtime/arch and loads in the serverless function.
    • Watch the unzipped function size (~250MB limit). Asset binaries live as separate files (not in the DB), keeping the DB to content JSON + metadata; monitor snapshot size as the catalog grows.
  • Fallback if the snapshot ever exceeds function size limits: store cms-snapshot.sqlite in Vercel Blob / object storage and fetch-to-/tmp on cold start (still no CMS dependency; adds a one-time cold-start download). Not needed initially; noted for scale.

Current blockers (from review)

  • Every CMS read sets ISR + tags, so runtime re-fetches the CMS:
// apps/lms/src/lib/cms/client.ts
response = await fetch(url, {
  method: "GET",
  headers: {
    "x-api-key": config.CMS_API_UUID,
    accept: "application/json",
  },
  next: { revalidate: 60, tags: options.tags },
});
  • Root layout reads session + DB on every route (forces the whole tree dynamic):
// apps/lms/src/app/\[locale\]/layout.tsx
const session = await getSession();
const siteProjectThemeCss = await getSiteProjectTheme({ locale });
// ... users email_verified_at lookup
  • generateStaticParams and asset proxies fetch the live CMS (e.g. learn/course/\[slug\]/page.tsx; /api/favicon, /api/cms/.../assets/* use cache: "no-store").

Work items

1. Build-time CMS snapshot generator

  • New generator (e.g. apps/lms/scripts/generate-cms-snapshot.ts invoked via scripts/snapshot-cms.mjs) that, with the CMS reachable, pulls everything the LMS needs, validates it, and writes the read-only SQLite artifact:
    • page hierarchy per locale, site project theme, header/footer pages
    • all published courses (list + per-course hierarchy), all entries per course
    • generic CMS pages referenced by the hierarchy (by id)
  • Create the schema/migration for the snapshot DB and a typed loader module (apps/lms/src/lib/cms/snapshot/) wrapping the read-only Kysely instance and lookup helpers (by slug/id/locale).

2. Make apps/lms/src/lib/cms/client.ts snapshot-backed

  • Add a data source switch (env CMS_SOURCE=http|snapshot, default snapshot). http is used only by the snapshot generator; snapshot is used by LMS build + runtime.
  • Reimplement getPageHierarchy, getPublishedCourses, getCourseBySlug, getEntry, getPageById, getSiteProjectTheme, and the catalog/featured helpers to read via the SQLite snapshot loader. Keep existing transforms (quiz adapter, published filtering) so callers and server actions (enrollInCourse, submitQuizAttemptAction) are unchanged.
  • Drop next: { revalidate, tags }; snapshot reads are local SQLite queries.

3. Assets: download at build, repoint URLs, preserve gating

  • During snapshot generation, download referenced CMS assets into a local store and record their metadata + local path in the snapshot's assets table:
    • public/site assets → emit to a static dir; update apps/lms/src/lib/cms/rewrite-asset-urls.ts to point at that static path.
    • gated course assets → store in a build artifact dir; keep /api/cms/courses/[courseSlug]/assets/* route + its enrollment check, but read bytes from the local store (resolved via the snapshot assets table) instead of fetching the CMS.
  • /api/favicon and project-asset-proxy.ts / resolve-published-course-project.ts: replace cache: "no-store" CMS fetches with local-store reads (or static fallback).

4. Env: CMS vars become build-only

  • In apps/lms/src/lib/env.ts, make CMS_API_URL / CMS_PROJECT_UUID / CMS_API_UUID optional for the running app; require them only inside the snapshot generator. Runtime env() must succeed without any CMS vars. Add CMS_SOURCE (http|snapshot, default snapshot).
  • Update the env hint in env.ts that currently points at build:integrated for missing CMS vars.
  • Decide CMS_WEBHOOK_SECRET fate (no longer used for tag revalidation — see item 7).
  • Propagate the contract change to: apps/lms/.env.local.template, apps/lms/.env.test, apps/lms/tests/e2e/fixtures/e2e-env.ts (REQUIRED_KEYS), and turbo.json build.env (CMS vars scoped to the snapshot task; add CMS_SOURCE).

5. Static vs dynamic route config (multi-tenant + widgets)

Do not blanket-apply force-static. Several "public" CMS pages (home, \[slug\], blog, learn) embed LMS widgets via resolveLmsWidgetData, which reads getTenantContext() (headers) + tenant DB + searchParams — these vary per tenant/host and per query and must stay dynamic.

  • Remove ISR revalidate from CMS content pages and let Next's automatic static/dynamic detection decide: pages that don't touch headers()/searchParams render statically from the snapshot; pages that do (widget/tenant/search) render dynamically from the snapshot. Reserve explicit dynamic = "force-static" only for pages provably free of dynamic-API usage.
  • Add dynamicParams = false (and fully enumerate generateStaticParams from the snapshot) on the slug routes: \[slug\], blog/\[slug\], learn/course/\[slug\], learn/course/\[slug\]/entry/\[entrySlug\].
  • Personalized routes stay dynamic and read CMS from the snapshot: learn/course/\[slug\], learn/course/\[slug\]/entry/\[entrySlug\] (+ layout), enroll. No change needed for dashboard/account/admin (DB-only, never touched CMS).
  • Entry player stays dynamic SSR + snapshot for security (lesson body + quiz are enrollment-gated and must not be static public HTML). Course landing default: dynamic SSR + snapshot; a static shell + client CTA is a later, optional enhancement.
  • The single CMS project means content/theme are identical across tenants, so static content pages are safely shared across hosts; only widget/tenant data differentiates by host (and stays dynamic).

6. Make the root layout static-compatible

  • Move the session-dependent chrome out of the server-rendered \[locale\]/layout.tsx:
    • UserButton, MainNav admin-link gating, and the unverified-email banner become client components that fetch session/flags from a small runtime endpoint (e.g. /api/session-chrome) or useSession.
    • Theme CSS (getSiteProjectTheme) comes from the snapshot, so it stays in the static layout.

7. Remove CMS ISR / repurpose the revalidate webhook (scope carefully)

Only CMS-content ISR/tags are removed. DB-driven cache invalidation must be preserved:

  • Remove revalidate = 60 and the CMS tags (cms:page-hierarchy, cms:courses, course:* content, entry:*, page:*, cms:site-project-theme) from client.ts and CMS content pages.
  • Keep DB-driven caching unrelated to CMS content: lib/tenant/resolve.ts unstable_cache (revalidate: 60, tenant-host:*), admin dashboards' revalidate: 300 + course/progress tags, and the in-app revalidateTag/revalidatePath calls in enroll, entry-progress, and feature-flag actions (course:*, tenant:*, progress:*). These are LMS DB slices, not CMS reads.
  • POST /api/cms/revalidate: CMS content is now build-pinned, so tag revalidation is meaningless. Decide between (a) retire the route, or (b) repurpose it as a redeploy/deploy-hook trigger. Either way, update apps/lms/src/schemas/openapi-registry.ts + regenerate public/openapi.yaml (item 9) and the rate-limit row in 120-security.mdc.
  • Resolve packages/platform-config ISR_REVALIDATE_SECONDS: keep if still used by tenant/admin ISR; otherwise remove to avoid a dead export. Content publishing is now rebuild-to-publish (CMS deploy hook → LMS rebuild).

8. Wire into the build pipeline + Vercel bundling

  • Insert the snapshot step into build:integrated: after the CMS health check, run the snapshot generator (CMS_SOURCE=http), then build the LMS with CMS_SOURCE=snapshot. The integrated CMS server (scripts/run-cms-build-server.mjs, port 3099) is now needed only for the snapshot step (see Root Scripts Reference for root script context).
  • apps/lms/next.config.ts: add outputFileTracingIncludes for apps/lms/data/cms-snapshot.sqlite + the local asset store. (serverExternalPackages: ["better-sqlite3"] already exists, which covers the native module.)
  • .gitignore already ignores data/, so the artifact is covered; confirm it is not accidentally required to be committed.
  • Update turbo.json (snapshot artifact/asset store as outputs; CMS env scoped to the snapshot task) and .github/workflows/ci.yml.

9. Regenerate OpenAPI + keep contract tests green

  • The asset-proxy routes and /api/cms/revalidate are registered in apps/lms/src/schemas/openapi-registry.ts and emitted to apps/lms/public/openapi.yaml. Any route change/removal must regenerate the spec (npm run openapi:generate) so openapi-sync.test.ts, openapi-coverage.test.ts, and openapi-generate.test.ts stay green.

10. Tests & validation

  • Update the tests that assert the about-to-change contract: src/lib/cms/client.test.ts (drops next.revalidate === 60 + CMS-tag assertions; adds snapshot-loader behavior), src/app/api/cms/revalidate/route.test.ts (matches the route's new fate), src/app/api/favicon/route.test.ts + asset-proxy route/lib tests (local-store reads instead of CMS no-store fetch), and the OpenAPI tests above.
  • Add new Vitest coverage: snapshot generator, read-only SQLite loader + CMS_SOURCE switch, asset URL rewriting to static paths, gated course-asset proxy reading from the local store, and an env.ts test confirming runtime boot succeeds without CMS_*.
  • Update apps/lms/tests/e2e/fixtures/e2e-env.ts required keys.
  • Run npm run check (full repo) and npm run build:integrated to confirm prerender coverage with zero runtime CMS calls.

11. Documentation

Rewrite the whole corpus to the build-time-only-CMS architecture (replace HTTP + ISR + webhook wording with snapshot / SSG-hybrid; keep milestone/phase framing but correct the technical contract):

  • Rules: .cursor/rules/050-apis.mdc, .cursor/rules/030-coding-best-practices.mdc, .cursor/rules/120-security.mdc, .cursor/rules/090-error-handling.mdc (verify CMS-secret redaction still reads correctly).
  • App rules/agents: apps/lms/.cursor/rules/020-lms-system.mdc, apps/lms/AGENTS.md.
  • Reference/guide docs: admin/deployment.md, admin/security-and-rate-limiting.md, guides/content-rendering.md, guides/learner-experience.md, reference/tech-stack.md, reference/hierarchy-structure.md, reference/entry-pagination.md, reference/quiz-system.md, reference/openapi.md, ../../../docs/platform-auditing/tickets/T-027-cms-lms-drift.md, reference/assessment-architecture.md, reference/implementation.md, development/state-management-and-hooks.md, development/setup-and-contributing.md.
  • Historical/point-in-time docs: planning_milestone_1.md, planning_milestone_2.md, planning_milestone_3.md, roadmap.md, and docs/audit-2026-05/**.
  • CMS-side integration docs: apps/cms/docs/guides/create-your-lms-site.md, apps/cms/docs/admin/deployment.md.
  • Root README.md env table + integrated-build section; packages/platform-config/AGENTS.md if ISR_REVALIDATE_SECONDS changes.
  • Run npm run docs:check / docs:build after edits and confirm no broken cross-links.

Open follow-ups (non-blocking)

  • Gated course assets become local files at build; confirm the access-checked proxy (item 3) is the desired enforcement point rather than fully public static files.
  • Course landing could later become a static CMS shell + client-hydrated CTA for SEO/CDN benefits (the entry player must remain dynamic SSR for content gating).