Docs

Dev & HMR

Nifra gives you two local development loops, and the rule between them is that one toolchain owns a whole phase. Both give you React Fast Refresh with state preserved. Bun is the default; Vite takes over automatically when your config's only transforms are vitePlugins. Both serve your real SSR app locally, and neither mixes the two bundlers in one process.

Two loops, same app

importwatcher → updatedependenciesuse when
@nifrajs/web/devBun HMR + Fast Refresh (native)none (Bun only)one bundler dev+prod, no Vite dep; CSS Modules included
@nifrajs/web/vitetrue HMR (Fast Refresh / framework HMR)vite + your framework's pluginstate-preserving UI iteration

Which pipeline runs, when

One rule decides the bundler, and it decides it the same way for nifra dev and nifra build - your dev loop and your production build are never on different toolchains.

your configcommandpipelinewhy
no plugins at allnifra dev / nifra buildBunthe default: no Vite dependency, one bundler across dev and prod
clientPlugins and/or serverPluginsnifra dev / nifra buildBunthose slots are Bun.build plugins; Vite would never call them
vitePlugins onlynifra dev / nifra buildVite (automatic)the Bun build cannot run them, so staying on Bun would silently drop your transforms
vitePlugins only--bunerrornifra refuses rather than build an app with its compiler switched off
any--viteVite (forced)exact dev/prod client resolution for conditions, or a Vite-only plugin
any--bunBun (forced)allowed whenever no transform would be lost

You never have to work this out from the table. Every nifra dev and nifra build run prints the answer under its banner, and nifra check and nifra doctor report it - with the config hazards below - without starting a server:

TS
# doc-check: skip - fragment: terminal output, not source.
$ nifra dev
nifra dev (bun) → http://localhost:3000
  bundler: bun (default; --vite to switch)

