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:
submitQuizAttemptActionre-fetches the entry server-side to grade quizzes (the client cannot be trusted for grading).enrollInCoursechecks 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.sqlitewritten toapps/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
outputFileTracingIncludesinapps/lms/next.config.tsfor both thebetter-sqlite3native binary andapps/lms/data/cms-snapshot.sqliteso they ship in the function bundle. better-sqlite3is already an LMS dependency, but production currently usespgat 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.
- Add
- Fallback if the snapshot ever exceeds function size limits: store
cms-snapshot.sqlitein Vercel Blob / object storage and fetch-to-/tmpon 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
generateStaticParamsand asset proxies fetch the live CMS (e.g.learn/course/\[slug\]/page.tsx;/api/favicon,/api/cms/.../assets/*usecache: "no-store").
Work items¶
1. Build-time CMS snapshot generator¶
- New generator (e.g.
apps/lms/scripts/generate-cms-snapshot.tsinvoked viascripts/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, defaultsnapshot).httpis used only by the snapshot generator;snapshotis 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
assetstable:- public/site assets → emit to a static dir; update
apps/lms/src/lib/cms/rewrite-asset-urls.tsto 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 snapshotassetstable) instead of fetching the CMS.
- public/site assets → emit to a static dir; update
/api/faviconandproject-asset-proxy.ts/resolve-published-course-project.ts: replacecache: "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, makeCMS_API_URL/CMS_PROJECT_UUID/CMS_API_UUIDoptional for the running app; require them only inside the snapshot generator. Runtimeenv()must succeed without any CMS vars. AddCMS_SOURCE(http|snapshot, defaultsnapshot). - Update the env hint in
env.tsthat currently points atbuild:integratedfor missing CMS vars. - Decide
CMS_WEBHOOK_SECRETfate (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), andturbo.jsonbuild.env(CMS vars scoped to the snapshot task; addCMS_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
revalidatefrom CMS content pages and let Next's automatic static/dynamic detection decide: pages that don't touchheaders()/searchParamsrender statically from the snapshot; pages that do (widget/tenant/search) render dynamically from the snapshot. Reserve explicitdynamic = "force-static"only for pages provably free of dynamic-API usage. - Add
dynamicParams = false(and fully enumerategenerateStaticParamsfrom 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 fordashboard/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,MainNavadmin-link gating, and the unverified-email banner become client components that fetch session/flags from a small runtime endpoint (e.g./api/session-chrome) oruseSession.- 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 = 60and the CMStags(cms:page-hierarchy,cms:courses,course:*content,entry:*,page:*,cms:site-project-theme) fromclient.tsand CMS content pages. - Keep DB-driven caching unrelated to CMS content:
lib/tenant/resolve.tsunstable_cache(revalidate: 60,tenant-host:*), admin dashboards'revalidate: 300+ course/progress tags, and the in-apprevalidateTag/revalidatePathcalls 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, updateapps/lms/src/schemas/openapi-registry.ts+ regeneratepublic/openapi.yaml(item 9) and the rate-limit row in120-security.mdc.- Resolve
packages/platform-configISR_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 withCMS_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: addoutputFileTracingIncludesforapps/lms/data/cms-snapshot.sqlite+ the local asset store. (serverExternalPackages: ["better-sqlite3"]already exists, which covers the native module.).gitignorealready ignoresdata/, 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/revalidateare registered inapps/lms/src/schemas/openapi-registry.tsand emitted toapps/lms/public/openapi.yaml. Any route change/removal must regenerate the spec (npm run openapi:generate) soopenapi-sync.test.ts,openapi-coverage.test.ts, andopenapi-generate.test.tsstay green.
10. Tests & validation¶
- Update the tests that assert the about-to-change contract:
src/lib/cms/client.test.ts(dropsnext.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 CMSno-storefetch), and the OpenAPI tests above. - Add new Vitest coverage: snapshot generator, read-only SQLite loader +
CMS_SOURCEswitch, asset URL rewriting to static paths, gated course-asset proxy reading from the local store, and anenv.tstest confirming runtime boot succeeds withoutCMS_*. - Update
apps/lms/tests/e2e/fixtures/e2e-env.tsrequired keys. - Run
npm run check(full repo) andnpm run build:integratedto 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, anddocs/audit-2026-05/**. - CMS-side integration docs:
apps/cms/docs/guides/create-your-lms-site.md,apps/cms/docs/admin/deployment.md. - Root
README.mdenv table + integrated-build section;packages/platform-config/AGENTS.mdifISR_REVALIDATE_SECONDSchanges. - Run
npm run docs:check/docs:buildafter 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).