Skip to content

feat: frontend-only / SPA mode (ssr: false) [deferred design record] #462

Description

@vivek7405

Status: DEFERRED (not planned for now). This issue records a fully-researched implementation plan for a webjs frontend-only / SPA mode. After a thorough design exploration we decided not to build it yet, for these reasons:

  • Off-thesis. SPA mode is the one mode that inverts webjs's core invariants (no-JS first paint, pages-do-not-execute-client-side, .server.ts everywhere). It would fork the guardrails, docs, and test matrix on every future change.
  • Real ongoing carry for an early-stage framework. Two routers to keep in sync, a manifest generator locked to buildRouteTable, a doubled test matrix, and no dogfood app to keep the path honest (all 4 dogfood apps are SSR).
  • Most "frontend-only" demand is not SPA demand. Component-as-a-library and embeds already work via the browser-ready @webjsdev/core; heavy interactivity is already covered by islands; static-host content sites are largely covered by webjs start + revalidate + a CDN.
  • Prerender/SSG is low-ROI here. Because webjs is no-build and already SSRs + caches HTML, prerender's only unique gain is "no server process at all" (a narrow static-only-host case).
  • Deferring costs almost nothing. The enabling primitive (browser-capable core) already exists and is not going away, so building this later is no harder than now.

Revisit when there is real user demand for a server-free, client-routed mode. The full plan below is preserved so the analysis is not re-litigated. Cheaper, on-thesis precursors (bless components-as-a-library + a --template widget; a small spa-island client-router helper) are the preferred near-term moves if any frontend-only appetite shows up.


Plan: frontend-only / SPA for webjs (ssr: false)

Context

webjs is SSR-first and server-bound: pages and layouts run only on the server, the client router swaps server-rendered HTML fragments via <!--wj:children:--> markers plus the X-Webjs-Have header, and the route table is built by walking the app/ filesystem server-side. There is no way to ship a webjs app as a frontend-only / SPA bundle (client-side routing, no server at runtime), in the spirit of early React Router. The component layer is already frontend-capable: @webjsdev/core (the Lit-borrowed html / css / WebComponent / signals / directives / render()) ships a browser bundle and renders entirely client-side today.

Prior-art research (what comparable frameworks do)

Explored local clones plus web docs:

  • React Router / Remix SPA Mode (ssr: false in the Vite plugin): at build, server-renders ONLY the root route to a static index.html containing the root layout plus a HydrateFallback and the route manifest; client router (window.__remixManifest plus route.lazy) does all matching; server loader/action exports are rejected at build time, only clientLoader/clientAction run; deep links need the host to serve index.html for all paths. Files: remix-v2/packages/remix-dev/vite/plugin.ts (handleSpaMode), packages/remix-react/routes.tsx, docs/guides/spa-mode.md.
  • Nuxt (ssr: false): swaps the server entry for a no-op, emits a spa-loading-template.html shell plus a 200.html/404.html fallback for static hosts, client app createApp().mount(), routes generated from pages/ into routes.mjs with defineAsyncComponent per-route splitting. Files: nuxt/packages/nitro-server/src/index.ts, packages/nuxt/src/app/entry.ts, pages/module.ts.
  • SvelteKit: export const ssr = false plus adapter-static({ fallback: '200.html' }). Same shape.
  • Next.js: only output: 'export', which is SSG, not SPA (enforces generateStaticParams, blocks server actions/middleware/dynamic functions). Wrong model to copy here.

Three convergent lessons: (1) the surface is a ssr: false-style flag, (2) the shell is the root layout plus a loading fallback plus a 200.html deep-link fallback, (3) server data functions are forbidden and a clear build/dev-time error redirects users to client fetch.

