Docs

Framework contract

Everything you'd otherwise read the source to learn, on one page: the LoaderContext shape, the loader / action / meta / head signatures, what runs on the server vs the client, the two ways to handle a request, the dynamic-route 404 rule, and the build-output contract. Every signature here is verified against the @nifrajs/web source.

LoaderContext

One context object is passed to every loader and every action. The same five fields, always - params, request, api, env, and draft.

TS
// The single context object passed to every loader AND action (@nifrajs/web).
interface LoaderContext {
  params:  Record<string, string>  // matched route params (:id, *path)
  request: Request                 // the standard Web Request - read headers/body/URL off it
  api:     unknown                 // the in-process backend client createWebApp was given
  env:     unknown                 // platform bindings forwarded from c.env (Workers KV/D1/…)
  draft:   boolean                 // true only when a valid draft cookie is present (draftSecret set)
}

// api + env are typed per-route via @nifrajs/client's LoaderArgs<Api, Env>; the agnostic core
// keeps them `unknown`. Branch on `draft` to load unpublished content for preview/editors.

Loader, action & meta signatures

A route module co-locates its data, mutation, head, and view. Only the default export (the component) is required; loader, action, meta, and hydrate are all optional. A loader runs on GET and feeds props.data; an action runs on POST and feeds props.actionData.

TS
// routes/users/[id].tsx - a route module's full contract (every export optional but `default`).
// doc-check: skip - full-contract sketch; ctx.api's routes and meta's loader-data shape come from
// the reader's own backend type, so it can't compile standalone (see the typed examples elsewhere).
import type { LoaderContext } from "@nifrajs/web"

// GET → data for the page. Runs in-process on the server during SSR (no HTTP hop).
export async function loader(ctx: LoaderContext) {
  return { user: await ctx.api.users.get({ id: ctx.params.id }) }
}

// POST → a mutation. Same context. Return a Response (e.g. a redirect - passed straight
// through) OR data, which reaches the component as `actionData`.
export async function action(ctx: LoaderContext) {
  const form = await ctx.request.formData()
  return { ok: true }
}

// Static OR a function of { data, params, origin }. Static meta is serialized once; function meta
// recomputes per request (its content can vary with loader data). `origin` is the request's
// scheme+host (server-resolved, matches the client's location.origin) - use it for ABSOLUTE
// canonical / og:url / og:image URLs without threading siteUrl through loader data.
export const meta = ({ data, params, origin }) => ({
  title: `User ${data.user.name}`,
  meta: [{ name: "description", content: data.user.bio }],
  link: [{ rel: "canonical", href: `${origin}/users/${params.id}` }],
})

export const hydrate = false   // opt out of full-document hydration (static / island pages)
export default function User(props) { /* props.data, props.actionData, props.params */ }
[!NOTE] An action may return a Response (a redirect(), say) - it's passed through untouched. Any other return is serialized as actionData.

Two ways to handle a request

A page's data comes from a loader calling the in-process backend (ctx.api) - but that backend is loaders-only: it registers GET handlers, so a .post() to ctx.api answers 405. A public POST (a browser form or fetch) is a route action export, not an inProcessClient(backend).post() call.

TS
// TWO ways to handle a request - pick by who is calling.
import type { LoaderContext } from "@nifrajs/web"

// (1) A page's own data + mutations → route exports. A PUBLIC POST is a route ACTION
//     (routes/contact.tsx) - the browser POSTs the route's own URL, Nifra runs `action`:
export async function action(ctx: LoaderContext) {
  const form = await ctx.request.formData()        // the browser POSTed this route
  return { ok: true, body: String(form.get("body")) } // → props.actionData on re-render
}

// (2) ctx.api is the IN-PROCESS backend client (the inProcessClient(server) the app was given).
//     It is LOADERS-ONLY: the SSR backend registers GET handlers, so a .post() to ctx.api 405s.
export async function loader(ctx: LoaderContext) {
  // ✅ a GET in-process - no HTTP hop. (A .post() to ctx.api would 405; write an `action` instead.)
  return { id: ctx.params.id }
}

// Rule of thumb: data INTO a page → loader + ctx.api (GET). A mutation FROM the browser
// (a form/fetch POST) → an `action` export on the route. Don't `inProcessClient(api).post()`.

