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 fields, always - params, request, api, env, draft, and set.
// 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)
set: LoaderResponseControls // response headers + cookies for this page request (see below)
}
// api + env are typed per route by the generated Route.LoaderArgs (./+types/<name>); the agnostic
// core keeps them `unknown`. Branch on `draft` to load unpublished content for preview/editors.Loader, action & meta signatures
A route is two files. The page (x.tsx) holds the view and its head: default (required) and meta. Its backend half (x.backend.ts) holds everything that runs only on the server: loader + loaderOutput, action + actionOutput, hydrate, prerender, revalidate. A loader runs on GET and feeds props.data; an action runs on POST and feeds props.actionData. A server-only export in the page file is a build error - see Project structure.
// routes/users/[id].tsx - the page: it ships to the browser whole. Only `default` is required.
// doc-check: skip - full-contract sketch across two files; the data shape comes from the reader's
// own backend, so it can't compile standalone (see the typed examples elsewhere).
import type { Route } from "./+types/[id]"
// Static OR a function of { data, pageData, params, origin }. Static meta is serialized once; function
// meta recomputes per request (its content can vary with loader data). `data` is the exporting
// module's own loader data: a layout's meta gets its layout's (null without a loader) and reads the
// page's as `pageData`. `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 }: Route.MetaArgs) => ({
title: `User ${data.name}`,
meta: [{ name: "description", content: data.bio }],
link: [{ rel: "canonical", href: `${origin}/users/${params.id}` }],
})
export default function User({ data, actionData, params }: Route.ComponentProps) { /* … */ }
// routes/users/[id].backend.ts - the server half: it never reaches the browser.
import { t } from "@nifrajs/schema"
// GET → data for the page. Runs in-process on the server during SSR (no HTTP hop). Only what
// loaderOutput declares reaches the page as `data`.
export const loaderOutput = t.object({ name: t.string(), bio: t.string() })
export async function loader({ api, params }: Route.LoaderArgs) {
const res = await api.users({ id: params.id }).get()
return { name: res.data.name, bio: res.data.bio }
}
// POST → a mutation. Same context. Return a Response (e.g. a redirect - passed straight
// through) OR data, which reaches the page as `actionData`, bounded by actionOutput.
export const actionOutput = t.object({ ok: t.boolean() })
export async function action({ request }: Route.ActionArgs) {
const form = await request.formData()
return { ok: true }
}
export const hydrate = false // opt out of full-document hydration (static / island pages)[!NOTE] Anactionmay return a control-flow value - aredirect()or astatus(...)render, or a hand-rolledResponse- and it's passed through untouched. Any other return is serialized asactionData.
Response headers & cookies
ctx.set is the page counterpart of a route handler's c.set: a loader or action writes response headers on ctx.set.headers and queues cookies with ctx.set.cookie() / ctx.set.deleteCookie().
// routes/account.backend.ts - response headers and cookies, from a loader or an action.
import { t } from "@nifrajs/schema"
import { type LoaderContext, redirect } from "@nifrajs/web"
declare function signIn(email: string, password: string): Promise<{ token: string } | null>
export const loaderOutput = t.object({ plan: t.string() })
export const actionOutput = t.object({ error: t.string() })
export async function loader(ctx: LoaderContext) {
// Headers land on the rendered document.
ctx.set.headers["cache-control"] = "public, max-age=60"
ctx.set.headers["x-robots-tag"] = "noindex"
return { plan: "pro" }
}
export async function action(ctx: LoaderContext) {
const form = await ctx.request.formData()
const session = await signIn(String(form.get("email")), String(form.get("password")))
if (session === null) return { error: "Wrong email or password" }
// HttpOnly; Secure; SameSite=Lax; Path=/ unless the options say otherwise.
ctx.set.cookie("session", session.token, { maxAge: 60 * 60 * 24 * 7 })
throw redirect("/dashboard") // the cookie rides the redirect
}| Outcome | Headers | Cookies |
|---|---|---|
| Rendered document | applied | sent |
| Navigation data response (a client soft-nav) | not applied | sent |
redirect(), a status page, an _error page | not applied | sent |
- Merge order. Each loader writes its own header record. They are merged layouts root to leaf, then the page loader, then the action, so the most specific writer wins a name - whichever loader settles first.
- Refused names.
content-type,set-cookie,location, the transport headers (content-length,transfer-encoding,connection, ...), and thex-nifra-prefix throw. So does a name that is not an HTTP token and a value with a line break, a control character, or a character outside Latin-1. The request then answers through the_errorboundary; the error names the header, never its value. - A cookie makes the response private. Queueing a cookie through
ctx.setforcescache-control: private, no-storeon whatever carries it - the document, a redirect, a status or error page, a hand-builtResponse- and the page is not offered towithISR, so aSet-Cookienever reaches a shared cache. A cookie queued by a middleware on the serving app is outsidectx.set, so set the cache policy for those routes yourself (withISRstill refuses to store any response carryingSet-Cookie). - Navigation data responses are never stored. A soft-nav fetches the same URL as the document with an
x-nifra-datarequest header, so its response is alwayscache-control: private, no-storewithvary: x-nifra-data, and a document that carries loader headers getsx-nifra-dataadded to itsvary. A CDN that ignoresVaryneedsx-nifra-datain its cache key when it caches documents. - Write before you return. The controls close when the loader or action settles. A write from a deferred promise that resolves later throws instead of being dropped.
- ISR.
withISRreplays a stored page'sx-robots-tag,link, and the other headers on its allowlist; a loader'scache-control: no-storeor avaryon a real request header keeps the page out of the store. - Prerender. A prerendered page is a static file: it is served without the loader's headers or cookies. Set those on the host that serves the file, or keep the route on-demand. A
bunornodebuild serves the file itself, afterusemiddleware, with the page'scontent-languageand CSP.
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.
// TWO ways to handle a request - pick by who is calling.
import { t } from "@nifrajs/schema"
import type { LoaderContext } from "@nifrajs/web"
// (1) A page's own data + mutations → its backend half's exports. A PUBLIC POST is a route ACTION
// (routes/contact.backend.ts) - the browser POSTs the route's own URL, Nifra runs `action`:
export const actionOutput = t.object({ ok: t.boolean(), body: t.string() })
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 const loaderOutput = t.object({ id: t.string() })
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:
// routes/users/[id].backend.ts - 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 { t } from "@nifrajs/schema"
import type { LoaderContext } from "@nifrajs/web"
declare function lookupUser(id: string): Promise<{ id: string } | null>
export const loaderOutput = t.object({ user: t.object({ id: t.string() }) })
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.
// 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. Import them from @nifrajs/web/seo: the entry imports nothing at runtime, so a page's browser bundle gets the three helpers and none of the server modules the package root also exports. 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().
// 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) come from
// @nifrajs/web/seo, which imports nothing at runtime, so the page's browser bundle takes only them;
// `origin` is a MetaArgs field beside data, pageData and params.
import type { MetaArgs } from "@nifrajs/web"
import { canonical, jsonLd, openGraph } from "@nifrajs/web/seo"
// 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 fromorigin.openGraph()emits only the properties you pass (plusog: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. The code is emitted as written; one that contains </script or <!-- is refused, since either would end or escape the element. 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:
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, streamed scripts and every island tag
head: {
script: [{ content: JSON.stringify({ "@type": "Article" }) }], // inert: JSON only
unsafeScript: [unsafeInlineScript("window.dataLayer = []", { nonce })],
},
})A strict CSP that still caches
A nonce makes every document unique, so a nonce-bearing page is private, no-store and no shared cache - withISR, a CDN - may store it. Page data never needs one: it rides in an inert <script type="application/json"> the browser does not execute. To cache pages under a strict policy, give createWebApp a csp instead of a nonce. A page then carries a nonce only when it has a script specific to this request; the rest are allowed by hash, and nifraScriptHashes(adapter) returns the same list for a policy set at a proxy:
import { createCspPolicy, createWebApp, MemoryCacheStore, withISR } from "@nifrajs/web"
declare const adapter: import("@nifrajs/web").RenderAdapter
declare const manifest: import("@nifrajs/web").Manifest
const csp = createCspPolicy({
// `sources`: a sha256 hash per constant inline script nifra writes, plus 'nonce-…' only on a
// page that needs one - it defer()s, or its meta names the nonce in unsafeInlineScript.
header: ({ sources }) => `default-src 'self'; script-src 'self' ${sources}; object-src 'none'`,
})
const app = createWebApp({ adapter, manifest, clientEntry: "/assets/client.js", csp })
// Every other page is nonce-free, sends the same CSP header each time, and caches.
export const handler = withISR(app, {
store: new MemoryCacheStore(),
revalidate: 60,
now: () => Date.now(),
})The client ↔ server boundary
Loaders, actions, and head resolution run on the server. The client receives the loader's output - its return value projected through loaderOutput - never the loader, the api, or env. Server code lives in a route's .backend.ts half and in backend/, and the build refuses a frontend file that reaches it.
// 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 OUTPUT - its return value projected through loaderOutput and
// serialized into the document - never the loader itself, the api, or env. So:
// 1. Server code lives in a route's x.backend.ts and in backend/. The page file, frontend/ and
// shared/ ship to the browser, and the build FAILS with the import chain if they reach backend
// code, a database driver or a `node:` built-in (see /docs/structure).
// 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.Optional server components
The post-4.0 server-components pipeline supports React, Vue, Svelte and Solid. Declare closed input/public-props schemas and authorization in *.component.server.ts(x), register owner instances and export a namedcomponentPipeline configuration. React uses native RSC/Flight; the other three hydrate native islands in validated server ranges. Refresh repeats owner GET admission; HTML/data/refresh are private/no-store. Writes use server functions or actions. Bind the router and await auth-scope invalidation on identity, tenant or permission changes.
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, copyChunks when the app has copy tables).
// 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)
"copyChunks": { "hi": "/assets/copy-hi-H.js" } // copy tables by locale (@nifrajs/i18n/copy)
}
// 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 into
// backend code - a route's .backend.ts or backend/). A browser polyfill would build, then break.
// • 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).
// 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 in its .backend.ts (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.
// 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"`) - both in the
// route's .backend.ts.
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).
// 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 typedWhy 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.