Docs

Backends & API (dev and prod)

Your @nifrajs/core backend reaches a Nifra app two ways from a single wiring. inProcessClient(backend) is fed to every loader and action as ctx.api - and, given an apiPrefix, createWebApp mounts that backend over HTTP there, so the browser can call the very same routes. No hand-written if (pathname.startsWith("/api/")) branch in your server entry.

One backend, two call paths

Write the backend once. It defines its routes at the full /api/… path (the mount does no path stripping). Loaders call it in-process during SSR; the browser calls itover HTTP. Both run the identical lifecycle - validation, middleware, contracts.

TS
// backend/app.ts - a normal @nifrajs/core server. Routes live at the full /api/... path.
import { server } from "@nifrajs/core/server"
import { t } from "@nifrajs/schema"

export const backend = server()
  .post("/api/sync", { body: t.object({ cursor: t.string() }) }, async (c) => {
    // validated body; runs the same whether called in-process (a loader) or over HTTP (a client fetch)
    return { applied: 12, nextCursor: c.body.cursor }
  })
  .get("/api/me", (c) => ({ id: c.cookies.session ?? null }))

ctx.api - the in-process loader client

Pass inProcessClient(backend) as createWebApp's api. Inside a loader or action, ctx.api is that typed client, and a call goes straight to the backend's fetch in-process - no network hop, no port, the full real lifecycle. This is the SSR data path; it never touches the HTTP mount.

TS
// routes/index.backend.ts - the loader calls the backend IN-PROCESS via api (no network).
import { t } from "@nifrajs/schema"
import type { Route } from "./+types/index"

export const loaderOutput = t.object({ me: t.object({ id: t.union([t.string(), t.null()]) }) })
export async function loader({ api }: Route.LoaderArgs) {
  const res = await api.api.me.get() // in-process: full validation/middleware, no HTTP round-trip
  return { me: res.data }
}