Dynamic routes & the 404 / control-flow rule

A [param] route's loader runs for any value on plain SSR - getStaticPaths's fallback: "404" is enforced only by the prerender / CDN layer, never by the on-demand worker. So a loader that can't find its record must 404 itself. The mechanism is the throw contract:

TS
// routes/users/[id].tsx - plain SSR runs the loader for ANY :id value. `fallback: "404"`
// only takes effect under prerender/CDN, so on on-demand SSR you MUST guard and 404 yourself.
import type { LoaderContext } from "@nifrajs/web"

declare function lookupUser(id: string): Promise<{ id: string } | null>

export async function loader(ctx: LoaderContext) {
  // ctx.params.id is `string | undefined` (an optional segment) - default it before the lookup.
  const user = await lookupUser(ctx.params.id ?? "")
  if (!user) {
    // CONTROL FLOW: a thrown Response is an INTENTIONAL HTTP response - it propagates untouched
    // (no _error boundary, no 500). This is how you 404 a missing record.
    throw new Response("Not Found", { status: 404 })
  }
  return { user }
}

declare function lookupUser(id: string): Promise<{ name: string } | null>

// The throw contract, stated once:
//   • throw a Response  → that exact HTTP response is sent (404 / redirect() / 403 / …). Control flow.
//   • throw an Error    → renders the nearest _error boundary if one exists, else a 500. A bug.
// So NEVER `throw new Error("not found")` to mean 404 - that is a 500. Throw a 404 Response.

Throwing a Response is control flow - that exact HTTP response (a 404, a redirect(), a 403) is sent untouched, bypassing the _error boundary. Throwing an Error is a fault - it renders the nearest _error boundary (or a 500 if there is none). Never throw an Error to signal a 404.

Meta & head rendering rules

A route's <head> is its layout chain's head merged with the page's - so a _layout.tsx can export meta to put sitewide tags (hreflang, preconnect, a section <title>) on every page below it.

TS
// routes/_layout.tsx - a layout can export `meta` too. Its tags are SITEWIDE: they land in
// the <head> of every page below it - the home for hreflang / preconnect / a section <title>.
export const meta = {
  link: [
    { rel: "preconnect", href: "https://cdn.example.com", crossorigin: "anonymous" },
    { rel: "alternate", hreflang: "es", href: "https://example.com/es" },
  ],
  title: "Docs",   // section default - a child page's title overrides it
}

// The route's final <head> = its LAYOUT CHAIN's meta merged with the page's:
//   • title (and other scalars): NEAREST-WINS - page overrides inner layout overrides outer.
//     An undefined page title keeps the layout's.
//   • meta / link arrays: CONCATENATED outermost-layout → … → page (so the page's canonical
//     comes after the layout's links).
// <link> attributes are typed (LinkDescriptor: a partial like { rel, href, hreflang } is assignable),
// name-validated (any letter/digit/hyphen name) and value-escaped against XSS - so
// rel/href/hreflang/crossorigin/media/sizes/as/integrity/fetchpriority/… all survive. A boolean
// attribute (e.g. `disabled: true`) renders bare; `false`/`undefined` is omitted.

The merge is nearest-wins for scalars (the page's title beats an inner layout's, which beats an outer one) and concatenated for the meta / link arrays (outermost layout first, page last). A link entry is a typed LinkDescriptor - a partial like { rel, href, hreflang } is assignable, custom / data-* attrs pass through, and every <link> attribute with a normal name - rel, href, hreflang, crossorigin, media, sizes, type, as, integrity, referrerpolicy, fetchpriority, imagesrcset - survives into the SSR'd tag; values are HTML-escaped against XSS.

SEO: Open Graph, Twitter cards & JSON-LD

A route's meta emits the full social / structured-data head with three public helpers - canonical(), openGraph(), and jsonLd() - and builds absolute URLs from the origin argument. Because meta() runs in both SSR and client navigation it has no server env: never read process.env or the request inside it. The framework injects origin (the site's scheme + host, e.g. https://news.example.com) from the request on the server and from location.origin on the client, so the two match and an absolute og:url / canonical / og:image never drifts between the server-rendered <head> and a soft-nav. Anything else server-only (an API base, a CDN host) belongs in the loader, not meta().

