Docs

Upgrading from Nifra 3.x or 4.x to 5.0

Nifra 4.0 split every app into a frontend and a backend half, and 5.0 enforces the split and changes a handful of defaults. The upgrade command pins the packages and moves imports; the layout migration moves files; this guide covers what each leaves to you and how to tell a finished migration from one that needs a decision.

1. Run the executable upgrade

Shell
bunx @nifrajs/cli@5.0.0 upgrade 5.0.0          # dry run: every release step from the installed version
bunx @nifrajs/cli@5.0.0 upgrade 5.0.0 --write  # apply the pins and import moves
bun install

Run the command from the release you are moving to. It chains every release recipe between the installed version and 5.0, so a 3.x app takes the 4.0 steps (the retired @nifrajs/web/server-only import moves to @nifrajs/web/backend-only) and the 5.0 ones in one pass, and prints each release's notes. It lists every @nifra-gate-reviewed comment, which silences nothing in 5.0: after review, write // nifra-expect <code>: <reason> with the code nifra check reports there. The upgrade never writes a reason for you.

2. Move onto the frontend/backend layout (from 3.x)

Shell
nifra migrate layout            # dry run: splits, moves, rewritten imports, issues
nifra migrate layout --write    # apply it
nifra schemas --write           # draft the output schemas it lists as follow-ups
nifra check

Each route file splits into x.tsx, the page, and x.backend.ts, which holds loader, action, middleware and the other server exports. The root backend.ts and framework.ts move to backend/app.ts and backend/framework.ts, and every other module goes to frontend/, backend/ or shared/ by who imports it at runtime. A folder whose imported files all land in one zone moves whole, so a component nothing imports yet travels with its folder; a folder split between zones is reported instead.

TSX
// doc-check: skip - a route split into its two halves
// routes/notes.tsx - the page, shipped to the browser whole
import type { loader } from "./notes.backend.ts"
export default function Notes({ data }: { data: Awaited<ReturnType<typeof loader>> }) {
  return <ul>{data.notes.map((note) => <li key={note.id}>{note.title}</li>)}</ul>
}

// routes/notes.backend.ts - loader, action, middleware and the other server exports
import { t } from "@nifrajs/schema"
export const loaderOutput = t.object({
  notes: t.array(t.object({ id: t.string(), title: t.string() })),
})
export async function loader() {
  return { notes: await listNotes() }
}

To keep a folder where it is, declare it a zone before migrating. The migration leaves zoned folders in place, and builds, nifra dev and nifra check enforce them like the default folders.

JSON
{
  "nifra": {
    "zones": {
      "frontend": ["src/ui"],
      "backend": ["server"]
    }
  }
}

nifra migrate layout --zones writes those entries for you: every module the layout would move stays where it is, and package.json lists the widest folder whose files all belong to one side, or the file itself where its folder holds both. Route files are still split and the root entries still move. A path nested in another's folder overrides it, so a backend file can sit in a frontend folder without moving.

The command exits 0 when nothing needs a decision. A step with a known command, such as drafting an output schema for a backend half it did not write, is listed under next and in followUps with --json, and does not fail it; so is a moved path a module names only in comments, which is worth updating but changes nothing that runs. An issue (an import it could not place, a frontend that still uses a moved name at runtime, a moved path written in code or a string) exits 1.

5.0 refuses a route file that still exports a server name: nifra build and the server stop on it, and nifra check reports it as NF-C041. nifra fix --code NF-C041 moves the export and the code only it uses into the route's backend half.

3. Draft and review output schemas

A loader or action that returns data needs loaderOutput or actionOutput; the data is projected through it, so undeclared keys never reach the page. nifra schemas --write drafts each missing one from the function's return type, leaves out credential-like fields and marks it nifra:generated so later runs keep it current. A value typed any or unknown drafts as t.json(), which admits anything: nifra schemas lists those paths and nifra check warns on them (NF-C030). Give the value a type and draft again. nifra schemas --check fails on a missing, stale or open schema, for CI.

4. Choose where the backend is served

3.x and 4.x served backend/app.ts at /api by default. In 5.0 createWebApp serves it over HTTP only at an apiPrefix the app sets; without one the backend is loader-only, reached in-process through ctx.api, and a browser call to /api reaches page routing. nifra migrate layout adds apiPrefix = "/api" when it moves a root backend.ts, and nifra check reports a framework config that sets none (NF-C035).

TS
// doc-check: skip - backend/framework.ts
export { reactAdapter as adapter } from "@nifrajs/web-react"

// Serve backend/app.ts at /api, as 3.x and 4.x did by default. "" keeps it loader-only.
export const apiPrefix = "/api"

With both nifra.config.ts and backend/framework.ts, every command takes apiPrefix, apiStrip, mounts, csp, nonce, serverComponents, use and publicEnvPrefix from backend/framework.ts when nifra.config.ts leaves them unset. The backend sees each request's full path unless apiStrip is on, so its routes are registered under the prefix; nifra routes marks one outside it as in-process only, and nifra dev and nifra build warn about it.

An app that strips the prefix itself, with a Proxy or a wrapper around the backend's fetch that cuts /api before routing, gets that warning for every route: the check reads the registered paths, and a rewrite at run time is invisible to it. Delete the wrapper and export apiStrip = true beside apiPrefix, which does the same cut where the framework can see it. A second prefix the wrapper served (a /v1 alias, a webhook path) becomes an entry in mounts.

5. Review the 5.0 defaults that changed

  • Root status pages (_404.tsx, _<status>.tsx) and _error.tsx render inside the layouts above them. One that imports the root layout to wrap itself renders it twice (NF-C034); remove the wrap, or export inLayouts = false.
  • A layout's meta receives its own loader data as data and the page's as pageData (NF-C040).
  • A WebSocket handshake runs the route chain an HTTP GET at the same point runs, so guards added before app.ws() apply to it.
  • A Response returned to more than one request is no longer shared; build one per request.
  • nifra check fails a secret comparison (NF-S002) outside browser code of a web app.

From 3.x, two 4.0 changes also show up in tests and tooling rather than in pages:

  • Page data is handed over in one <script type="application/json" id="__nifra-handover">, not a script that assigns window.__NIFRA_DATA__. A test that read the data out of the HTML parses that element (HANDOVER_ID from @nifrajs/web).
  • x-nifra-isr-revalidate and x-nifra-isr-tags are set only under a cache wrapper such as withISR, which removes them before responding. A test that read a page's TTL from them on an unwrapped app reads nothing.

nifra upgrade prints the full list with the finding or the one-line change that keeps the 4.x behavior for each.

Past 5.0, 5.1 adds one change a frontend on another origin sees. A route with a body schema answers a urlencoded, multipart or text/plain body whose Origin names another site with 403 forbidden_origin, before reading it. List that frontend’s origin, or an identity provider’s for a form_post callback, in the formOrigins server option (an array of exact origins or a function; @nifrajs/edge takes it too). JSON bodies are unchanged. Security covers what the check leaves to csrf().

6. Run the release gates

  1. Run nifra check --json and fix every error. nifra doctor lists any package still on another release.
  2. If the project has nifra.assurance.ts, run nifra assure --json.
  3. Run the application test suite and a production build.
  4. Exercise the pages that load or post data, and every browser call to the backend, under nifra dev and against the production build.