Skip to content

Proposal: --target=static (SSG output + client-only runtime) #253

Description

@terrablue

Motivation

Today, Primate’s build pipeline targets a monolithic server bundle (e.g. a single server.js) that powers SSR + hydration. Client navigation uses Primate’s existing fetch-browsing model: clicking from /docs to /docs/start typically fetches the next page’s components (often the same) and new data (often different).

This proposal introduces a new build target:

primate build --target=static

With --target=static, Primate produces a static site output:

  • A full HTML file per route (SSG)
  • Optional per-route page data to enable SPA-like client navigation
  • Optional offline support via a Service Worker precache

Primary goals:

  • Enable static hosting (CDN / object storage) with no server runtime
  • Make frameworks like Svelte “pure frontend” in this target: no SSR server at runtime
  • Preserve a fast UX by keeping Primate’s client navigation model (but backed by static artifacts)

Non-goals:

  • Replace the existing monolithic target. --target=static is additive.
  • Define a universal “public env” mechanism (out of scope).
  • Implement dynamic runtime rendering in the static target.

Proposed Solution

1) Add --target=static to primate build

Add a new build target that generates:

  1. Static HTML per route (SSG output)
  2. Static page-data per route (for client navigation)
  3. A manifest that maps routes to generated artifacts (for runtime + tooling)
  4. (Optional) A Service Worker for offline caching

Example routes:

  • /docs
  • /docs/start

Static output:

build/
  docs/
    index.html
  docs/
    start/
      index.html
  page-data/
    docs.json
    docs-start.json
  client/
    app.<hash>.js
    app.<hash>.css
  primate-manifest.json

Note: a “flattened naming” (docs.html, docs-start.html) can be supported as a config option, but defaulting to “folder + index.html” yields clean URLs on common static hosts.

2) Static HTML generation per route

For each route, Primate renders a full HTML document at build time.

Inputs:

  • Route list (from router config + optional crawling)
  • The same page composition logic used today (components + data)
  • Optional preloaded data and route params

Output:

  • An HTML document that can be served directly by any static host

Key behavior:

  • The static HTML should work with:
    • No JS (content visible; links work)
    • JS enabled (client runtime boots and enhances navigation)

3) Per-route page-data (/page-data/<route>.json) for SPA-like navigation

Generate one JSON per route.

Example:

  • /page-data/docs.json
  • /page-data/docs-start.json

Each JSON file contains the minimum information needed for Primate’s client runtime to “transition” to the route without a full page reload.

Suggested shape:

{
  "route": "/docs/start",
  "rev": "build-hash-or-timestamp",
  "components": [
    { "id": "StaticPage", "chunk": "/assets/chunks/static-page.<hash>.js" } // chunks aren't currently supported by Primate
  ],
  "data": {
    "html": "<h1>Getting Started</h1>...",
    "navbar": { "items": [/* ... */] }
  },
  "head": {
    "title": "Docs · Getting Started",
    "meta": [/* ... */]
  }
}

Client navigation behavior (default):

  • On click, fetch the next route’s page-data JSON
  • Reuse already-loaded component chunks
  • Apply data, update <head> (title/meta), and update the view
  • Update history.pushState

Fallback behavior:

  • If page-data is missing or fails to load, fall back to fetching the route’s HTML and:
    • Either do a full navigation, or
    • Extract embedded page-data from the HTML (see section 4)

4) Embed initial page-data inside each HTML

To avoid an extra request on initial load, embed each page’s page-data inside the generated HTML:

<script type="application/json" id="__PRIMATE_PAGE__">
  {"route":"/docs/start","rev":"...","components":[...],"data":{...},"head":{...}}
</script>

Benefits

  • Zero extra network requests on initial load
  • Keeps HTML as a single canonical artifact for each route
  • Enables an optional “HTML fetch on navigation” strategy if desired

Runtime boot

  • On initial load, the client runtime reads #__PRIMATE_PAGE__
  • Initializes router state + component registry + data store ("mini-hydration")

5) Optional: Prefetch + caching

To preserve fast navigation:

  • Prefetch page-data on:
    • link hover/focus
    • or when links enter the viewport (IntersectionObserver)
  • Cache page-data in memory and/or Cache Storage

Suggested runtime hooks:

primate.prefetch("/docs/start");  // fetches /page-data/docs-start.json
primate.navigate("/docs/start");  // uses cached data if available

6) Offline support (opt-in)

Provide an opt-in offline mode that precaches:

  • all route HTML files
  • all route page-data files
  • app JS/CSS/assets

CLI option:

primate build --target=static --static.offline