TS
// routes/articles/[slug].tsx - a complete SEO head: canonical + Open Graph + Twitter + JSON-LD,
// with ABSOLUTE URLs built from `origin`. The three helpers (canonical/openGraph/jsonLd) are public
// exports of @nifrajs/web; `origin` is the third MetaArgs field (after data + params).
import { canonical, jsonLd, openGraph, type MetaArgs } from "@nifrajs/web"

// CAVEAT: meta() runs in BOTH SSR and client navigation, so it has NO server env - never read
// `process.env` or the request here. The framework injects `origin` (the site's scheme+host, e.g.
// "https://news.example.com") server-side from the request AND on the client from location.origin, so
// the two match and an absolute og:url/canonical/og:image never drifts between SSR and a soft-nav. For
// anything else server-only (an API base, a CDN host), thread it through the LOADER, not meta().
export const meta = ({ data, params, origin }: MetaArgs) => {
  const article = data as { title: string; summary: string; image: string; published: string }
  const url = `${origin}/articles/${params.slug}`        // absolute, from the injected origin
  const image = `${origin}${article.image}`              // og:image MUST be absolute for crawlers
  return {
    title: article.title,
    meta: [
      { name: "description", content: article.summary },
      // og:* - openGraph emits only the props you pass (+ og:type, here "article").
      ...openGraph({
        title: article.title,
        description: article.summary,
        url,
        image,
        type: "article",
      }),
      // twitter:* - a large-image summary card sharing the same absolute image.
      { name: "twitter:card", content: "summary_large_image" },
      { name: "twitter:title", content: article.title },
      { name: "twitter:description", content: article.summary },
      { name: "twitter:image", content: image },
    ],
    link: [canonical(url)],                              // <link rel="canonical"> - the authoritative URL
    // JSON-LD structured data - escaped for safe <script> embedding by the head renderer.
    script: [
      jsonLd({
        "@context": "https://schema.org",
        "@type": "NewsArticle",
        headline: article.title,
        image,
        datePublished: article.published,
        mainEntityOfPage: url,
      }),
    ],
  }
}
[!NOTE] og:image (and the Twitter image) must be absolute for crawlers - build it from origin. openGraph() emits only the properties you pass (plus og:type); jsonLd()'s payload is escaped for safe <script> embedding by the head renderer.

Inert scripts, and the escape hatch

head.script takes data only - application/ld+json or application/json. That is not a style rule. The head renderer escapes content against closing the element early (</script>, <!--, ]]>), which is exactly what inert JSON needs and is no protection at all for code. A route interpolating loader data into an executable body had escaping that looked like a boundary and was not one, so the slot now only accepts what it can actually make safe. A wrong type is a compile error; it also throws at render, for callers the types do not reach.

Executable inline code goes through unsafeInlineScript(), which is named after what it is and requires a CSP nonce. Pass the same nonce to renderPage and it reaches every framework-owned script in the document, so a strict script-src 'nonce-…' policy is achievable rather than aspirational:

TS
import { renderPage, unsafeInlineScript } from "@nifrajs/web"

declare const adapter: import("@nifrajs/web").RenderAdapter
declare const nonce: string // one per response, from your CSP middleware

const page = renderPage({
  adapter,
  chain: [null],
  data: null,
  clientEntry: "/assets/client.js",
  nonce, // reaches the hydration bootstrap, the data script and every island tag
  head: {
    script: [{ content: JSON.stringify({ "@type": "Article" }) }], // inert: JSON only
    unsafeScript: [unsafeInlineScript("window.dataLayer = []", { nonce })],
  },
})

The client ↔ server boundary

Loaders, actions, and head resolution run on the server. The client receives the loader's return value (serialized into the document), never the loader, the api, or env. Keep server-only imports out of a route file's top level.

TS
// What runs WHERE:
//
//   SERVER (Bun / edge)            CLIENT (browser)
//   ─────────────────────          ──────────────────────────
//   loader / action                event handlers, useState/effects
//   meta / head resolution         soft-nav re-fetch of loader data
//   SSR of the layout chain        hydration + Fast Refresh (dev)
//   backend (api, env, draft)      -
//
// The client gets the loader's RETURN VALUE (serialized into the document), never the loader
// itself, the api, or env. So:
//  1. Never import server-only modules (Bun, a DB client, secrets) at the top level of a route
//     file - the bundler pulls it into the client chunk and it crashes / leaks. (The build now
//     FAILS with a named error if a `node:` built-in reaches a client chunk - see below.)
//  2. A client soft-nav re-runs the loader over the network (X-Nifra-Data) and re-merges the
//     same layout-chain head, so sitewide tags persist across navigation - no page-only flash.

