Docs

Rendering: SSR, streaming, SSG & ISR

Nifra renders on one framework-agnostic seam, so the same app can be server-rendered per request (the default), streamed progressively, prerendered to static files at build (SSG), or cached and served stale-while-revalidate (ISR) - and every strategy works on Bun, Node, Deno, and the edge.

Progressive rendering with defer()

Keep critical loader data in the first HTML shell and mark slower, non-critical values with defer(). Nifra flushes the document shell and the adapter's rendered stream first, then sends each deferred resolution through <Await> without a second data fetch. The same protocol works for actions and soft navigations, including NDJSON on the client side.

TS
// routes/products/[id].backend.ts
import { t } from "@nifrajs/schema"
import { defer } from "@nifrajs/web"
import type { Route } from "./+types/[id]"

const Review = t.object({ author: t.string(), body: t.string() })
declare function loadReviews(productId: string): Promise<{ author: string; body: string }[]>

// A deferred value is held to the same output contract as the rest of the data.
export const loaderOutput = t.object({
  product: t.object({ name: t.string() }),
  reviews: t.deferred(t.array(Review)),
})

export async function loader({ api, params }: Route.LoaderArgs) {
  const product = await api.products({ id: params.id }).get()
  return {
    product: { name: product.ok ? product.data.name : "" }, // critical: in the first shell
    reviews: defer(loadReviews(params.id)),                  // non-critical: streamed when ready
  }
}
TS
// routes/products/[id].tsx
import type { Route } from "./+types/[id]"

export default function Product({ data }: Route.ComponentProps) {
  return (
    <>
      <ProductSummary product={data.product} />
      <Await resolve={data.reviews} fallback={<p>Loading reviews…</p>}>
        {(reviews) => <Reviews items={reviews} />}
      </Await>
    </>
  )
}

This is request-time progressive SSR/partial rendering. It is distinct from build-time SSG: the shell is not a precomputed PPR artifact, and deferred values remain request-scoped.

SSG - prerender at build

Opt a static route into prerendering with export const prerender = true in its .backend.ts. Its loader runs at build time (build-safe data only - no per-request cookies or secrets), and the route is baked to a static index.html plus a _data.json the client fetches on soft-navigation.

TS
// routes/blog/index.backend.ts - a static route: render it to index.html at build time.
import { t } from "@nifrajs/schema"
import type { Route } from "./+types/index"

export const prerender = true

export const loaderOutput = t.object({
  posts: t.array(t.object({ slug: t.string(), title: t.string() })),
})

export async function loader({ api }: Route.LoaderArgs) {
  const res = await api.posts.get() // runs at BUILD (no per-request secrets)
  return { posts: res.ok ? res.data : [] }
}

For dynamic routes (/posts/:slug), enumerate the param sets with getStaticPaths. fallback decides what happens to a path you didn't list: "ssr" renders it on-demand (the natural hybrid), "404" means only the listed paths exist.

TS
// routes/posts/[slug].backend.ts - a dynamic route: enumerate which pages to prerender.
export async function getStaticPaths(): Promise<StaticPaths> {
  const slugs = await loadAllSlugs()
  return {
    paths: slugs.map((slug) => ({ params: { slug } })),
    fallback: "ssr", // an unlisted slug renders on-demand (the worker); "404" = only these exist
  }
}

At build, prerenderRoutes drives the app's own fetch to render each page to bytes - agnostic, because it sits above the adapter seam. The output is turnkey for a hybrid CDN deploy: static files served by the edge, everything else falling through to the SSR worker.

TS
// build.ts - buildClient, then prerender opted-in routes to static HTML.
import { buildClient, prerenderRoutes, cloudflarePagesRoutes } from "@nifrajs/web/build"
import { discoverRoutes } from "@nifrajs/web/fs"

await buildClient({ routesDir: "./routes", outDir: "./dist", clientModule: "@nifrajs/web-react/client" })
const { app } = await import("./server")
const { prerendered } = await prerenderRoutes({
  app,
  routes: discoverRoutes("./routes").routes,
  outDir: "./dist", // writes <path>/index.html + <path>/_data.json per prerendered route
})

// Hybrid deploy (Cloudflare Pages): serve prerendered HTML + _data.json from the CDN; everything
// else falls through to the SSR worker.
const paths = prerendered.map((p) => p.path)
Bun.write("./dist/_routes.json", JSON.stringify(cloudflarePagesRoutes({ prerendered: paths })))

On a server you run yourself, nifra build --target bun or node prerenders the same routes into _prerendered/ beside server.js, and the server answers each from its file instead of rendering it. The file is served from the page route, so use middleware still runs first and a gate in front of the page still applies. A request with a query string, a navigation data request, or a draft-mode visitor renders on demand. So does a page whose build-time render set a cookie, was private or no-store, or varied on a request header, and every page of an app with a nonce. The Vite pipeline and the edge targets render every page on demand.