Key decisions (settled during the design discussion)

  1. Surface: ssr: false. Expose frontend-only as "webjs": { "ssr": false } in package.json, the surface React Router / Nuxt / SvelteKit users already know. (Not a novel engine: spa marker.)
  2. Internals: a separate SPA engine module, reusing @webjsdev/core. The flag dispatches to it; the SSR request handler and the SSR webjs check rules stay unpolluted (no per-mode conditionals smeared through the SSR pipeline).
  3. Pages/layouts client-execute, once. A pure SPA renders each page exactly once, in the browser, so it does NOT reintroduce the whole-page hydration-mismatch bug class (that only exists in server-render-plus-client-hydrate, which ssr: false is not). The "pages don't hydrate" invariant stays true: in SPA they client-render once, never hydrate.
  4. No .server.ts in SPA projects. 'use server' actions, expose() REST, and .server.ts utilities are forbidden (no server to host the RPC endpoints; the stub would POST to a non-existent /__webjs/action/*). Mirror Remix: a dev-time hard error plus a check rule. Server logic, if wanted, lives in a separate backend the SPA calls over HTTP.
  5. Data via richFetch/fetch/connectWS (all browser-safe, already in core) against a third-party API or a separately deployed backend. v1 default scaffold = static SPA calling WEBJS_PUBLIC_API_BASE, with a pure third-party-API variant shown.
  6. Components are unchanged. All interactivity (signals, @click, static lazy, context, Task, optimistic, render()) already runs client-side. Porting a component to SPA is swapping the data import, not a rewrite.
  7. Runtime route manifest, not a shared matcher. Generate .webjs/spa-manifest.js (path -> lazy import(), with precompiled RegExp patterns) by reusing buildRouteTable. The browser router carries a ~15-line match loop. segmentsToPattern stays server-only (runs only in the generator).
  8. SSG is deferred (build-time prerender is a separate later feature; Next's export model is explicitly out of scope).

Recommended approach (phased, each phase independently testable)

Phase 0. Spike plus lock shape (throwaway)

Hand-write a manifest for a 3-route tree, wire an ~80-line client router using core render(), confirm the nested layout chain renders in-memory (layouts.reduceRight((children, L) => L({children, ...props}), page(props)), no wj:children markers). Lock the SPA engine package name (@webjsdev/spa), exports map, and that ssr: false is the dispatch flag.

Phase 1. SPA engine skeleton plus manifest generator (Node-side, unit-testable)

  • Create packages/spa/package.json (@webjsdev/spa, type: module, dep @webjsdev/core; @webjsdev/server as a devDependency only, used at generate/dev time, never shipped to the browser). Exports ., ./client-router, ./check.
  • Create packages/spa/src/manifest.js. generateManifest(appDir) reuses buildRouteTable (packages/server/src/router.js:61) plus segmentsToPattern (:205); emits .webjs/spa-manifest.js (sorted pages, paramNames, RegExp literals byte-identical to the server, lazy import() thunks for page plus layout chain plus root not-found).
  • webjs types for an SPA project also calls generateRouteTypes (packages/server/src/route-types.js:132, unchanged) for .webjs/routes.d.ts.
  • Create packages/spa/AGENTS.md plus CLAUDE.md.
  • Tests (unit, packages/spa/test/manifest/): route count plus specificity sort; paramNames; regex sources byte-identical to segmentsToPattern; [slug]/[...rest]/[[...rest]] matching; _private/(group) excluded; layouts outermost-first; deterministic output.

Phase 2. SPA client router (browser; the core deliverable)

  • Create packages/spa/src/spa-router-client.js (exported as @webjsdev/spa/client-router): import the manifest; matchSpaPage(table, pathname) (mirrors matchPage, packages/server/src/router.js:254); resolve the layout chain via import() thunks in parallel; build the nested tree and render(tree, mountEl) via @webjsdev/core. Intercept same-origin <a> clicks plus History API (pushState/popstate). No fetch of server fragments, no wj:children markers. Root not-found in v1 (nested not-found is a fast-follow). Per-history-entry scroll restoration (scroll only). Optional loading rendered before the lazy import() resolves (v1 may defer loading.js; called out).
  • Tests: browser (packages/spa/test/routing/browser/, wtr) is the headline assertion. Initial render; link-click swap without reload plus location update; back/forward route plus scroll; dynamic param decode; unknown route to not-found. Unit covers matchSpaPage precedence/params/catch-all in Node.
  • Risk: custom-element registration ordering. Render only after the page module import() resolves (its top-level component imports self-register), already the natural order.

Phase 3. Static shell plus dev server (CLI dev when ssr: false)

  • Create packages/spa/src/shell.js. buildSpaShell({appDir, coreDir, dev}) emits ONE static index.html: doctype/html/head/body, the importmap (@webjsdev/core -> /__webjs/core/index-browser.js), modulepreload for manifest plus root layout, a loading fallback (the root layout plus a spinner/placeholder, the HydrateFallback/spa-loading-template analog) shown until the client router resolves, and a boot <script type="module"> importing @webjsdev/spa/client-router. Reuse buildImportMap/importMapTag (packages/server/src/importmap.js:336,444) and the head shape from ssr.js buildDocumentParts/wrapHead; call setCoreInstall(coreDir, distMode). Also emit a copy as 200.html (and 404.html) for static-host deep-link fallback (the Nuxt/SvelteKit pattern).
  • Create packages/spa/src/dev.js, a slim independent SPA dev server (keeps the no-server-runtime-dep constraint clean; webjs is no-build so there is no Remix-style build-time root render, the shell is emitted directly). SPA fallback (any non-asset GET returns the shell), serve app//components//lib/utils/ as TS-stripped ES modules, serve /.webjs/spa-manifest.js (regenerated on fs.watch of app/), serve /__webjs/core/*, SSE live reload. Reuse the module-graph authorization gate reachableFromEntries (packages/server/src/module-graph.js) as a devDependency.
  • Modify packages/cli/bin/webjs.js case 'dev': a single detectSsrFalse(appDir) (reads package.json webjs.ssr === false) branches to the SPA dev server.
  • Tests: e2e (test/e2e/, WEBJS_E2E=1) boots webjs dev on a scaffolded SPA app: client nav, deep-link /blog/[slug] returns the shell plus router resolves, 404 renders not-found. Unit covers buildSpaShell (importmap/boot/preload/200.html) and detectSsrFalse. Browser covers shell plus router against the real served shell.

Phase 4. CLI start / types / check when ssr: false

  • Modify packages/cli/bin/webjs.js (all branch on detectSsrFalse):
    • case 'start': no server to start. Run a minimal static preview server serving the shell plus assets AND print a "deploy this directory to any static host" hint (with the 200.html note).
    • case 'types': call generateRouteTypes (routes.d.ts) AND generateManifest (spa-manifest.js).
    • case 'check': call checkSpaConventions from @webjsdev/spa/check.
  • Create packages/spa/src/check.js. checkSpaConventions(appDir) reuses server's checkConventions/RULES engine (devDependency) with a name allowlist. Keep: components-have-register, tag-name-has-hyphen, no-duplicate-tag, reactive-props-use-declare, no-browser-globals-in-render, erasable-typescript-only, no-non-erasable-typescript, no-scaffold-placeholder. Drop: shell-in-non-root-layout. Add: no-server-in-spa (flags any .server.* import, 'use server', or expose() in an SPA project).
  • Server-import is a dev-time HARD ERROR, not only a check (Remix parity): the SPA dev/manifest path throws with a clear message plus docs link when it encounters a .server.* import reachable from a browser entry.
  • Tests: unit (packages/spa/test/check/) kept rules present, dropped rule absent, no-server-in-spa fires on a .server.ts import plus clean-pass counterfactual; a test asserting every SPA-allowlist name exists in server's RULES (guards rename drift); e2e webjs check exit codes plus the dev-time hard error; unit webjs types writes both artifacts.

Phase 5. Scaffold (webjs create <name> --template spa)

  • Modify packages/cli/bin/webjs.js to add 'spa' to TEMPLATES (:38).
  • Modify packages/cli/lib/create.js to add isSpa = template === 'spa' (:250): package.json with "webjs": { "ssr": false }, @webjsdev/spa dep, NO Prisma / .server / migrate hooks; app/layout wiring import '@webjsdev/spa/client-router'; app/page example client-rendered page; one interactive component; an example richFetch against WEBJS_PUBLIC_API_BASE with a pure third-party-API comment variant; webjs-scaffold-placeholder sentinels; .gitignore reusing the .webjs plus vendor-exception pattern.
  • Create packages/spa/src/spa-template.js (parallel to saas-template.js); a SPA-tuned CONVENTIONS.md (data via API, no .server.*).
  • Tests: scaffold-smoke (test/scaffolds/) adds spa to template validation; assert ssr: false present, no Prisma, router wired, webjs dev boots; e2e scaffolded SPA app client nav end-to-end.

Phase 6. Docs plus config schema

  • Finalize packages/spa/AGENTS.md; new agent-docs/spa.md (the SPA execution model, the no-.server.ts rule, the data story, the 200.html deploy note); update root AGENTS.md Scaffolding plus CLI sections (mind the prose-punctuation rules); update docs//website/.
  • Add ssr (boolean, default true) to webjs-config.schema.json, the WebjsConfig type, the config reader, and KNOWN_KEYS in the config-drift test (all in lockstep).
  • Tests: config-schema drift test updated for ssr.

Deferred within the plan (noted, not built in v1)

  • Typed remote-action client: generate RPC stubs targeting a configured remote webjs API origin (WEBJS_PUBLIC_API_BASE, CORS plus token auth) so .server.ts actions hosted on a separate webjs backend stay callable from the SPA with full end-to-end types ("your actions are your API, fully typed"). Fast-follow.
  • SSG / build-time prerender (separate feature).
  • Nested not-found, loading.js in SPA, View Transitions in SPA (fast-follows once the core router lands).

Critical files

  • packages/server/src/router.js. Reuse buildRouteTable plus segmentsToPattern/matchPage at generate time.
  • packages/core/src/render-client.js. The isomorphic render() the SPA router renders the layout-plus-page tree through.
  • packages/core/src/router-client.js. Reference only; its fetch-plus-marker mechanism is NOT reused.
  • packages/cli/bin/webjs.js. detectSsrFalse dispatch for dev/start/types/check/create.
  • packages/cli/lib/create.js. The isSpa branch plus spa template.
  • packages/server/src/importmap.js. Reuse buildImportMap/importMapTag for the static shell.
  • packages/server/src/check.js. Reuse checkConventions/RULES for the filtered SPA ruleset.

Cross-cutting risks

  1. @webjsdev/spa must not runtime-depend on @webjsdev/server. Keep server a devDependency used only in generate/dev code; the shipped browser artifact imports only @webjsdev/core. Fallback if even a dev-dep is unwanted: extract buildRouteTable/segmentsToPattern into a tiny @webjsdev/router-core shared package (defer unless needed).
  2. Manifest staleness in dev. Regenerate on fs.watch of app/.
  3. Custom-element registration ordering (Phase 2).
  4. Rule-name drift between server RULES and the SPA allowlist, guarded by a test.
  5. start semantics could imply SPA needs a Node host. The deploy hint plus 200.html note make the static-host story explicit.
  6. Users expecting server actions in an SPA. The hard error plus docs redirect to the fetch/external-API model (and the deferred typed remote-action client).

Verification

  • Per-phase tests above (unit, browser via npm run test:browser, e2e via WEBJS_E2E=1, scaffold-smoke). npm test does NOT run browser or e2e, so run them explicitly and report results.
  • End-to-end manual: webjs create demo --template spa, then cd demo, then npm run dev. Load /, click-navigate (no full reload), deep-link a dynamic route, hit a 404, confirm an interactive component upgrades client-side, confirm richFetch hits the configured API base, confirm importing a .server.ts errors with the redirect message. Then webjs check (clean) and webjs types (emits both routes.d.ts and spa-manifest.js). Confirm the emitted 200.html exists for static deploy.
  • Dogfood gate per repo policy: verify the existing 4 dogfood apps still boot (the SSR path must be untouched by the ssr:false dispatch).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Type

No type

Projects

  • Status
    Done

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions