Pracht supports four rendering modes, configured per-route. Each route declares how and when its HTML is generated.
| Mode | HTML generated | Loader runs | Best for |
|---|---|---|---|
| SSG | Build time | Build time | Static content: marketing, docs, blog |
| SSR | Every request | Every request | Personalized/dynamic pages |
| ISG | Build + revalidation | Build + on stale request | Semi-static: pricing, catalogs |
| SPA | Client only | Client navigation | Auth-gated dashboards, admin UI |
route("/about", () => import("./routes/about.tsx"), { render: "ssg" });HTML is generated at build time. The loader runs once during the build, and the
output is written to dist/client/about/index.html. No server is needed for the
initial document request — it's served as a static file. Client-side navigation
uses the route-state JSON endpoint when an adapter runtime is available, and
falls back to full document navigation on purely static hosts.
For routes with dynamic segments, export a getStaticPaths function that
returns the params for each page to generate:
// src/routes/blog-post.tsx
import type { LoaderArgs, RouteParams } from "@pracht/core";
export function getStaticPaths(): RouteParams[] {
const posts = getAllPosts();
return posts.map((p) => ({ slug: p.slug }));
}
export async function loader({ params }: LoaderArgs) {
return { post: await getPost(params.slug) };
}The build calls getStaticPaths() to enumerate params, constructs full paths
from the route pattern, then runs the loader and renderer for each.
Output: dist/client/blog/hello-world/index.html, etc.
Dynamic params are percent-encoded before output paths are written, and exact
. / .. dynamic param segments are rejected. Static route patterns cannot
contain raw dot segments or backslashes. The CLI also verifies every
prerendered file resolves inside dist/client/ before writing.
Prerendering runs concurrently (default: 10 parallel renders). Tune it with pracht({ prerenderConcurrency }) in your Vite config when CI needs more or less parallelism.
route("/dashboard", () => import("./routes/dashboard.tsx"), { render: "ssr" });HTML is generated fresh on every request. The loader runs server-side, the component renders to a string, and the full HTML is returned with hydration state.
After hydration, client-side navigation takes over — subsequent navigations fetch only the loader data as JSON, not full HTML.
- Pages that depend on the request (cookies, auth, personalization)
- Data that changes frequently
- Pages where SEO matters and data is dynamic
route("/pricing", () => import("./routes/pricing.tsx"), {
render: "isg",
revalidate: timeRevalidate(3600), // revalidate every hour
});ISG generates HTML at build time (like SSG) and, on adapters with persistent platform state, regenerates it after a configurable time window or an authenticated webhook. Node and Cloudflare serve stale HTML immediately while a new version is generated in the background for time-based revalidation. Vercel uses Build Output API prerender functions and the platform ISR cache.
import { timeRevalidate } from "@pracht/core";
{
revalidate: timeRevalidate(3600);
} // secondsNode checks the file's mtime against the revalidation window. Cloudflare checks
the generated timestamp stored in the Cache API entry and falls back to the
build-time asset timestamp. Vercel writes native .prerender-config.json files
with expiration set from the time policy.
Cloudflare Workers Caching: with
cloudflareAdapter({ cache: true })(plus"cache": { "enabled": true }in wrangler config), time-revalidated ISG routes are instead served through Workers Caching: pages render on demand, the edge caches them for therevalidatewindow, and stale pages are served instantly while the Worker re-renders in the background — a true edge-tier cache rather than the per-colo Cache API. Cached pages can be purged early withpurgeCache()from@pracht/adapter-cloudflare/cache. Pracht only stamps Workers Caching headers on ISG responses that pass the shared ISG cache-safety policy, so responses withSet-Cookie,Cache-Control: private/no-store, orVary: Cookie,Vary: Authorization, orVary: *stay out of the shared edge cache. See ADAPTERS.md.
import { webhookRevalidate } from "@pracht/core";
{
revalidate: webhookRevalidate();
}An external system POSTs to the framework endpoint to trigger regeneration:
curl -X POST https://example.com/__pracht/revalidate \
-H "Authorization: Bearer $PRACHT_REVALIDATE_TOKEN" \
-H "Content-Type: application/json" \
-d '{"paths":["/pricing"]}'Set PRACHT_REVALIDATE_TOKEN in the runtime environment. The endpoint fails
closed with 401 when the token is unset or incorrect. Pracht also accepts the
same secret in the x-pracht-revalidate-token header for webhook providers
that cannot send bearer auth.
The request accepts at most 64 paths; larger batches receive 400. The
response reports revalidated, skipped, and failed path arrays. Ineligible
paths (not ISG, not prerendered, no webhookRevalidate()) are skipped; paths
whose regeneration errored are reported in failed and keep serving the
previously generated copy. Concurrent regenerations of the same path are
single-flighted, so a burst of stale traffic or webhook posts triggers one
render, not N. A webhook arriving during a stale-request regeneration joins
that existing render and can report revalidated even if the render started
before the content change; send a later webhook when strict post-change
freshness is required.
Dynamic ISG paths that getStaticPaths() did not enumerate at build time are
rendered on demand per request (uncached); webhook posts naming them are
reported as skipped on Node and Cloudflare because there is no generated
copy to refresh.
Webhook and time policies can be combined:
import { timeRevalidate, webhookRevalidate } from "@pracht/core";
route("/pricing", () => import("./routes/pricing.tsx"), {
render: "isg",
revalidate: [timeRevalidate(3600), webhookRevalidate()],
});This means "regenerate hourly, or sooner when a CMS webhook names this path."
| Adapter | Time revalidation | Webhook revalidation |
|---|---|---|
| Node | File mtime + stale-while-revalidate | Regenerates the on-disk HTML file |
| Cloudflare | Cache API + env.ASSETS fallback |
Overwrites the Cache API entry |
| Vercel | Build Output API prerender expiration |
Uses x-prerender-revalidate |
Cloudflare's Cache API is per-colo. A webhook updates the colo that receives the webhook request; other colos refresh on their next stale request or their own webhook hit. Use shorter time windows when globally immediate invalidation is required.
route("/settings", () => import("./routes/settings.tsx"), { render: "spa" });The route component is not server-rendered. On the initial document request,
pracht renders the assigned shell immediately and, if the shell exports
Loading, includes that placeholder in the HTML. The route component still
renders entirely in the browser after the client router fetches route-state
JSON.
// src/shells/app.tsx
import type { ShellProps } from "@pracht/core";
export function Shell({ children }: ShellProps) {
return <div class="app-shell">{children}</div>;
}
export function Loading() {
return <p>Loading page...</p>;
}This improves first paint for auth-gated apps without serializing loader data into the initial document by default.
- Auth-gated pages where SEO doesn't matter, but shell chrome should paint fast
- Complex interactive UIs (editors, dashboards)
- Pages where server rendering adds no value
The power of per-route modes is mixing them in one app:
export const app = defineApp({
shells: {
public: () => import("./shells/public.tsx"),
app: () => import("./shells/app.tsx"),
},
routes: [
group({ shell: "public" }, [
route("/", () => import("./routes/home.tsx"), { render: "ssg" }), // Static
route("/pricing", () => import("./routes/pricing.tsx"), {
render: "isg",
revalidate: timeRevalidate(3600), // Revalidating
}),
route("/login", () => import("./routes/login.tsx"), { render: "ssr" }), // Dynamic
]),
group({ shell: "app", middleware: ["auth"] }, [
route("/dashboard", () => import("./routes/dashboard.tsx"), { render: "ssr" }), // Dynamic
route("/settings", () => import("./routes/settings.tsx"), { render: "spa" }), // Client-only
]),
],
});Public marketing pages are SSG (fast, cacheable). Pricing updates hourly via ISG. Login needs SSR for CSRF/session handling. Dashboard is SSR for personalization. Settings is SPA because it's behind auth and doesn't need SEO.
Orthogonal to the render mode, every route also has a hydration mode:
route("/", () => import("./routes/home.tsx"), {
render: "ssg",
hydration: "islands",
});| Mode | Behavior |
|---|---|
"full" (default) |
The whole page tree hydrates; the client router takes over navigation. |
"islands" |
Only components from src/islands/ hydrate; the page is otherwise static. |
"none" |
Fully static output — no JavaScript is injected at all. |
hydration combines with ssg, isg, and ssr. render: "spa" always
implies full hydration (combining it with "islands"/"none" is a config
error). Routes with "islands" or "none" use regular full-document
navigation instead of the client router. See ISLANDS.md for the
full picture: island discovery, hydration strategies (load/idle/visible),
prop serialization rules, and limitations.
After the initial page load (for full-hydration routes, regardless of render mode), the client router handles navigation. All subsequent route transitions use the same flow:
- Client matches the new route
- Fetches loader data as JSON from the server (via
x-pracht-route-state-requestheader) - Updates the component tree with new data
- Pushes to browser history
This means even SSG routes get fresh loader data during client navigation — the static HTML is only for the initial load and crawlers.
Routes with hydration: "islands" or hydration: "none" never load the
client router; navigating to or from them is a normal full-document
navigation (MPA-style).