ISR - cache with stale-while-revalidate

When data changes but not on every request, ISR gives static-like speed with background freshness. withISR wraps the app: a cacheable page (a GET document, 200, text/html) is served from the store when fresh, served stale while a fresh copy regenerates behind it, or rendered + stored on a miss. Regeneration is single-flight per key, so a hot stale page regenerates once. Every response carries an x-nifra-isr: hit | stale | miss header.

TS
// server.ts - wrap the app with Incremental Static Regeneration.
import { createWebApp, withISR, MemoryCacheStore } from "@nifrajs/web"
import { reactAdapter } from "@nifrajs/web-react"
import { clientEntry, manifest } from "./server-manifest" // generated by buildServer

const app = createWebApp({ adapter: reactAdapter, manifest, clientEntry })
const store = new MemoryCacheStore() // dev / single-instance only

// GET text/html responses are cached + served stale-while-revalidate. Default freshness 60s.
const isr = withISR(app, { store, revalidate: 60, now: () => Date.now() })
Bun.serve({ fetch: (req) => isr(req) })

Entries are keyed by origin and path. A request with a query string skips the cache by default - rendered fresh, never stored - so ?a=1, ?a=2, ... can't each store a page. To cache a paginated or sorted list, name the parameters: query: ["page", "sort"] keys on those in any order, and a request carrying any other parameter still skips. query: "all" keys on the whole query string. Give revalidateEndpoint the same query so purges match.

Set a route's freshness with export const revalidate (seconds) in its .backend.ts - Nifra hands it to the wrapper as the x-nifra-isr-revalidate header, which sets that page's TTL. That header and the route's tags exist only while a wrapper reads them: an app nothing wraps never sends them, and withISR removes them from every response it returns.

TS
// routes/pricing.backend.ts - a per-route freshness window (seconds), overriding the wrapper's.
export const revalidate = 300 // this page is fresh for 5 min, then regenerates on the next hit

A shared store for production

MemoryCacheStore is per-instance - fine for dev, but it refuses to run under NODE_ENV=production unless you opt in, because the cache and on-demand purges wouldn't propagate across instances. In production use a shared, durable store. On Cloudflare, KVCacheStore wraps a Workers KV namespace; any backend that fits the small CacheStore interface (Redis, the Cache API) works too. Every read is validated before it's trusted, so a corrupt or version-skewed entry is treated as a miss, not served broken. A store that also implements fence() keeps a purge that lands while a page renders from being undone when that render stores the page it built from the old data; both built-in stores do.

TS
// doc-check: skip - Workers entry: `Env`/`ExecutionContext` globals + your `app` from server.ts.
// worker.ts - production uses a SHARED store so the cache + purges hold across instances.
import { withISR, KVCacheStore, revalidateEndpoint } from "@nifrajs/web"

export default {
  async fetch(req: Request, env: Env, ctx: ExecutionContext) {
    const store = new KVCacheStore(env.ISR_CACHE, { expirationTtl: 86_400 }) // Workers KV
    if (new URL(req.url).pathname === "/__nifra/revalidate") {
      return revalidateEndpoint({ store, secret: env.REVALIDATE_SECRET })(req)
    }
    const isr = withISR(app, { store, revalidate: 60, now: () => Date.now() })
    // waitUntil keeps the worker alive while a stale page regenerates behind the response.
    return isr(req, { env, waitUntil: (p) => ctx.waitUntil(p) })
  },
}

A store outage never fails a page. A read that throws, rejects, or outlives storeTimeoutMs is a miss and the page renders; a failed write still returns the rendered page; a failed background regeneration keeps the stale entry. Each failure goes to onStoreError(error, { op }), which by default logs one line per operation per minute without the cache key. Set storeTimeoutMs for a network store, so a hung connection costs a miss rather than the request.

On-demand revalidation

Purge a path the moment its data changes (a CMS webhook, an admin action) with revalidateEndpoint - a POST that drops the cached entry so the next request re-renders. The token is compared in constant time; a wrong or missing token is 401, a missing or relative path is 400.

TS
# On-demand revalidation: purge a path so the next request re-renders it.
curl -X POST 'https://example.com/__nifra/revalidate?path=/posts/hello' \
  -H 'x-nifra-revalidate-token: $REVALIDATE_SECRET'
# → { "revalidated": "/posts/hello" }   (the token is checked in constant time)

