Docs

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.

may not ship to a browser - backend code reached a browser build

Every browser build (Bun and Vite, production and dev) refuses code that belongs to the server: anything under backend/, a route's x.backend.ts half, a node: or bun: built-in, a server package (a database driver, a server SDK), and any module that imports @nifrajs/web/backend-only. The message names the module, why it may not ship, and the import chain that reached it:

[nifra/web] the browser build reached code that may not ship to a browser:
- backend/db.ts: it is backend code
via routes/notes.tsx → backend/db.ts

Read the chain left to right: the page routes/notes.tsx imports backend/db.ts. Fix it by where the code runs:

  • Data for the page comes from the route's backend half: a loader in routes/notes.backend.ts, with the output schema that declares what the browser may see.
  • A call from the browser goes through a *.fn.ts server function, which the browser build replaces with a fetch stub.
  • Code both sides need (types, formatting, validation) moves to shared/, which may import only shared code.
TS
// doc-check: skip - illustrative three-file layout (relative cross-file imports).
// backend/db.ts - backend code: no browser build may load it.
import { Database } from "bun:sqlite"
export const db = new Database("app.db")

// routes/notes.backend.ts - the route's backend half: loader, action, output schemas.
import { t } from "@nifrajs/schema"
import { db } from "../backend/db.ts"
export const loaderOutput = t.object({ notes: t.array(t.object({ title: t.string() })) })
export const loader = () => ({ notes: db.query("select title from notes").all() })

// routes/notes.tsx - the page: it renders the loader's data and imports nothing from backend/.
export default function Notes({ data }) {
  return data.notes.map((note) => <p>{note.title}</p>)
}

Server code whose location does not say so - say, a workspace package both sides import - opts out of every browser build with the marker import:

TS
// packages/billing/src/keys.ts - a workspace package both sides import, holding one server value.
import "@nifrajs/web/backend-only"             // ← a browser build that reaches this module fails
import type { BackendOnly } from "@nifrajs/web" // ← type-level intent: this value stays on the server

// `BackendOnly<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: BackendOnly<string> = process.env.SECRET_API_KEY!

