Morphos & Kosmesis are here.Explore
PraxisJS

SSG

@praxisjs/ssg — static site generation with real client-side hydration: prerender every route to HTML at build time, then reconcile it into the live DOM on the client instead of discarding it.

SSG

@praxisjs/ssg prerenders your app's routes to static HTML at build time — good for SEO and first paint — then hydrates that HTML on the client instead of throwing it away and mounting fresh. Elements that match keep their real DOM node (props and listeners are reapplied to it); only text/comment nodes and any local mismatch get recreated.

npm install -D @praxisjs/ssg
pnpm add -D @praxisjs/ssg
yarn add -D @praxisjs/ssg
bun add -d @praxisjs/ssg

Experimental

New package, still settling. See Packages for the full stability matrix.


Setup

Add ssgPlugin() alongside praxisjs(). It runs after vite build, loading root through Vite's own SSR module graph to read your route table:

// vite.config.ts
import { praxisjs } from '@praxisjs/vite-plugin'
import { ssgPlugin } from '@praxisjs/ssg'
import { defineConfig } from 'vite'

export default defineConfig({
  plugins: [
    praxisjs(),
    ssgPlugin({
      root: './src/app.tsx', // exports the root component (default), routes, and optionally getStaticPaths
    }),
  ],
})

root needs two exports: the root component as the default export, and the same route table you pass to @Router([...]) as a named routes export. The plugin reads routes to know which pages exist — it can't recover that table from a decorated class at build time.

// src/routes.ts
import type { RouteDefinition } from '@praxisjs/router'
import { Home, About, BlogPost } from './pages'

export const routes: RouteDefinition[] = [
  { path: '/', component: Home },
  { path: '/about', component: About },
  { path: '/blog/:slug', component: BlogPost },
]
// src/app.tsx
import { Router } from '@praxisjs/router'
import { routes } from './routes'

export { routes }

@Router(routes)
export default class App extends StatefulComponent {
  render() {
    return <RouterView />
  }
}

Keep routes and getStaticPaths out of vite.config.ts directly

vite.config.ts is loaded by Vite's own minimal config bundler, which doesn't lower decorator syntax. Passing routes or getStaticPaths inline — where either one imports something decorated, like a @Collection schema — bundles raw decorator syntax straight into the config, and Node can't run that. Exporting both from root sidesteps it: they only ever load through ssrLoadModule(), which runs your app's real Vite pipeline, decorators included.

Nothing else changes. main.tsx still calls render(() => <App />, document.getElementById('app')) exactly as before — render() checks the prerendered HTML for a hydration marker and decides on its own whether to hydrate.


Options

ssgPlugin({
  root: './src/app.tsx',
  routerOptions: {},
  containerSelector: '#app',
  hydrate: true,
})
OptionTypeDefaultDescription
rootstringModule exporting the root component (default), the route table (named routes), and optionally getStaticPaths (named).
routerOptionsRouterOptionsSame options object you'd pass to @Router([...], options) / createRouter().
containerSelectorstring"#app"CSS selector for the mount container inside index.html.
hydratebooleantrueMarks output HTML for client-side hydration. Set false to fall back to a plain client remount — see Turning hydration off.

Dynamic routes

A route like /blog/:slug has no fixed set of pages, so give it its own getStaticPaths to supply the concrete paths to prerender. If you're using @praxisjs/content, skip writing this by hand — collectionStaticPaths(Schema) builds it from a collection, substituting each entry's slug into the route's dynamic segment:

// src/routes.ts
import { collectionStaticPaths } from '@praxisjs/content'
import type { RouteEntry } from '@praxisjs/ssg'
import { Blog } from './content/blog'

export const routes: RouteEntry[] = [
  // ...other routes
  {
    path: '/blog/:slug',
    component: BlogPost,
    getStaticPaths: collectionStaticPaths(Blog),
  },
]

Without @praxisjs/content, or for custom logic, write getStaticPaths yourself. It receives the route's own full path and returns the concrete paths to generate:

{
  path: '/blog/:slug',
  component: BlogPost,
  getStaticPaths: async (fullPath) => ['/blog/hello-world', '/blog/second-post'],
}

No path matching needed either way — a route's getStaticPaths only ever runs for the route it's declared on, so several dynamic routes each get their own instead of one function dispatching on fullPath. root can also export a top-level getStaticPaths: GetStaticPaths (receiving (route, fullPath)) as a fallback for routes that don't declare their own; a route's own definition always wins when both are present.

Output

/ writes index.html; every other path writes <path>/index.html (/aboutabout/index.html, /blog/hello-worldblog/hello-world/index.html) — the trailing-slash convention most static hosts (Netlify, Vercel, GitHub Pages) expect.


How hydration works

  1. Prerender (build time, Node). Each route mounts into a headless DOM, waiting for resource()/@Collection data and any Lazy(...)-wrapped route or layout component to settle, then serializes the result.
  2. Client mount. render() sees the hydration marker on the container and builds the component tree the same way it would for any client-only app — except into a detached node instead of the page.
  3. Reconcile. That fresh tree is diffed against the container's real DOM. Matching elements keep their real node — props and event listeners are reapplied rather than recreated. Text and comment nodes always come from the fresh tree, and anything that doesn't line up locally is recreated at that one spot.

No changes to how you write or mount a component are needed to make it hydratable — render() detects the marker on its own.

Turning hydration off

Pass hydrate: false to skip all of the above. Output HTML carries no marker, and render() falls back to its normal create-mode mount (clear the container, mount fresh) on the client — same as a page with no server-rendered HTML at all. Reach for this when a page's DOM is unlikely to match closely enough for reconciliation to pay off, like heavy client-only randomization.

Limitations

  • <Portal> content isn't in the prerendered HTML. Portals mount outside the normal tree (document.body by default), so the server render skips them — they mount normally on the client right after hydration completes. Fine for modals and tooltips, which rarely need to exist in the initial HTML anyway.
  • resource()/@Collection data is re-fetched on the client, not transferred. For build-time-static data this reproduces the same result, so there's no visible mismatch — just some redundant work versus serializing the data alongside the HTML.
  • Reconciliation is positional, not keyed. Dynamic lists are matched by position rather than a stable key. Deterministic, build-time data isn't affected — same data, same order on both sides — but a genuine reorder between build and first client render shows up as a set of local mismatches rather than a detected reorder.

Lower-level API

prerender() does the actual rendering work without any Vite integration, for custom build pipelines:

import { prerender } from '@praxisjs/ssg'
import App from './src/app'
import { routes } from './src/routes'

const pages = await prerender({
  root: App,
  routes,
  template: await readFile('dist/index.html', 'utf-8'),
})
// pages: Array<{ path: string; file: string; html: string }>

On this page