$ nifra build
nifra build (node, vite) → dist/server/server.js
  bundler: vite (auto: this app's only transforms are `vitePlugins` (svelte), which the Bun build cannot run)

$ nifra check
• bundler: vite (auto: this app's only transforms are `vitePlugins` (svelte), which the Bun build cannot run)

State-preserving HMR

Use createViteDevServer when you want component edits to update the browser without a full page reload. Pass the official plugin for your UI framework, keep the same Nifra routes and loaders, and run the dev server during local development.

TS
// doc-check: skip - needs the third-party @vitejs/plugin-react + your ./backend; install it to run this.
// dev.ts - state-preserving HMR for supported UI adapters
import react from "@vitejs/plugin-react"            // your framework's official Vite plugin
import { createWebApp } from "@nifrajs/web"
import { discoverRoutes } from "@nifrajs/web/fs"
import { createViteDevServer } from "@nifrajs/web/vite"
import { reactAdapter } from "@nifrajs/web-react"
import { backend } from "./backend"

const routesDir = `${import.meta.dir}/routes`
const server = await createViteDevServer({
  root: import.meta.dir,
  routesDir,
  clientModule: "@nifrajs/web-react/client",
  plugins: [react()],                                // Vue: @vitejs/plugin-vue, Svelte: …, etc.
  port: Number(Bun.env.PORT ?? 4321),                // Nifra's default; --port / PORT override it
  createApp: (clientEntry, importQuery) =>
    createWebApp({
      adapter: reactAdapter,
      manifest: discoverRoutes(routesDir, { importQuery }),
      clientEntry,
      api: inProcessClient(backend),
    }),
})

Start it with bun run dev. The server reads your route source directly, so you can edit routes, components, loaders, actions, and styles in one local loop.

Framework coverage

All five adapters have a dev setup. Pass the framework's official Vite plugin and you are done: under nifra dev the Vite pipeline compiles both halves, so the same plugin that transforms your components for the browser also transforms them for SSR. No separate server-side compiler plugin to preload, and no way for the two halves to disagree about a specifier.

frameworkVite plugin (client + SSR)local state on edit
React@vitejs/plugin-reactpreserved (Fast Refresh)
Preact@preact/preset-vitepreserved (prefresh)
Vue@vitejs/plugin-vuepreserved (rerender)
Solidvite-plugin-solid ({ ssr: true })resets (solid-refresh)
Svelte@sveltejs/vite-plugin-svelteresets (svelte HMR)

For React, Preact, and Vue, an edit hot-swaps with component state preserved. For Solid and Svelte, the module hot-swaps live (no full reload - scroll, route, and other components are kept), but the edited component re-runs, so its own local state resets. For Solid, use solid({ ssr: true }) and the "solid" resolve condition. Working examples for all five live in examples/hmr-*.

The Fast Refresh boundary rule

React Fast Refresh (and the other frameworks' equivalents) only hot-swap a module when every export is a component. Nifra route files co-locate loader, action, and meta next to the component - so a route file isn't a refresh boundary, and saving it does a clean full reload. Keep the view in a child component and edits hot-swap with state intact.

TS
// routes/index.tsx - NOT a Fast Refresh boundary (exports loader/meta), so a save
//                     here does a clean full reload. Keep the view in a child component:
export const meta = { title: "Home" }
export async function loader({ api }) { /* … */ }
export default function Home(props) {
  return <Counter message={props.data.message} />   // ← edit Counter.tsx for state-preserving HMR
}

// components/Counter.tsx - component-only module → a Fast Refresh boundary. Editing this file's
// JSX hot-swaps it with useState/useReducer state PRESERVED (no reload).
import { useState } from "react"
export function Counter(props: { message: string }) {
  const [count, setCount] = useState(0)
  return <button onClick={() => setCount((n) => n + 1)}>{count}</button>
}

DevTools overlay

@nifrajs/devtools is a plugin that streams what each request actually did - loader traces, ISR status, route metadata - over a secured SSE endpoint, with an overlay to read it in the browser.

TS
import { server } from "@nifrajs/core/server"
import { devtools } from "@nifrajs/devtools"

// Enabled only when NODE_ENV is "development" unless you say otherwise.
export const app = server().use(devtools())

It is off outside development by default, refuses remote connections unless you allow them, and caps both the buffered event count and the number of live connections - a dev tool that streams request internals has to be closed by default rather than merely quiet in production.

Containers & sandboxes

In Docker, networked volumes, and some sandboxes, pass poll: true (or set CHOKIDAR_USEPOLLING=1) to use a polling watcher instead.

The zero-dep alternative

nifra dev --bun (library: @nifrajs/web/dev) is self-contained - no Vite anywhere. Bun.serve's native HMR bundles and hot-reloads the client while Bun's runtime resolves SSR, and it applies React Fast Refresh natively: editing a component-only module swaps its markup with useState state intact, no reload. The boundary rule is the same as Vite's (see below). The real prize is that dev and production use the same bundler, so the dev/prod seam disappears.

CSS Modules included. Bun's dev-server bundler has no *.module.css transform of its own, so the CLI hands it the same one the production Bun.build uses - and a class hashes to the same scoped name on every pipeline. Calling createDevServer as a library instead of through the CLI, you supply that plugin yourself through your own bunfig.toml; plain CSS and Tailwind need nothing either way.

Server functions and *.server modules work under --bun - and the plumbing is worth knowing. The client build strips them: a *.fn module is replaced with an RPC stub and a *.server module is emptied, so their bodies (DB handles, secrets, imports) never reach a browser. Bun's dev-server bundling accepts plugins only through bunfig.toml ([serve.static] plugins) - not programmatically, and a runtime Bun.plugin never reaches it (upstream ask: oven-sh/bun#36830). So nifra dev --bun generates a config under .nifra/dev-bun/ carrying the same production boundary plugins, merges your own bunfig's [serve.static] plugins and preload entries, and re-launches itself once with --config= pointing at it. Same stubs as nifra build, byte for byte - one implementation, three pipelines.

Two pipelines, one contract: what keeps them honest is not hope but guards. Both loops serve public/ through the same handler production uses, the build dedupes the UI framework to one physical copy on both paths, SSR verifies at render time that the renderer and your components share one core (naming both directories if not), and nifra doctor flags a stale workspace dist before it can shadow source. A divergence between the loops is treated as a bug, not a caveat.

TS
// doc-check: skip - fragment: routesDir/outDir/clientModule/createApp are your app's dev config.
// dev.ts - Bun-native HMR, no Vite in the process
import { createDevServer } from "@nifrajs/web/dev"
// Bun.serve bundles + hot-reloads the client; Bun's runtime resolves SSR. An edit reloads the
// changed module graph - with React Fast Refresh (state preserved) applied natively by Bun, no plugin.
// CSS + the entry URL come from Bun. Plain CSS/Tailwind work as-is; for *.module.css as a library
// caller, pass the production CSS Modules plugin through your own bunfig (nifra dev --bun does it for you).
const server = await createDevServer({ routesDir, outDir, clientModule, createApp })

Production is Bun - with a Vite escape hatch

Production builds default to Bun (buildClient / nifra build): faster, Bun-native, and the profile Nifra is tuned for. If an app genuinely needs a Vite-only transform with no Bun equivalent, you can run a Vite/Rollup production client build instead - but it must carry the same client-leak guards the Bun build enforces, or a second pipeline becomes a way for server-only code to reach the browser unnoticed. Add viteLeakGuard(): it runs the same detection and emits the same error as the Bun build (one implementation, adapted to Rollup's graph), so node: builtins and server-only modules fail the build either way.

TS
// vite.config.ts - a Vite/Rollup PRODUCTION client build (the escape hatch, not the default).
// Only reach for this when an app needs a Vite-only transform with no Bun equivalent; Nifra's default
// production bundler stays Bun (buildClient), which is faster and Bun-native.
import { viteLeakGuard } from "@nifrajs/web/plugins/vite-leak-guard"

export default {
  build: {
    // The SAME two client-leak guards Nifra's Bun build runs - server-only code or a node: builtin
    // reaching the browser fails the build, with the identical error message. A second production
    // pipeline must not ship without them.
    rollupOptions: { plugins: [viteLeakGuard()] },
  },
}

For the full deploy, nifra build --vite --target <t> builds both halves - client and SSR worker - with Vite and assembles the identical per-target deploy dir the Bun build produces (same _worker.js / server.js, same _routes.json, same prerender + size report). Only the bundler differs: both go through one orchestrator, so the deploy shape can't drift between pipelines. The leak guards run automatically.

You usually don't need the flag. nifra build picks the bundler from your config: Bun by default, but Vite when your only transforms are vitePlugins, and it prints the reason. That case is the one where the phase defaults would otherwise bite - dev runs Vite, so your plugins run; the Bun build reads clientPlugins/serverPlugins and never vitePlugins, so it would drop them and still succeed. An app declaring both slots has supplied the Bun equivalent on purpose, so it keeps the faster Bun default. --vite and --bun force the choice; --bun is refused for a vitePlugins-only app rather than silently building without your transforms.

Styling (CSS)

Import a stylesheet anywhere - import "./app.css" in a route, layout, or component. In dev, Vite injects and hot-reloads it (no page reload). In production, buildClient bundles + minifies + content-hashes the CSS and records it as manifest.css; pass that to createWebApp's styles and Nifra links it in every page's <head> as a render-blocking <link rel="stylesheet"> (no FOUC). Serve .css assets as text/css.

TS
// Import CSS anywhere in a route or component - a global stylesheet (in _layout) or local:
// routes/_layout.tsx
import "./app.css"

// Dev: Vite injects + HMRs the CSS (no reload). Production: buildClient bundles + content-hashes it
// into manifest.css (aggregate) + manifest.routeStyles (per route); wire both into your server:
// server.ts
const assets = JSON.parse(await Bun.file("dist/manifest.json").text())
export const app = createWebApp({
  adapter, manifest, clientEntry: assets.entry,
  styles: assets.css,              // aggregate - the safe fallback
  routeStyles: assets.routeStyles, // per route - each page links only its chain's CSS
})
// → <link rel="stylesheet"> for just the matched route's CSS in <head>. Serve .css as text/css.

This is the global imports tier: one bundled stylesheet linked on every page (the common case - a global stylesheet or Tailwind output).

Scoped styles - CSS Modules & SFC <style>

For component-local styles you have two collision-free options, both bundled into that same stylesheet:

  • CSS Modules (*.module.css) - works in any framework. buildClient (Bun) and the dev server (Vite) both hash the class names and hand you a Record<string, string> map. Add an ambient declaration once so TypeScript types the import.
  • SFC <style scoped> (Vue) and <style> (Svelte - scoped by default) - the framework's compiler plugin rewrites the selectors to a unique scope ([data-v-…] / .svelte-…) and bakes the matching marker into the SSR markup, so the server HTML already matches the bundled CSS. No runtime, no FOUC.
TS
// CSS Modules - *.module.css gives a hashed, collision-free class map:
// Counter.module.css  →  .box { padding: 1rem }
import styles from "./Counter.module.css"
// then: <div className={styles.box}>…</div>   →   class="box_a1b2c3" at runtime

// TS needs ambient types for CSS imports - declare them once (e.g. src/css.d.ts):
declare module "*.module.css" { const c: Readonly<Record<string, string>>; export default c }
declare module "*.css" {}

// Vue / Svelte SFCs - <style scoped> just works. The framework compiler scopes the selectors
// (#page[data-v-…] for Vue, .page.svelte-… for Svelte) and folds them into the same app stylesheet.

Per-route CSS splitting

buildClient splits CSS per route: each page links only its layout chain and its own stylesheet. Pass manifest.routeStyles to createWebApp alongside styles, and Nifra links the matched route's CSS during SSR. In dev, Vite injects per-module CSS.

Gotchas

The rule "one toolchain owns a whole phase" is what keeps the two loops honest, and most of what follows is a consequence of it. None of these are things you have to memorise before you start - they are the edges you can hit later, collected in one place.

Keep the adapter out of your config file

Split framework.ts (the adapter, imported by your server and edge entries) from nifra.config.ts (the CLI's config, where the Vite plugins and SFC compilers live). If your server entry reaches the adapter through the config, everything the config imports is bundled into your production server - the Vite plugin, the framework compiler, and their native bindings. It builds without complaint and then fails at startup with a missing native binding, which reads like a broken install rather than a config-shape problem.

TS
// doc-check: skip - fragment: two files from one app, shown together.
// framework.ts - imported by your SERVER and edge entries. Adapter only, nothing else.
import { svelteAdapter } from "@nifrajs/web-svelte"
export const adapter = svelteAdapter

// nifra.config.ts - imported ONLY by the CLI, which runs on Bun. Compilers and Vite plugins
// belong here. Re-export the adapter; never define it here, or the dev toolchain it pulls in
// (Vite, the SFC compiler, their native bindings) is bundled into your production server.
import { svelte } from "@sveltejs/vite-plugin-svelte"
export { adapter } from "./framework"
export const clientModule = "@nifrajs/web-svelte/client"
export const vitePlugins = [svelte()]

Plugins go in the slot that matches the pipeline

vitePlugins run on the Vite pipeline; clientPlugins and serverPlugins run on the Bun one. A plugin in the wrong slot is not an error, it is a no-op - your transform silently does not run. Nifra classifies each plugin by its hook shape and refuses a mismatch rather than letting the build succeed without it. The same check is why --bun throws for an app whose only transforms are vitePlugins, instead of building without them.

conditions does not reach the Bun dev client bundle

On the Bun pipeline, conditions governs SSR - nifra dev passes them to the runtime when it re-execs - and it governs the production client bundle. It cannot govern the client bundle the dev server serves: Bun's dev-server bundler accepts no resolve conditions, and there is no bunfig.toml key for them either (a top-level conditions, or one under [run], [serve.static] or [bundle], parses and is ignored). So a package with an exports map can resolve to one file in dev and another in nifra build. Nifra says so once at startup rather than letting you find out in production. If your app depends on exact dev/prod client resolution, run nifra dev --vite.

Related, if you ever pass the flag to bun yourself: it takes one condition per flag. The comma form is accepted and matches nothing, because Bun reads the whole string as a single condition name.

TS
# Resolve conditions on the Bun dev pipeline.
# SSR honours `conditions` - nifra passes them to the runtime when it re-execs.
# The CLIENT bundle does not: Bun's dev-server bundler takes no resolve conditions,
# from bunfig.toml or anywhere else. nifra warns once at startup when it matters.
nifra dev --vite            # exact dev/prod client resolution, if your app needs it

# If you invoke bun yourself: ONE FLAG PER CONDITION.
bun --conditions=browser --conditions=development ./app.ts   # correct
bun --conditions=browser,development ./app.ts                # ONE condition named "browser,development"

Editing a non-route file updates SSR too

On the Bun pipeline the dev server tracks the module graph your routes import, so editing a component, a helper, or a server module several levels down is reflected in the next SSR render - not just in the browser bundle. Modules you did not touch keep their identity, so a module-level singleton stays single across the reload. Two limits are deliberate: files under node_modules are not tracked (they are dependencies, not your source), and a specifier only participates if it is relative and written literally - a computed or aliased specifier is invisible to a static graph. A third-party Bun plugin that rewrites imports needs to call rewriteSsrImports for its output to stay tracked.

An adapter with a compiled asset needs the dev server's loader

This one matters only if you are writing a render adapter. On the Vite pipeline Vite owns SSR resolution, which is what lets resolve.dedupe reach the server and keeps your app on one copy of the framework. Adapter packages themselves stay external to that graph, because the adapter's context object has to be the one instance your app imported. If such an adapter must load a compiled asset on the server - Svelte's chain component is one - a plain import reaches a runtime with no compiler for it, and registering a second compiler in the runtime is worse: the tree then holds two copies of the framework runtime, and context written by one half is invisible to the other. Load it through ssrModuleLoader() instead, and take the framework's server renderer from there as well - a component compiled by one toolchain has to render through the renderer that toolchain resolved.

Scoped class names are identical across pipelines

A CSS Modules class hashes to the same scoped name whichever pipeline produced it, and across machines - the hash is keyed on the class name plus the module's package-relativepath, never an absolute one. A selector written against a generated name behaves the same under nifra dev, nifra dev --bun, and nifra build.