This generates:

  • dist/sw.js
  • dist/sw-manifest.json (precache list with hashes)

With offline enabled:

  • Client navigations continue to fetch page-data, but requests resolve from Cache Storage when offline.

7) Manifest: primate-manifest.json

Generate a manifest mapping routes to artifacts and build metadata.

Example:

{
  "target": "static",
  "rev": "2026-03-01T12:34:56Z",
  "routes": {
    "/docs": {
      "html": "/docs/index.html",
      "data": "/page-data/docs.json"
    },
    "/docs/start": {
      "html": "/docs/start/index.html",
      "data": "/page-data/docs-start.json"
    }
  },
  "assets": {
    "entry": "/assets/app.<hash>.js"
  }
}

Uses:

  • Runtime can resolve route -> data url
  • Tooling can validate completeness
  • Deploy tooling can diff route-level changes

Configuration / CLI surface

CLI

  • primate build --target=static
  • --static.offline (optional)
  • --static.layout=folders|flat (optional)
    • folders/docs/start/index.html (default)
    • flatdocs-start.html for legacy/simple hosting environments
  • --static.navigation=page-data|html|auto (optional)
    • page-data → fetch /page-data/*.json on nav (default)
    • html → fetch *.html and extract embedded __PRIMATE_PAGE__
    • auto → try page-data, fallback to html

Framework behavior (Svelte example)

In --target=static, Svelte becomes client-only:

  • Static HTML contains rendered markup at build time
  • Svelte runtime hydrates/enhances the existing DOM
  • Navigations update app state using page-data and re-render client-side

This keeps “SSG + SPA transitions”:

  • Direct loads: SEO-friendly, fast initial render
  • Subsequent navigations: snappy, data-driven, minimal reloads

Backward compatibility

  • Existing targets remain unchanged.
  • Static target is additive and opt-in.
  • Runtime navigation defaults should preserve existing mental model:
    • “navigate → fetch structured data → update UI”
    • but data now comes from static page-data/*.json

Security

Static output is public by definition:

  • Do not serialize secrets into HTML/page-data (see Proposal: Add AppFacade#env(key: string) #251)
  • Clearly document that:
    • --target=static output can be inspected by end users
    • only “public” data should be included in page-data

Summary of changes

Area Change
Build CLI Add --target=static
Build pipeline Render full HTML per route
Output Emit per-route page-data/*.json
Output Emit primate-manifest.json
Runtime Support client navigation using static page-data
Runtime Prefetch + caching hooks (optional)
Offline (opt-in) Generate SW + precache manifest

Implementation sketch

Step A — Route enumeration

  • Collect known routes from router definitions
  • Optional “crawler mode” for discovering dynamic routes via a user-provided list (e.g. docs slugs)

Step B — Build-time render

For each route:

  • Produce:
    • html (full document)
    • page-data (structured payload)
  • Embed page-data inside HTML (__PRIMATE_PAGE__)

Step C — Runtime navigation adapter

  • In static target builds, runtime reads primate-manifest.json
  • Route → page-data URL resolution
  • Navigation uses:
    • fetch JSON by default
    • fallback to HTML extraction

Step D — Offline (optional)

  • Generate SW script + manifest containing hashed assets + per-route files
  • Precache during install

Open questions

  1. File naming default
    • Default folders (/docs/start/index.html) vs flat (docs-start.html)
  2. Navigation fetch strategy
    • Fetch JSON by default, or fetch HTML + extract data?
  3. Head management
    • Should page-data carry a declarative head object, or do we re-run a head-renderer client-side?
  4. Dynamic routes
    • How do we generate paths for param routes (e.g. /blog/[slug])? Likely requires user-provided route inventory, or scanning.
  5. Error handling
    • What is the runtime fallback when page-data is missing (404)? Should it fetch HTML or show a client-side 404?
  6. Chunk/component mapping
    • Do we store a components[] list in page-data, or rely on the client bundle to already include all needed components?
  7. Data size
    • How to avoid duplicating large shared data (e.g. nav tree) across many page-data files? (Possible future: shared data chunks.)
  8. Offline scope
    • Offline by default vs opt-in, and should offline support allow “partial” offline (specific route groups)?
  9. Build determinism
    • Ensure stable hashes when content is unchanged to maximize CDN caching.

Acceptance criteria

  • primate build --target=static produces a build/ that can be served by a static host
  • Each route is accessible via direct load and displays correct content
  • Client navigation between two routes:
    • does not require a full page reload
    • fetches per-route page-data by default
  • primate-manifest.json exists and correctly maps all routes
  • (If --static.offline) the site remains navigable between prebuilt routes without network access

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions