Docs

Frameworks

Nifra renders five UI frameworks on one agnostic core. The render seam, file-based routes, typed loaders/actions, streaming, islands, prefetch, and the client router are shared across adapters, while each page still uses its framework's normal component style.

Scaffold any of them

create-nifra's --framework flag scaffolds the multi-target SSR site with the adapter, routes, and deps for your pick. It composes with --target, so one command gives you a framework + a deploy target.

TS
# Scaffold a multi-target SSR site in any of the five (react is the default):
bun create nifra my-app --framework solid      # or react · preact · vue · svelte

# Composes with --target - pick a framework AND a deploy target:
bun create nifra my-app --framework svelte --target vercel

The adapters

FrameworkPackageUI idiomBuild pluginHydration bundle
None (vanilla)@nifrajs/web-vanillatagged-template HTML + islandsnone0 KB
Solid@nifrajs/web-solidprimitivesBabel~15 KB
Preact@nifrajs/web-preacthooks (React-compat)none~18 KB
Svelte 5@nifrajs/web-sveltestores + runes.svelte compiler~49 KB
Vue 3@nifrajs/web-vuecomposables.vue SFC (or render fns)~66 KB
React 19@nifrajs/web-reacthooksnone (Bun JSX)~182 KB
Bundle = the same minimal counter app, minified (not gzipped), for each framework - see examples/web-*. Indicative payload, not a benchmark.

Or no framework at all

@nifrajs/web-vanilla is the first row, and it is not a sixth framework - it is the absence of one. Pages are plain functions returning an auto-escaping html tagged template, and the client ships no framework runtime, so the bundle is genuinely zero rather than small.

TS
import { html, vanillaAdapter } from "@nifrajs/web-vanilla"

// routes/hotels.tsx - a page keeps a route extension even with no JSX in it.
export default function Hotels({ data }: { data: { hotels: Array<{ name: string }> } }) {
  // Interpolated values are escaped; wrap trusted markup in raw() to opt out deliberately.
  return html`<ul>${data.hotels.map((h) => html`<li>${h.name}</li>`)}</ul>`
}

// routes/hotels.backend.ts - its backend half; hydrate = false ships the page with no JavaScript.
export const hydrate = false

Everything that lives in @nifrajs/web rather than the view layer works unchanged: loaders, actions, ISR, SSG, head management, streaming. What you give up is hydration - these are server-rendered documents, so set export const hydrate = false in the route's .backend.ts half and reach for islands where a page needs interactivity.

Worth it for the surfaces where HTML is the product: landing pages, listings, docs, comparison tables. This documentation site renders that way.

Authoring routes

A page's default export is the component and meta is a named export; its loader and action live in the route's .backend.ts half, the same file whatever the framework. Most adapters write .tsx, but the compiled frameworks use their native single-file format - Svelte .svelte (meta in <script module>) and Vue .vue SFCs (meta in the plain <script>; the component in <script setup> + <template>) - each compiled by its package's Bun plugin (@nifrajs/web-vue/plugin, @nifrajs/web-svelte/plugin).

TS
<!-- routes/index.vue - a Nifra route authored as a Vue Single-File Component -->
<script lang="ts">
// The plain <script> carries the page's named exports (meta, handle, ...):
export const meta = { title: "Home" }
</script>

<script setup lang="ts">
defineProps(["data"])          // Nifra passes the loader data in as `data`
</script>

<template>
  <h1>Count: {{ data.count }}</h1>
</template>

<!-- routes/index.backend.ts - the loader, the same file for every framework -->
import { t } from "@nifrajs/schema"
import type { Route } from "./+types/index"

export const loaderOutput = t.object({ count: t.number() })
export async function loader({ api }: Route.LoaderArgs) {
  const res = await api.count.get()
  return { count: res.ok ? res.data.count : 0 }
}
Component <style scoped> (Vue) and <style> (Svelte - scoped by default) are compiled and scoped into the app stylesheet - no runtime, no FOUC - plus CSS Modules and global imports. See Dev & HMR → Styling. examples/routing-vue-sfc is a full SSR + hydration + client-nav demo.

Same features, every framework

Every adapter gets Nifra's full feature set - not a subset:

  • Streaming SSR + hydration (islands), with component-level Suspense.
  • File-based routing, nested layouts, typed loader/action, and progressive-enhancement forms (work with JS off).
  • <Await> for deferred data (defer()), a keyed query cache (useQuery), and concurrent useFetcher mutations.
  • Optimistic UI, targeted revalidation, hover-prefetch, and scroll restoration.
  • True HMR in dev for all five (@nifrajs/web/vite) - see Dev & HMR.

One line changes

The adapter is the only framework-specific choice on the server. Swap it and the entire app - routes, data, streaming - runs on a different framework, unchanged.

TS
// Server - pick an adapter. Everything else is identical across all five frameworks:
// the same routes, loaders, actions, streaming, <Await>, fetchers, query cache.
import { createWebApp } from "@nifrajs/web"
import { reactAdapter } from "@nifrajs/web-react"   // ← or web-solid · web-vue · web-preact · web-svelte
import { clientEntry, manifest } from "./server-manifest" // generated by buildServer

const app = createWebApp({
  adapter: reactAdapter,   // the one line that changes per framework
  manifest,
  clientEntry,             // the built client bundle (from buildClient's manifest)
})

Idiomatic, not lowest-common-denominator

The bindings share names and behaviour across frameworks, but each is expressed the native way - React/Preact hooks, Vue composables, Solid signals, Svelte stores.

TS
// The data primitives - same names + behaviour everywhere, each in the framework's idiom.
// React / Preact - hooks:
const fetcher = useFetcher("save")          // fetcher.submit(...), fetcher.pending
const q = useQuery(["todos"], loadTodos)    // q.data, q.isFetching, q.refetch()

// Vue - composables (refs):           const f = useFetcher("save"); f.state.value.pending
// Solid - signals:                    const q = useQuery(...);       q().data
// Svelte - stores (read with $):      const f = useFetcher("save");  $f.pending

Deferred data behavior

React, Preact, and Solid stream a <Await> boundary - the fallback flushes in the SSR HTML, then the resolved content streams in (it works with JS off). Vue and Svelte resolve deferred data on the client, so JS-off users see the fallback for that deferred boundary. Put critical page data in the route loader; use defer() for slower, non-critical data.

Markup outside the page

Svelte's <svelte:head> renders into the document's <head>, after the tags from the route's meta. The document always has a <title> of its own and a browser uses the first one, so set the title in meta.

A Vue <Teleport> is server-rendered where Vue hydrates it from, the first children of its target. to="body" content comes first in <body>. An #id target the app renders empty receives it, and one the app does not render gets a container before the app root. Any other selector, a target that has children of its own, and every teleport on a page that uses defer() render on the client, with a warning in development.

Dev HMR support

Dev HMR works for all five adapters through createViteDevServer and the framework's Vite plugin. React, Preact, and Vue preserve edited component state. Solid and Svelte update live without a full page reload, and the edited component may remount. Full matrix in Dev & HMR.