[!TIP]
Run nifra check (or nifra check --json for agents) to catch this before the build: it reports the same chain from source. An app still on *.server.ts files and @nifrajs/web/server-only moves over with nifra migrate layout.

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. Under @nifrajs/web-react the render fails with [nifra/web-react] a component called a React hook with no dispatcher instead, with the engine's original message kept in parentheses and as the error's cause.

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:- or link:-linked package (a local component library you bun link or reference with link:../lib) that bundles its own React in its own node_modules. Fix the install first, if you can:

  • 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.json overrides (Nifra's own repo pins react / react-dom this way) so every package resolves the same copy.
  • Delete the linked package's nested node_modules/react after linking if your package manager duplicated it.

When the duplicate can't be installed away: declare it single-copy

A link: dependency on a separate checkout is the case none of the above reaches. The linked package's files live in the other repo, so Node and Bun resolve their imports from that repo's real path - and that repo's install owns its node_modules. Peer deps don't help (the copy is already installed there), overrides don't help (they govern your install, not theirs), and deleting the nested copy is undone by the next install in the sibling repo. Only a resolver can answer this: the walk has to be intercepted, not rearranged.

Declare the packages in your app's package.json. It is a static declaration on purpose - nifra check reads it without importing your config, so the preflight never executes app code:

JSON
{
  "name": "my-app",
  "dependencies": {
    "react": "19.2.8",
    "@example/ui": "link:../design-system/packages/ui"
  },
  "nifra": {
    "singleCopy": ["react", "react-dom", "@nifrajs/*"]
  }
}

"singleCopy": true is shorthand for Nifra's built-in identity-sensitive set: @nifrajs/*, react, react-dom, preact, solid-js, svelte, vue. Entries may be exact names or a @scope/* pattern. @nifrajs/* belongs in this set for the same reason React does: two copies of @nifrajs/core are two distinct Server classes with separate request state. The second copy prints [nifra] @nifrajs/core is loaded 2 times with the path of each as it loads, and .merge() refuses a server built against the other copy with merge() requires a server() from this copy of @nifrajs/core. A mount() crosses only the fetch boundary, so it keeps working across copies.

The build honours the declaration on its own - buildClient and buildServer inject the resolver, so bundled output already loads one copy, and so does the browser bundle nifra dev serves on Bun. Listing singleCopyPlugin() in clientPlugins as well is harmless: dev runs that instance in the resolver's place instead of a second one. On Vite, the dev server and both Vite builds add each declared package to resolve.dedupe, a @scope/* pattern as every package installed under that scope. Unbundled phases do not: Bun's runtime resolver never offers a bare specifier to a plugin, so bun test, bun run, and preloaded scripts still load both copies unless you preload the registrar:

TOML
# bunfig.toml
preload = ["@nifrajs/core/single-copy/register"]

[test]
preload = ["@nifrajs/core/single-copy/register"]

Both sections matter, and they are independent: preload covers bun run, [test].preload covers bun test. Declaring without preloading is a real state, not a mistake - nifra check reports it and names the phase left uncovered.

What it will not do: redirect across versions. If the two copies are 19.2.7 and 19.2.8, the redirect is skipped and nifra check still fails with version-skew - for every declared package, not only the built-in set. Silently collapsing a version difference would trade a loud install problem for a quiet behavioural one; align the ranges instead.

The preloaded registrar never skips quietly either. A declared package it cannot collapse - a version skew, or a linked file with no counterpart in your copy - prints one warning per package naming both copies and both versions. To make that a startup failure, use the object form "singleCopy": { "packages": [...], "strict": true } or set NIFRA_SINGLE_COPY_STRICT=1.

A declared duplicate is reported, not suppressed. nifra check keeps printing the copies as a warning and nifra doctor lists them under "deduplicated by declaration", so the topology stays visible - it just stops being fatal.

A copy someone else planted is named. When an importer reaches a copy through a symlink that points outside its own install - another project's node_modules linked into a shared package, or a bun link - nifra doctor prints a links: line with the link and its target, and nifra check puts it ahead of both fixes. Removing that link and reinstalling there is usually the whole fix. Package-manager store links inside an install (.bun/, .pnpm/) are never reported.

[!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; a file:-linked package shipping its own copy is the thing to fix. See Dev & HMR and the file:-linked-package note in AGENTS.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's data infers never (Nifra can't see the shape). Return a plain object and shape the response with c.set - reach for c.json / c.text only for an error short-circuit (throw from a derive / 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() return Server<any, any> and the client loses your registry. Build it with defineRouterPlugin(name, …) (the clearer-named defineIdentityPlugin) 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 internal AddRoute. 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.

TS
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.

TS
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 a withX<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 from typeof app is exactly as precise after a .merge() or an implement() as it is for an inline route. Splitting or going contract-first costs you nothing at the call site.

A hook read the body, then the route answers 500 - "c.req.body was read as a stream"

c.req.body is the request's one-shot stream. A derive or beforeHandle that reads it - an HMAC over the raw bytes of a webhook, say - consumes it, and nothing after the hook can read the body again: the route's body schema, c.boundedJson() and the handler's c.req.json() fail with this error, which the server logs. Read the body in a hook with c.req.bytes(), c.req.text(), c.req.arrayBuffer() or c.req.json() instead: they read it once, within the route's body limit, and every reader after the hook replays the same bytes.

"mixed nifra versions in this workspace"

Every @nifrajs/* package, nifra and create-nifra ship together at one version. nifra doctor (and nifra check, as the NF-D002 advisory) reads the versions your package.json files pin, the versions installed in node_modules - followed to their real location, so a linked repository reports its own - and the running CLI. When they disagree it names each version and where it came from. It only reads files, and it never fails the run.

The fix is nifra upgrade <highest>, not a hand edit of the pins: it runs every release's migration steps between your oldest pin and the target, then re-pins. Run it without --write first to see each planned edit. It edits files under the workspace root only. A symlinked package directory or package.json is never followed, and a group package installed by a link from outside the root is listed under "Left alone" - upgrade that repository on its own.

A file: dependency's own dependencies are recorded in bun.lock when Bun first installs it, and Bun does not read that package's package.json again: after the linked package moves to a new version of a dependency, bun install (even with --force) keeps installing the recorded one beside it, so the app can load two copies of the same package. nifra doctor (and nifra check, as the NF-D003 warning) compares each recorded entry with the linked package.json and names every dependency that moved.

Run bun update <package> for each named package. nifra upgrade reads the lock against the manifests it is about to write, and with --write drops those entries so the next bun install records them again; run that install before committing bun.lock, or bun install --frozen-lockfile fails.

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.