A per-request CSP nonce makes a page uncacheable. For a strict policy under ISR, give createWebApp a csp from createCspPolicy instead of a nonce: constant scripts are allowed by hash, so only a page that needs a nonce carries one - see A strict CSP that still caches.

A page that must not be cached as a whole, because it is personalized or carries a nonce, can still cache the data its loader reads. Wrap the expensive call in cache.wrap from @nifrajs/cache, with the same tags the pages use. Concurrent loads of one key run once, and a store outage falls through to the loader. Pass that cache to revalidateEndpoint as dataCaches: [cache]. A tag purge then invalidates it first, then the page store, then the CDN, so no page is rebuilt from stale data. If a data cache fails, the endpoint answers 500 before it touches the page store, so a retry purges everything again. createInvalidator from @nifrajs/web/cdn takes the same dataCaches for purges from app code.

revalidateTags names what a purge by tag reaches. A list tags every page of the route alike; a function of the route's params and URL tags each page on its own. Tags travel to the CDN and to anyone reading the origin, so build them from params, never from loader data.

TS
// routes/products/[id].backend.ts - tags per page, from the route's params (never from loader data).
export const revalidate = 300
export const revalidateTags = ({ params }: { params: { id: string } }) => [`product:${params.id}`, "catalog"]

A CDN in front

withCdn from @nifrajs/web/cdn lets a CDN serve your pages near the visitor and purges it by tag. On every page a shared cache may hold (the same rule ISR caches by: a GET 200 HTML document with no Set-Cookie, no private or no-store, and an explicit public when the request carries a cookie), it sets the CDN's tag header (the route's tags plus one for the page's path) and the CDN's TTL from the route's revalidate. Browsers get max-age=0, must-revalidate, because a purge reaches the CDN, never a browser. Every other HTML response is marked no-store for the CDN, drafts included. Over withISR, the CDN is only given the freshness the page has left.

TS
// doc-check: skip - server entry: your `app`, `store` and env vars.
import { withISR } from "@nifrajs/web"
import { cloudflareZone, withCdn } from "@nifrajs/web/cdn"
import { revalidateEndpoint } from "@nifrajs/web"

const cdn = cloudflareZone({ zoneId: env.CF_ZONE_ID, apiToken: env.CF_PURGE_TOKEN })
// The CDN stores the pages ISR would, tagged, for the route's `revalidate`; browsers revalidate.
const handler = withCdn(withISR(app, { store, revalidate: 60, now: () => Date.now() }), { provider: cdn })
// One purge reaches the origin store first, then the CDN: 200 done, 202 queued, 502 refused.
const revalidate = revalidateEndpoint({ store, secret: env.REVALIDATE_SECRET, cdn })
  • Cloudflare zone (cloudflareZone): needs a Cache Rule that makes HTML eligible for cache, and, because the zone cache ignores Vary, that bypasses the cache when the request has an x-nifra-data header (soft navigations fetch the page URL with it). The Free plan allows 5 purge calls a minute; purges arriving together go out as one call.
  • Workers Cache (cloudflareWorkersCache): for an app deployed as a Worker with [cache] enabled = true. Pass cache from cloudflare:workers. It keys by path, not host, so a Worker serving different content per hostname must not use it. Local wrangler dev does not run it.
  • Vercel (vercel): pass invalidateByTag from @vercel/functions inside a Vercel Function, or a token and project id for the REST API. Purges mark pages stale by default; mode: "delete" drops them.
  • Fastly (fastly): Surrogate-Key and soft purge by default.

A purge is sent after the origin store is purged, coalesced for 250 ms, chunked to the provider's per-call limit, and retried on 429 or 5xx honoring Retry-After. The endpoint never reports a purge that did not happen: 202 while it is queued, 502 with retryable when the CDN refused it. After a mutation in app code, createInvalidator({ store, cdn, origin }).invalidate({ tags }) does the same. Staleness compounds: a page can be up to the CDN's stale-while-revalidate older than ISR's own window.

After a deploy, nifra cdn-check https://example.com/products/42 requests the page twice and once as a soft navigation. It reports whether the second request came from the CDN's cache, and fails when nifra's internal headers reach the visitor or when a soft navigation is answered with the cached document.

TS
# Several paths and tags in one call (at most 100 paths and 32 tags).
curl -X POST https://example.com/__nifra/revalidate \
  -H "x-nifra-revalidate-token: $REVALIDATE_SECRET" -H 'content-type: application/json' \
  -d '{ "paths": ["/products/42"], "tags": ["catalog"] }'
# → { "revalidated": ["/products/42"], "revalidatedTags": ["catalog"], "cdn": "accepted" }

Draft / preview mode

