Docs

Dev & HMR

Nifra gives you two local development loops. Use nifra dev for state-preserving UI edits, or use @nifrajs/web/dev when you want a Bun-only live-reload server. Both serve your real SSR app locally.

Two loops, same app

importwatcher → updatedependenciesuse when
@nifrajs/web/devrebuild + full-page live-reloadnone (Bun only)default; zero extra deps
@nifrajs/web/vitetrue HMR (Fast Refresh / framework HMR)vite + your framework's pluginstate-preserving UI iteration

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
// 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 ?? 3000),
  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 for Vue, Svelte, and Solid, preload the matching Nifra compiler plugin for server rendering.

frameworkclient (Vite plugin)SSR compilelocal state on edit
React@vitejs/plugin-reactBun-nativepreserved (Fast Refresh)
Preact@preact/preset-viteBun-nativepreserved (prefresh)
Vue@vitejs/plugin-vuevueBunPlugin preloadpreserved (rerender)
Solidvite-plugin-solid ({ ssr: true })solidBunPlugin preloadresets (solid-refresh)
Svelte@sveltejs/vite-plugin-sveltesvelteBunPlugin preloadresets (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>
}

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

@nifrajs/web/dev is a self-contained server: it builds the client on boot, serves SSR, watches the routes, and does a full-page reload on change.

TS
// dev.ts — Bun-native live reload
import { createDevServer } from "@nifrajs/web/dev"
// builds the client on boot, serves SSR, watches routes, full-page live-reload on change.
const server = await createDevServer({ routesDir, outDir, clientModule, createApp })

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.

Proudly built with Nifra — server-rendered on Cloudflare Pages.MIT