Troubleshooting
Hit an error? Search this page for the literal message. Each section is keyed on the exact string Nifra prints, with what it means and the fix. The build, nifra check, and the runtime all use the same wording so you can grep for it.
reached the client bundle - a node: / native import leaked to the browser
The client build refuses to ship a Node built-in (node:fs, node:crypto, bun:sqlite, a native driver like pg) to the browser. Bun would silently substitute a polyfill that breaks - or leaks server code - at runtime, so Nifra fails the build instead. The message names the offending builtin and the import chain that pulled it in:
[nifra/web] Node built-in(s) in the client bundle - move them behind a server-only path
- node:crypto reached the client bundle via routes/x.tsx → ../data.ts → ../db.ts (chunk: x-abc123.js)
Read the chain right-to-left: ../db.ts imports node:crypto, and a top-level import in routes/x.tsx dragged it into the browser. Fix it one of two ways:
- Move the code into a
*.server.tsmodule (the recommended default). The client build empties*.servermodules, so theirnode:/ native imports never reach the browser. Import the server module only from a loader/action (which run on the server). - Reach the server code from a loader/action, not a route's top level. A loader runs on the server during SSR; importing the
node:module inside (or via) a loader keeps it out of the client graph.
// doc-check: skip - illustrative two-file layout (a relative cross-file import).
// db.server.ts - the `.server` convention: the client build EMPTIES this module, so its
// `node:` / native imports never ship to the browser. No marker import needed - the filename is it.
import { Database } from "bun:sqlite"
export const db = new Database("app.db")
// routes/notes.tsx - import the server module from a loader; it runs only on the server during SSR.
import type { LoaderContext } from "@nifrajs/web"
import { db } from "../db.server"
export async function loader(_ctx: LoaderContext) {
return { notes: db.query("select * from notes").all() }
}[!TIP]
Runnifra check(ornifra check --jsonfor agents) to catch this before the build: it reports the same transitive chain (routes/x → ../data → ./db → node:crypto) by walking the local module graph, so you see the leak as a lint result, not a failed build.
server-only module reached the client bundle - the server-only marker fired
This is the companion guard for pure server logic that carries no node: import - a secret-bearing constant, a server-only API call - so the node-builtin guard above has nothing to catch and the .server convention needs the file to be named *.server. You opt a module in with a side-effect import, and the client build fails loud (with the import chain) if it ever lands in a browser chunk:
[nifra/web] server-only module(s) in the client bundle - a module marked
import "@nifrajs/web/server-only"reached the browser.
- server-only module reached the client bundle via routes/x.tsx → ./secrets.ts (marked server-only)
There are three markers; reach for them by intent:
| marker | enforcement | use when |
|---|---|---|
*.server.ts filename | client build auto-empties the module | a dedicated server module (a DB client, a node: helper) you can name *.server |
import "@nifrajs/web/server-only" | client build fails loud, with the chain, if it leaks | pure server logic with no node: import to catch (a secret, a server-only call) that you can't / don't want to rename |
ServerOnly<T> type | type-level intent only - does NOT keep it out of the bundle | documenting that a value must not cross to the browser; pair it with one of the two runtime markers |
A worked example - a secret with no node: import, marked so a leak fails the build rather than shipping the key to every visitor:
// secrets.ts - pure server logic: a secret constant, no `node:` import to catch.
import "@nifrajs/web/server-only" // ← fails the CLIENT build (loud, with the import chain)
import type { ServerOnly } from "@nifrajs/web" // ← type-level intent: this value is server-only
// `ServerOnly<string>` is structurally `string` (the brand is an optional phantom field), so it
// stays assignment-compatible - it documents intent without obstructing real use.
export const apiKey: ServerOnly<string> = process.env.SECRET_API_KEY!
// If this module ever reaches a browser chunk, buildClient fails with:
// server-only module reached the client bundle via routes/x.tsx → ./secrets.ts (marked server-only)
// Reach it from a loader/action (server-only) instead, and the secret never ships to the client.Fix: reach the module from a loader/action (server-only), or rename it *.server.ts so the client build empties it. nifra check reports the same transitive chain pre-build. The ServerOnly<T> brand on its own is purely type-level (it erases at build), so always back it with the import marker or the .server filename.
resolveDispatcher / Invalid hook call - duplicate React
If SSR throws Cannot read properties of null (reading 'useState') inside resolveDispatcher, or React logs "Invalid hook call. Hooks can only be called inside the body of a function component", you almost certainly have two copies of React in one render. React's hook dispatcher is module-level global state; a second copy nulls it out and every hook throws.
Nifra dedupes React in both the production build and the Vite dev server, so the framework itself won't load two copies. The usual culprit is a file:-linked package (a local component library you bun link or reference with file:../lib) that bundles its own React in its own node_modules:
- Make React a peer dependency of the linked package (not a regular dependency), so it resolves to the app's single copy.
- Ensure one React version across the workspace - pin it in the root
package.jsonoverrides(Nifra's own repo pinsreact/react-domthis way) so every package resolves the same copy. - Delete the linked package's nested
node_modules/reactafter linking if your package manager duplicated it.
[!NOTE]
This applies to every framework with module-global render state, not just React (Preact, Vue, Solid, Svelte). Nifra dedupes the active adapter's runtime in build and dev; afile:-linked package shipping its own copy is the thing to fix. See Dev & HMR and thefile:-linked-package note inAGENTS.md.
client<typeof app> resolves to never (or data: never)
The typed client is derived from your backend's type. Two things collapse it:
- A route returns a raw
Response. That route'sdatainfersnever(Nifra can't see the shape). Return a plain object and shape the response withc.set- reach forc.json/c.textonly for an error short-circuit (throwfrom aderive/beforeHandle), not a route's happy path. See API & typed client. - A plugin widened the app's type. A plugin that registers routes/hooks but whose return type isn't the concrete server (e.g. an untyped
app => app.onResponse(…)) makes.use()returnServer<any, any>and the client loses your registry. Build it withdefineRouterPlugin(name, …)(the clearer-nameddefineIdentityPlugin) so.use()returns your server unchanged and routes added after it stay typed. See Plugins → keep types with defineRouterPlugin.
nifra check flags the raw-Response case; the plugin case surfaces as a never client at the call site.
Call site rejects { query: {…} }
If api.thing.get({ query: { … } }) errors, the route declares no query schema - its query types as never, so the client can't accept query params. The error reads out the fix; add a schema to the route: .get("/thing", { query: z.object({ page: z.string() }) }, h). Then c.query is the validated type and the client accepts a typed query.
TS2589 - "Type instantiation is excessively deep and possibly infinite" (one server() chain grew past ~95 routes)
The fluent builder's whole value - end-to-end inference, so c.params.id is typed straight from :id and the client is derived from typeof app - carries an O(N) type-instantiation cost. Each .get(path, handler) / .post(...) does two things at once: it computes the handler's context type from the path, and it returns a server whose registry is one alias level deeper than the last. Neither strains the compiler alone; the product - recomputing the handler context while re-threading an ever-larger registry at every step - exhausts TypeScript's per-expression instantiation budget. A single chain hits TS2589 at ~95-100 routes.
[!NOTE]
This is a healthy, growing app's wall, not abuse. It is inherent to any builder that infers handler context and accumulates a typed route registry (Elysia, tRPC, and Hono's typed clients cap the same way), so it is not fixable by reshaping the internalAddRoute. The fix is to use a shape that does not form the product.
Fix 1 - split into domain groups and .merge() them. Each group is its own short server() chain, so no single chain approaches the ceiling; a .merge() is one R & R2 intersection with no per-call context work, so composing groups stays cheap. A 90-route single chain is inside the ceiling; 120 routes as four merged 30-route groups typecheck with full per-route fidelity.
import { server } from "@nifrajs/core"
// Each domain is its OWN short server() chain, kept well under the ~95-route ceiling.
const users = server()
.get("/users/:id", (c) => ({ id: c.params.id }))
.post("/users", () => ({ created: true }))
const orders = server()
.get("/orders/:id", (c) => ({ id: c.params.id }))
.post("/orders", () => ({ placed: true }))
// merge() adds ONE R & R2 intersection per group - no per-call context recompute - so the whole
// app stays inside tsc's budget no matter how many groups (or routes) you compose.
export const app = server()
.get("/health", () => ({ ok: true }))
.merge(users)
.merge(orders)Fix 2 - go contract-first, and stay flat at any route count. defineContract(...) declares the entire registry as one object type upfront, and implement(contract, handlers) binds handlers to it. Nothing grows a registry per call, so there is no ceiling at all - this is the path for an API that will keep adding routes for years. Handlers stay checked against the contract exactly as inline routes are. See Contract-first.
import { defineContract, implement } from "@nifrajs/core/contract"
// One object type, declared upfront - NOT an N-deep stack of `Server<AddRoute<…>>` aliases.
const contract = defineContract({
getUser: { method: "GET", path: "/users/:id" },
listUsers: { method: "GET", path: "/users" },
createUser: { method: "POST", path: "/users" },
// ...hundreds more operations stay flat: the registry is one type, not a growing chain.
})
export const app = implement(contract, {
getUser: (c) => ({ id: c.params.id }),
listUsers: () => ({ users: [] as string[] }),
createUser: () => ({ created: true }),
})TS2345 from an unrelated .merge() - budget exhausted "at a distance"
Because the budget is per-expression and global to a compilation, a type-heavy construct elsewhere in the program can push an otherwise-fine .merge() chain over the edge. It surfaces not as TS2589 but as a TS2345 assignability error naming an uninstantiated Server<Registry, unknown> - the shape the server type collapses to when the compiler gives up mid-inference. The usual trigger is a generic higher-order function that wraps the builder (a withX<T>(app) that threads the Server type through its own type parameters); merely having that file in the program can be enough. Treat these as real constraints of an inference-first framework:
- Keep service-layer types flat and explicitly annotated - do not let inference-heavy generics thread the
Server/ registry type through your own code. - Avoid generic HOF wrappers around the builder. Wrap with a
defineRouterPlugin(identity plugin) or compose with.merge()instead of awithX<T>(app)that re-infers the whole server type. - Put an explicit
Promise<T>return annotation on async callbacks that thread framework types, so the compiler stops re-deriving the awaited type at each use.
[!TIP]
Both fixes preserve full type fidelity - the client derived fromtypeof appis exactly as precise after a.merge()or animplement()as it is for an inline route. Splitting or going contract-first costs you nothing at the call site.
Still stuck?
Run nifra check --json as the done-gate - it surfaces the import-chain leaks, typed-client drift, and raw-Response-from-a-route issues before you ship. The full machine-readable contract is at /llms-full.txt, and each package ships a tight LLM.md contract card.