Let an editor preview unpublished content without exposing it to the world. enableDraft(c, secret) sets a signed, HttpOnly cookie (gate the route yourself - behind a login or a token, like Next's draftMode()); loaders then read ctx.draft to fetch drafts, and withISR bypasses the cache for that request - so the editor always renders fresh and a draft is never written to the public cache. Pass the same draftSecret to createWebApp and withISR; a forged or tampered cookie fails the constant-time signature check.

TS
// doc-check: skip - fragment spanning three files: your `app`, `env`, `redirect`, and route `slug`.
// 1. Mount the preview entry point your CMS links to. It checks the token in CONSTANT TIME,
//    sets the signed HttpOnly cookie, and refuses an off-site ?to= (an open redirect otherwise).
import { previewEndpoint, disableDraft } from "@nifrajs/web"

const preview = previewEndpoint({ secret: env.PREVIEW_TOKEN, draftSecret: env.DRAFT_SECRET })
app.get("/api/preview", (c) => preview(c.req)) // ?token=…&to=/posts/hello
app.get("/api/preview/exit", (c) => (disableDraft(c), redirect("/")))
// Gating it yourself instead? Use enableDraft(c, env.DRAFT_SECRET) AFTER your own check - and
// compare the token in constant time, since === leaks it one character at a time.

// 2. Loaders (in a route's .backend.ts) branch on draft to load unpublished content.
export async function loader({ api, draft, params }: Route.LoaderArgs) {
  return { post: (await api.posts.get({ query: { slug: params.slug, includeDrafts: draft } })).data }
}

// 3. Wire the SAME secret so loaders see ctx.draft + editors bypass the ISR cache.
createWebApp({ adapter, manifest, clientEntry, api, draftSecret: env.DRAFT_SECRET })
withISR(app, { store, revalidate: 60, now: () => Date.now(), draftSecret: env.DRAFT_SECRET })

Fonts

Self-host your fonts (hotlinking a CDN is a privacy leak and an extra connection). fontFace() generates a CLS-safe @font-face - it defaults to font-display: swap and supports the size-adjust / ascent-override metric overrides that stop the fallback→web-font layout shift; put it in a CSS file your app imports. fontPreload() returns a <link rel="preload" as="font"> for a layout's meta.link, so the file downloads with the document instead of waiting on CSS parse.

TS
// fonts.css - a CLS-safe @font-face for a self-hosted font (the pipeline bundles + hashes it).
import { fontFace } from "@nifrajs/web"
export default fontFace({
  family: "Inter",
  src: [{ url: "/fonts/inter-var.woff2" }], // self-host it - never hotlink a CDN
  weight: "100 900",                        // variable font
  sizeAdjust: "100.06%", ascentOverride: "90%", // optional: stop fallback->web-font layout shift
})

// a root layout - preload the file so it downloads WITH the document (not after CSS parse):
import { fontPreload } from "@nifrajs/web"
export const meta = { link: [fontPreload({ href: "/fonts/inter-var.woff2" })] }

Web vitals from real users

reportWebVitals from @nifrajs/web/vitals reports the Core Web Vitals your users' browsers measure (LCP, INP, CLS, plus FCP and TTFB), using Google's web-vitals - install it with bun add web-vitals. Each metric arrives once its value is final, with the id of the route it belongs to (the id useMatches reports), so the numbers group by route instead of by URL.

TS
import { reportWebVitals, type WebVitalsMetric } from "@nifrajs/web/vitals"

function send(metric: WebVitalsMetric): void {
  // name: "LCP" | "INP" | "CLS" | "FCP" | "TTFB"; route: the matched route's id, e.g. "users/[id]"
  const { name, value, rating, id, route } = metric
  const body = JSON.stringify({ name, value, rating, id, route })
  void fetch("/api/vitals", { method: "POST", body, keepalive: true }) // survives the page closing
}

// Call it from the root layout's mount effect; the stop function it returns is the cleanup.
//   React, Preact: useEffect(() => reportVitals(), [])
//   Svelte: onMount(reportVitals)    Solid: onMount(() => onCleanup(reportVitals()))
//   Vue: onMounted(() => (stop = reportVitals())), onUnmounted(() => stop())
export const reportVitals = () => reportWebVitals(send, { softNavigations: true })

With softNavigations: true, a client-side navigation counts as a page view of its own where the browser can measure one (Chromium 151 and later), and its metrics carry the route it navigated to; the first page's metrics then settle at the first navigation. Other browsers report the whole visit as one page. reportAllChanges: true reports every change to a metric instead of its final value - aggregate those with delta or id.

Which one?

Content that's the same for everyone and changes rarely → SSG. Content that changes occasionally and can tolerate seconds-to-minutes of staleness → ISR. Per-request or per-user content → plain SSR (the default). You can mix all three in one app, route by route.