The mounted /api/*

Without an apiPrefix, inProcessClient feeds ctx.api but the backend is not served over HTTP, so a browser POST /api/sync reaches the page router. Set apiPrefix: "/api" and createWebApp mounts it: a request whose pathname is exactly apiPrefix or starts with apiPrefix + "/" is dispatched through Nifra's platform-aware backend mount interface before page routing. The backend receives the same Workers env bindings and waitUntil lifetime as the web app, and its Response is returned untouched (the request body is passed through, never pre-read).

TS
// backend/server.ts - a hand-written server: createWebApp serves pages AND mounts the
// backend at /api/*. (`nifra build` emits this wiring for you; write it only for a custom host.)
import { inProcessClient } from "@nifrajs/client"
import { createWebApp } from "@nifrajs/web"
import { reactAdapter } from "@nifrajs/web-react"
import { backend } from "./app"
import { clientEntry, manifest } from "../server-manifest"

export const app = createWebApp({
  adapter: reactAdapter,
  manifest,
  clientEntry,
  api: inProcessClient(backend), // → ctx.api in loaders/actions
  apiPrefix: "/api", // AND served over HTTP at /api/*; without it the backend is loader-only
})

// Bun: Bun.serve({ fetch: app.fetch }). No `if (pathname.startsWith("/api/")) …` branch needed -
// POST /api/sync, GET /api/me, etc. are dispatched to the backend BEFORE the page router sees them.

The dispatch runs in createWebApp's request lifecycle, ahead of the page wildcard, for every method - so GET/POST/PUT/… all reach the backend, and an unknown /api/… path returns the backend's 404, not the page's. A sibling path that merely shares the prefix string (e.g. /apidocs) is not captured - only the /api boundary is. Leave apiPrefix unset, or set it to "", to keep ctx.api as a loader-only client.

Loader-only is the default because an app that also serves the same backend behind its own server - with that server's rate limits and guards - would otherwise get a second path to it that skips them. nifra check reports NF-C035 for a backend/framework.ts that sets no apiPrefix, so the choice is written down: nifra fix --code NF-C035 adds export const apiPrefix = "/api", which is what 4.x mounted by default, and "" keeps the backend loader-only.

With both files, nifra dev and nifra build take apiPrefix, apiStrip, mounts, csp, nonce, serverComponents, i18n, use and publicEnvPrefix from backend/framework.ts when nifra.config.ts does not set them, so the server entry and the dev server read one value without a re-export. A hand-written createWebApp call passes apiPrefix itself; import it from backend/framework.ts so production serves the backend where nifra dev does. nifra check reports NF-C035 at a call that mounts a backend with no apiPrefix, or with a different one. When another server already serves the backend there, behind middleware of its own, an apiPrefix on the call would add a second path to it that skips that middleware: mark the call // nifra-expect NF-C035: <reason> instead.

Because the backend answers first and its 404 is final, a page file under the prefix (routes/api/report.tsx) could never render. Nifra refuses it instead of serving a silent 404: createWebApp throws at startup naming the file, nifra build stops, and nifra check reports NF-C027. The same holds for every other mount in front of the page router.

An app run by nifra dev and nifra build sets these options from backend/framework.ts: apiPrefix, apiStrip, mounts, csp, nonce and i18n. The generated server entry imports them from there, so export them from framework.ts (and re-export from nifra.config.ts when both exist). A mounts entry hands a path to another app; a single extra URL outside the prefix, such as a sitemap, is a plain route registered in use:

TS
// backend/framework.ts - the backend moves to /rpc, a webhook handler mounts at /hooks,
// and one extra URL outside both is a plain route on the web app.
import { reactAdapter } from "@nifrajs/web-react"

// Any app with fetch(request) mounts; a third-party handler states why nifra cannot analyze it.
const webhooks = { fetch: (_request: Request) => new Response(null, { status: 204 }) }
const sitemapXml = () => '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"/>'

export const adapter = reactAdapter
export const clientModule = "@nifrajs/web-react/client"
export const apiPrefix = "/rpc" // "" turns the backend mount off
export const mounts = [{ path: "/hooks", app: webhooks, opaque: "third-party webhook verifier" }]
export const use = (app: { get(path: string, handler: () => Response): unknown }) => {
  app.get("/sitemap.xml", () => new Response(sitemapXml(), { headers: { "content-type": "application/xml" } }))
}
[!NOTE] The mount lives in createWebApp, and nifra dev (the Vite-backed dev server) routes every request through that same app's fetch. So the mounted routes are served identically in development and production - there is nothing extra to wire for the dev loop.

Events that survive the transport

@nifrajs/events types the shape of an event - a versioned envelope validated by any Standard Schema - and says nothing about how it is delivered. A queue, SSE, a webhook or an outbox relay are all somebody else's concern, which is what lets the same contract describe an event that changes transport later.

TS
import { defineEventContract } from "@nifrajs/events"
import { t } from "@nifrajs/schema"

export const noteCreated = defineEventContract({
  type: "note.created",
  version: 1,
  payload: t.object({ id: t.string(), title: t.string() }),
})

// Producer: validates the payload and stamps a full envelope (id, occurredAt).
export const emit = (id: string, title: string) => noteCreated.create({ id, title })

// Consumer: parse never throws - an event from outside the process is untrusted input,
// so the failure is a value you have to handle rather than an exception you might not.
export function receive(input: unknown): string | undefined {
  const parsed = noteCreated.parse(input)
  return parsed.success ? parsed.envelope.payload.title : undefined
}

The half that earns its keep is parse. An event arriving from outside the process is untrusted input exactly like a request body, and a versioned contract is what stops a producer's schema change from being silently misread by an older consumer.

Mounting, composition & path semantics

A useful thing to know up front, because it is the opposite of some frameworks: in Nifra composition and mounting are prefix-less, and paths stay absolute. There is no per-mount base path, and nothing rewrites the request URL. Two mechanisms are involved and both leave the path untouched:

  • .merge() (and contract-first implement()) - the composition escape hatch for large apps. merge() folds another server's routes into this one at the exact paths they were defined, into a single shared path space. It takes no prefix argument; a path+method collision throws a RouteConfigError at merge time (fail-closed), so groups never silently shadow each other. Each domain therefore owns its full absolute paths (/api/listings, /api/agents), exactly as it would standalone.
  • .group(prefix, build) - declare routes under a static prefix. app.group("/admin", (a) => a.get("/users", list)) serves /admin/users, and the prefix is part of the typed client, routes(), OpenAPI, capability events and the effect ledger. The builder inherits this server's middleware chain as it stands at the call; what it adds stays in the group, and its onRequest/onResponse hooks run only for requests at or under the prefix. A static header the group declares replaces the parent's value of that name on the group's routes, so securityHeaders() with the group's own configuration applies over the app's there. Any other plugin the parent already applied stays the parent's: the group's use() of that same instance does nothing, and its use() of another instance under the same name (another cors() policy) throws a RouteConfigError when the group is declared, rather than being dropped. A plugin the group applies still shares the parent's copy of a plugin it use()s itself. The prefix is plain text (no params or wildcards), and a collision throws before any group route is added.
  • the /api/* auto-mount - a path guard, not a rewrite. createWebApp hands the same Request (its URL and body intact) to the backend when the pathname is exactly apiPrefix or under apiPrefix + "/". The backend's own router then matches on new URL(c.req.url).pathname - the full, original path.

The consequence for a handler: c.req.url always carries the full, original path - the same URL the caller sent. The /api prefix is not stripped before a merged or mounted route sees it, and there is no pathOf-style helper that strips it. Whether a route was defined inline, merged in from a group, or reached through the HTTP mount, the pathname it observes is the request's own.

TS
// backend/app.ts - compose domains with .merge(). Each group owns its FULL absolute paths.
import { server } from "@nifrajs/core/server"

const listings = server().get("/api/listings", (c) => {
  // c.req.url is the ORIGINAL request URL - .merge() and the /api mount never strip the prefix.
  const path = new URL(c.req.url).pathname // GET /api/listings  ->  "/api/listings"
  return { path }
})

const agents = server().get("/api/agents", (c) => ({ ok: new URL(c.req.url).pathname }))

// merge() folds both groups into ONE flat path space at their defined paths; a path+method clash
// throws a RouteConfigError right here at merge time (fail-closed), never a silent shadow at runtime.
// A group holding .ws() routes or mount()s is refused the same way: add those on the parent.
export const backend = server().merge(listings).merge(agents)

The one gotcha: because nothing strips the prefix, a backend that will be mounted (or merged) under /api must declare its routes at the full path - server().post("/api/sync", …), not .post("/sync", …) in the hope that /api gets peeled off. Define /sync and a browser POST /api/sync returns the backend's 404: the router looks up /api/sync and finds only /sync. apiPrefix chooses which requests are handed to the backend; it does not rewrite them, so the route paths and the caller's paths are one and the same.

Calling /api/* from the browser

With the routes mounted, a client island or component hits them with the same typed client<typeof backend> - same-origin, no separate API server.

TS
// doc-check: skip - imports your backend/app.ts (the BACKEND sample above).
// frontend/sync.ts - browser code hits the mounted HTTP routes with the typed client.
// A type-only import of the backend is allowed anywhere: it is erased before bundling.
import { client } from "@nifrajs/client"
import type { Backend } from "../backend/app"

const api = client<Backend>("") // same-origin: /api/sync is served by createWebApp's mount
export async function runSync(cursor: string) {
  const { data, error } = await api.api.sync.post({ cursor })
  return error ? { applied: 0 } : data
}

Route actions vs the in-process backend (for mutations)

Two valid ways to mutate; pick by where the call originates.

  • Route action - the form/SSR path. A <form method="post"> (or the client submit) runs the route's action(ctx), which typically calls ctx.api in-process and returns actionData (or a redirect). Progressive-enhancement: works with JS off, and the loader revalidates after. Reach for this for page-driven mutations tied to a route's UI.
  • The mounted /api/* backend - the programmatic path. A browser island, a third party, a webhook, or a non-page client calls the HTTP route directly. Reach for this when the caller isn't a Nifra route's form - an RPC the page makes on an interaction, an external integration, a mobile client.

They share the backend: a route action and a browser fetch can both call POST /api/sync - one in-process, one over the mount - and get identical validation and behavior.