The build-output contract

The programmatic buildClient() API emits a content-hashed directory plus a manifest.json you can hand to createWebApp (entry, routes for per-route preload, css + routeStyles for <link> injection).

TS
// buildClient() writes a browser bundle + manifest.json for custom build pipelines:
{
  "entry": "/assets/_nifra-entry-HASH.js",        // the bootstrap module script
  "assets": ["/assets/…"],                          // every emitted chunk URL
  "routes": { "users/[id]": ["/assets/_layout-H.js", "/assets/_slug_-H.js"] },
  "css": ["/assets/app-H.css"],                     // aggregate stylesheets (fallback)
  "routeStyles": { "users/[id]": ["/assets/_layout-H.css"] }  // per-route CSS (chain only)
}

// Invariants worth knowing:
//  • Chunk names are URL-PATH-SAFE. A dynamic-route file [slug].tsx would emit `[slug]-H.js`,
//    whose [ ] make a static server 400 the request. The build renames it (`[slug]` → `_slug_`)
//    and rewrites every reference, so the lazy import resolves and the route hydrates.
//  • `process.env` is compiled away. `process.env` → `({})` and `process.env.NODE_ENV` → the
//    build mode; every other `process.env.X` becomes undefined - EXCEPT names you opt in with the
//    PUBLIC_ prefix (Vite/Next convention), which are baked in with their value (see below). No
//    `process is not defined` crash, and no unprefixed (secret) env leaking into the client bundle.
//  • A `node:` built-in in the CLIENT bundle FAILS the build with a named error (move it behind a
//    loader/action - server-only). It builds via a browser polyfill otherwise, then breaks at runtime.
//  • Assets are content-hashed + immutable. Serve /assets/* with a long-lived cache header.

Public env in the client bundle

process.env is compiled away in the browser bundle, so a bare read can't crash hydration and an unprefixed (secret) var resolves to undefined - it never reaches the client. To expose a value, give it the PUBLIC_ prefix (the Vite / Next convention) in the build environment and it's baked in by value. The prefix is overridable via buildClient's publicEnvPrefix ("" disables it).

TS
// process.env in the CLIENT bundle is compiled at build time:
//   process.env             → ({})          (a bare read won't crash hydration)
//   process.env.NODE_ENV    → "production"  (the build mode - frameworks' prod/dev branch)
//   process.env.SECRET_KEY  → undefined     (unprefixed → never exposed; no secret leak)
//   process.env.PUBLIC_API_URL → "https://api.example.com"  (PUBLIC_-prefixed → baked in by value)
//
// So to ship a value to the browser, give it a PUBLIC_ prefix in the BUILD environment:
//   PUBLIC_API_URL=https://api.example.com  bun run build
// then read process.env.PUBLIC_API_URL in app code - it becomes a string literal after the build.
import { buildClient } from "@nifrajs/web/build"

// Override the prefix (or disable auto-exposure) via buildClient:
await buildClient({
  routesDir: "./routes",
  outDir: "./dist",
  clientModule: "@nifrajs/web-react/client",
  publicEnvPrefix: "NIFRA_PUBLIC_", // default "PUBLIC_"; "" disables auto-exposure entirely
})

Static emit (prerender)

Opt a static route into SSG with export const prerender = true (or a dynamic route with export const getStaticPaths), then call prerenderRoutes - a public export of @nifrajs/web/build. It drives the app's own fetch to write index.html + _data.json per route; cloudflarePagesRoutes emits the _routes.json for a hybrid CDN + worker deploy. No need to boot a server and curl it.

TS
// build.ts - emit static HTML for opted-in routes. prerenderRoutes + cloudflarePagesRoutes
// are PUBLIC (exported from @nifrajs/web/build) - no need to boot a server and curl it.
import { buildClient, prerenderRoutes, cloudflarePagesRoutes } from "@nifrajs/web/build"
import { discoverRoutes } from "@nifrajs/web/fs"

