Docs

Rendering: SSG & ISR

Nifra renders on one framework-agnostic seam, so the same app can be server-rendered per request (the default), 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.

SSG — prerender at build

Opt a static route into prerendering with export const prerender = true. 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
// A static route: render it to a static index.html at build time.
export const prerender = true

export async function loader({ api }: LoaderArgs<typeof app>) {
  return { posts: (await api.posts.get()).data } // runs at BUILD (no per-request secrets)
}

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
// A dynamic route (/posts/:slug): 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 })))

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"

const app = createWebApp({ adapter, manifest, clientEntry, api })
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) })

Set a route's freshness with export const revalidate (seconds) — nifra emits it as the x-nifra-isr-revalidate header, which the wrapper reads to set that page's TTL.

TS
// A per-route freshness window (seconds) — overrides the wrapper default.
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.

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) })
  },
}

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)

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
// 1. A route you gate yourself flips draft mode on for the editor.
import { enableDraft, disableDraft } from "@nifrajs/web"

app.get("/api/preview", async (c) => {
  if (c.query.token !== env.PREVIEW_TOKEN) return new Response("nope", { status: 401 })
  await enableDraft(c, env.DRAFT_SECRET) // signed, HttpOnly cookie
  return redirect(String(c.query.to ?? "/"))
})
app.get("/api/preview/exit", (c) => (disableDraft(c), redirect("/")))

// 2. Loaders branch on ctx.draft to load unpublished content.
export async function loader({ api, draft }: LoaderArgs<typeof app>) {
  return { post: (await api.posts.get({ query: { 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" })] }

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.

Proudly built with Nifra — server-rendered on Cloudflare Pages.MIT