Docs

CLI

Nifra is zero-config: it reads routes/, framework.ts, and (optionally) backend.ts from your project and wires the right @nifrajs/web entrypoint - no dev.ts/build.ts/server.ts to hand-write. (`create-nifra` scaffolds the conventions.)

TS
nifra dev      # true-HMR dev server (Bun native HMR + Nifra SSR) - http://localhost:4321
nifra dev --vite   # the Vite middleware pipeline instead (automatic when vitePlugins are your ONLY transforms)
nifra build    # full Bun deploy → dist/server.js + content-hashed dist/assets/ (default target: bun)
nifra start    # run dist/server.js on Bun
nifra build --target cloudflare  # also: node | deno | vercel | static; add --report for chunk sizes

# dev + start share the default port 4321. Override per run: --port <n> (alias -p) or the PORT env var.
# flags: --port <n> (dev/start) · --out <dir> (build/start) · --target <t> (build) · --poll (dev)
#        --vite | --bun (dev/build) force the pipeline

The conventions

Four conventions at the project root. nifra dev runs the Bun-native HMR dev server; nifra build runs the Bun-native production build (content-hashed client assets plus a target-specific server); nifra startruns the default Bun output. The generated server serves assets and SSR with matched-route chunks preloaded and route CSS linked in each <head>.

One rule picks the bundler, and it is the same rule in both phases - so a project can never be bundled by one toolchain in dev and the other in production. Bun runs everything, except an app whose only transforms are vitePlugins: the Bun pipeline cannot run those, so that app gets Vite for dev and build alike, and says so. --vite / --bun force the choice; --bun is refused on a Vite-only app rather than dropping its transforms silently. Every dev and build run prints which bundler it picked and why, and nifra check answers the same question without starting a server - see which pipeline runs, when for the full table.

TS
my-app/
  routes/            # file-based routes (index.tsx, _layout.tsx, [id].tsx, …)
  framework.ts       # deploy-safe render adapter
  nifra.config.ts    # CLI-only client module + dev/build plugins
  backend.ts         # export const backend = server()...   (optional - the typed contract)

These paths are fixed, not configurable: the CLI resolves all four from the project root, so backend.ts cannot move into src/ or be renamed - if the file is not at the root, nifra dev|build simply run without a backend (it is optional, so nothing errors). Only the entry file is pinned; the feature modules it .merge()s can live in any directory. Keep backend.ts a pure composition root - merging modules, registering no routes of its own - so each route's capability reach stays its own module's (see Backends & API and Verification).

framework.ts - naming the framework once

Keep the render adapter in deploy-safe framework.ts. Put clientModule, Vite plugins, compiler plugins, conditions, and defines in CLI-only nifra.config.ts. This prevents build tooling and native dependencies from entering generated server bundles.

TS
// framework.ts - deploy-safe; generated server entries import this file.
import { reactAdapter } from "@nifrajs/web-react"

export const adapter = reactAdapter

// nifra.config.ts - CLI-only build/dev tooling; never imported by a deployed server.
// doc-check: skip - needs the third-party @vitejs/plugin-react; install it to run this.
import react from "@vitejs/plugin-react"
export { adapter } from "./framework"
export const clientModule = "@nifrajs/web-react/client"
export const vitePlugins = [react()]          // dev HMR (Fast Refresh)
// Vue/Svelte/Solid also export:
//   clientPlugins = [vueBunPlugin("dom")]     // compile routes for the client build
//   serverPlugins = [vueBunPlugin("ssr")]     // compile routes into the target's server bundle
//   conditions    = ["solid"]                 // Solid: resolve solid-js to its source
//   define        = { __VUE_OPTIONS_API__: "true", ... }   // Vue feature flags

Tailwind to StyleX

Nifra includes a conservative codemod for moving static Tailwind classes to StyleX. It is a dry run by default, so inspect the proposed edits first:

Shell
nifra migrate --from tailwind --to stylex          # report safe edits and manual issues
nifra migrate --from tailwind --to stylex --write  # apply only safe edits
nifra migrate --from tailwind --to stylex --dir src

Supported static className attributes become stylex.props(...) calls backed by a local stylex.create(...) table. Responsive breakpoints and element-local pseudo-classes such as hover: are supported. Dynamic class expressions, arbitrary values, parent-dependent variants, and unknown utilities stay unchanged and are reported with file/line evidence. A non-zero exit status means manual migration issues remain.