// 1. Build the client bundle first (so the app references the hashed entry).
await buildClient({ routesDir: "./routes", outDir: "./dist", clientModule: "@nifrajs/web-react/client" })

// 2. Drive the app's own fetch to render each opted-in route → dist/<path>/index.html + _data.json.
//    Static routes opt in with `export const prerender = true`; dynamic routes enumerate concrete
//    params with `export const getStaticPaths` (+ a `fallback: "ssr" | "404"`).
const { app } = await import("./server")
const { prerendered } = await prerenderRoutes({
  app,                                       // the built createWebApp (a { fetch } is enough)
  routes: discoverRoutes("./routes").routes,
  outDir: "./dist",
})

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

Publish a reusable router (library code vs app code)

The fluent server().get(...).post(...) builder is app code: each call both infers the handler's context from the path and returns a Server whose type has one more route intersected in (AddRoute / RouteInfoFor). That accreting registry type lives happily inside your own program, but it does not cleanly survive TypeScript's .d.ts declaration emit - for a router you publish as a package, the compiler has to serialize the whole inferred chain into your .d.ts and chokes (a TS2883-class error: the inferred type is too large to name), which forces ugly casts on the boundary. So a distributable router is written contract-first instead.

The recipe: declare the API as a plain defineContract({…}) object and export it (it is the consumer's typed-client source), then ship a factory that returns implement(contract, handlers). The consumer mounts the router with one .merge(...) and derives its client straight from the exported contract value - client(contract, url), no typeof app import crossing the package boundary. Pass a preamble as the third arg - implement(contract, handlers, server().use(auth)) - to bake a middleware / derive chain into the router's routes (captured at registration).

TS
// A PUBLISHABLE router - contract-first, so its types survive `.d.ts` emit for consumers.
import { client } from "@nifrajs/client"
import { defineContract, implement } from "@nifrajs/core/contract"
import { server } from "@nifrajs/core/server"
import { t } from "@nifrajs/schema"

// --- in the package (say @your-org/billing) ---
// 1. The CONTRACT is ONE plain declared object - methods, paths, schemas, no handlers.
//    Export it: it is the single type the package publishes, and a consumer's typed client is
//    built from THIS value (not from `typeof app`), so no internal server type crosses the seam.
export const billingContract = defineContract({
  getPlan:   { method: "GET",  path: "/billing/plan" },
  subscribe: { method: "POST", path: "/billing/subscribe", body: t.object({ plan: t.string() }) },
})

// 2. A FACTORY closes over the host's dependencies and returns a real, mountable router.
//    `implement` binds handlers to the contract; each handler's context is typed from the contract
//    alone (`c.body` is { plan: string } below) - no per-route `AddRoute` inference accreting.
export interface BillingDeps {
  charge(plan: string): Promise<{ id: string }>
}
export function createBillingRouter(deps: BillingDeps) {
  return implement(billingContract, {
    getPlan:   () => ({ plan: "pro" }),
    subscribe: (c) => deps.charge(c.body.plan),
  })
}

// --- in the consuming app ---
// 3. Mount the router with ONE `.merge()` - no per-route `.get()/.post()` chain to re-infer.
const app = server().merge(createBillingRouter({ charge: async (plan) => ({ id: plan }) }))

// 4. Build the client from the EXPORTED contract value - decoupled, no `typeof app` import needed.
const api = client(billingContract, "https://api.example.com")
const { data } = await api.billing.plan.get()          // GET  /billing/plan
// "subscribe" is a reserved client word (the SSE subscriber), so this segment is reached by
// CALLING the parent node with the segment name - same request, same typed body.
await api.billing("subscribe").post({ plan: "pro" })   // POST /billing/subscribe, body typed

Why this survives .d.ts emit where the fluent chain doesn't: the contract is a plain declared object type (ContractShape), fixed up front - there is no grow-the-registry-per-call inference to serialize, so its declaration is small and stable. The consumer still gets full end-to-end types both ways - .merge() threads the router's routes into the host app's registry, and client(contract, url) types every call from the same exported object.

See SSG & ISR for prerendering, Dev & HMR for the two dev loops and the --port flag, and Loaders & actions for the data layer in depth.