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 createWebApp now auto-mounts that backend over HTTP at /api/*, 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.
// backend.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.
// routes/index.tsx - a loader calls the backend IN-PROCESS via ctx.api (no network).
import type { LoaderContext } from "@nifrajs/web"
export async function loader(ctx: LoaderContext) {
const api = ctx.api as { me: { get(): Promise<{ data: { id: string | null } }> } }
const res = await api.me.get() // in-process: full validation/middleware, no HTTP round-trip
return { me: res.data }
}The auto-mounted /api/* (the new part)
Before, inProcessClient fed ctx.api but did not serve the backend over HTTP - so a browser POST /api/sync hit the page router and 404/405'd until you hand-wrote a dispatch branch in server-bun.ts. Now createWebApp mounts it for you: a request whose pathname is exactly apiPrefix (default /api) 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).
// server.ts (prod) - createWebApp serves pages AND auto-mounts the backend at /api/*.
import { inProcessClient } from "@nifrajs/client"
import { createWebApp } from "@nifrajs/web"
import { reactAdapter } from "@nifrajs/web-react"
import { backend } from "./backend"
import { clientEntry, manifest } from "./server-manifest"
export const app = createWebApp({
adapter: reactAdapter,
manifest,
clientEntry,
api: inProcessClient(backend), // → ctx.api in loaders/actions AND auto-mounted at /api/*
// apiPrefix: "/api", // the default; pass "" to disable the HTTP mount (pages 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. Pass apiPrefix: "" to turn the mount off and keep ctx.api as a loader-only client.
[!NOTE] The mount lives increateWebApp, andnifra dev(the Vite-backed dev server) routes every request through that same app'sfetch. So the/api/*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.
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-firstimplement()) - 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 aRouteConfigErrorat 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.- the
/api/*auto-mount - a path guard, not a rewrite.createWebApphands the sameRequest(its URL and body intact) to the backend when the pathname is exactlyapiPrefixor underapiPrefix + "/". The backend's own router then matches onnew 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.
// backend.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.
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.
// A browser island / client component hits the mounted HTTP routes with the typed client.
import { client } from "@nifrajs/client"
import type { backend } from "./backend"
const api = client<typeof 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'saction(ctx), which typically callsctx.apiin-process and returnsactionData(or aredirect). 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.