StyleX compilation is native to Nifra's Bun pipeline. Install the runtime and compiler peers, then register both the browser and SSR transforms:

Shell
# Install the runtime and the optional compiler peers.
bun add @stylexjs/stylex
bun add -d @babel/core @stylexjs/babel-plugin @babel/plugin-syntax-flow \
  @babel/plugin-syntax-jsx @babel/plugin-syntax-typescript
TS
// nifra.config.ts - Bun's client and SSR build transforms.
import { stylexBunPlugin } from "@nifrajs/web/plugins/stylex"

export const clientPlugins = [stylexBunPlugin("dom")]
export const serverPlugins = [stylexBunPlugin("ssr")]

If the project intentionally uses Vite, add the Vite adapter alongside its existing Vite transforms. Nifra will use Vite when vitePlugins are the app's only transforms:

TS
// doc-check: skip - requires the third-party Vite React plugin
// If the app intentionally uses Vite, keep StyleX in vitePlugins.
import react from "@vitejs/plugin-react"
import { stylexVite } from "@nifrajs/web/plugins/stylex"

export const vitePlugins = [react(), stylexVite()]

Upgrading nifra

Every @nifrajs/* package, nifra and create-nifra ship together at one version. Move a workspace between releases with nifra upgrade <version> rather than by editing pins: it runs every release recipe between the oldest version your package.json files pin and the target, oldest first. A recipe moves a removed package to its replacement, rewrites imports that moved, lists comments a release stopped honoring, and pins each group dependency to the target, keeping its ^ or ~ unless you pass --exact. The pins in overrides, resolutions and pnpm.overrides move with them, since an override decides what installs. A peerDependencies range is what a library accepts, not a pin: it is raised only when it excludes the target (keeping its operator, even with --exact), and a library's ">=3.1.0" floor never sets the version the upgrade starts from unless nothing else, installed or declared, names one.

BASH
nifra upgrade --list          # the release targets this CLI knows
nifra upgrade 5.0.0           # dry-run: every planned edit, nothing written
nifra upgrade 5.0.0 --write   # apply; it verifies with nifra check once the target is installed
bun install                   # install the new pins
nifra check                   # verify against them
nifra doctor                  # afterwards: no "mixed nifra versions" advisory left

It is a dry-run until --write, and the same run twice makes no further edits. An unknown version or a target below what is installed is refused (--allow-downgrade when the rollback is intended); a target newer than the running CLI prints the command for that release's CLI. 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" for you to upgrade in its own repository. The MCP launch pin in .mcp.json, .cursor/mcp.json, CLAUDE.md and AGENTS.md moves to the target, or is dropped when the project declares @nifrajs/cli so the launch runs that install from then on: the edit nifra init-agents --sync-mcp makes. Another version pinned outside package.json is listed with its line: the rest of an agent file, an MCP config elsewhere, a Dockerfile or compose file, a CI workflow, and a lockfile other than the workspace's own (an image build's frozen lockfile, say), which you regenerate after installing. nifra check reads the installed packages, so --write runs it (--no-verify skips that) only once they match the target: until the new pins are installed it would judge the release you are leaving, and the command prints the install to run first instead. With --write it also saves the hook report from before the edits and, once the target is installed, prints how hook scope changed across the upgrade (--no-hooks skips that); before then, nifra routes --hooks --since compares after the install. nifra doctor and nifra check (NF-D002) point here when a workspace carries more than one version; see mixed nifra versions. A file: package whose dependencies this upgrade moves keeps its old ones in bun.lock, where Bun never refreshes them; the plan lists each, and --write drops those entries so the next bun install records them again (see stale file: links).

A link: or file: dependency outside the root that installs its own @nifrajs/* at another version stops the upgrade before anything is written: its imports resolve from its own node_modules, so after the upgrade a server() it builds cannot be merged into yours. The refusal names each one with the commands that move it to the target; upgrade those first, or pass --allow-linked-skew to go ahead. nifra upgrade --list shows the targets with the notes of every release after the one you declare: what an app can observe changing on the way up.

Scope

nifra build defaults to a self-hosted Bun server. Use --target node, deno, cloudflare, vercel, or static for another complete deploy shape (see Deployment). Run non-Bun outputs with the command printed by the build.