Docs

Project structure

Every file a Nifra build loads belongs to one side: the browser, the server, or both. The folder says which, and the build refuses an import that crosses the wrong way - so server code and secrets cannot ship to the browser by accident.

TS
my-app/
  routes/                 the URL tree: each page and its backend half
    _layout.tsx             wraps every page below it
    _layout.backend.ts      its middleware (runs on the server first)
    index.tsx               /            ships to the browser
    index.backend.ts                     its loader and action - never does
    users/
      [id].tsx              /users/:id
      [id].backend.ts
  frontend/               components, hooks, browser-only code
  backend/                app.ts (the API), framework.ts, db/, auth.ts
  shared/                 schemas, types and pure helpers both sides import
  public/                 static files, served as they are
  nifra.config.ts         tooling: the deploy target, the client module

The zones

WhereHoldsReaches the browser
routes/x.tsx (.svelte, .vue, .mdx)a page: the component, metayes
routes/x.backend.tsthat page's loader, action, loaderOutput, actionOutput, hydrate, revalidate, middleware (in _layout.backend.ts)never
frontend/components, hooks, browser-only codeyes
backend/app.ts (the API), framework.ts, the database, auth, secretsnever
shared/schemas, types, pure helpers both sides importyes
public/static filesyes

A file outside those folders joins a side with a suffix: x.frontend.ts, x.backend.ts or x.shared.ts. A workspace package declares its side in its package.json: "nifra": { "environment": "frontend" | "backend" | "shared" | "library" }. A file in no zone is a build error, so nothing is ever classified by accident.

A route is two files

The page renders; its backend half loads. A server-only export (loader, action, hydrate, ...) in the page file is a build error that names the backend file it belongs in.

TS
// routes/users/[id].tsx - the page. It ships to the browser whole.
import type { Route } from "./+types/[id]"
import { Avatar } from "../../frontend/avatar"

export const meta = { title: "User" }

export default function User({ data }: Route.ComponentProps) {
  return (
    <main>
      <Avatar url={data.avatarUrl} />
      <h1>{data.name}</h1>
    </main>
  )
}
TS
// routes/users/[id].backend.ts - the page's server half. It never reaches the browser.
import { t } from "@nifrajs/schema"
import { notFound } from "@nifrajs/web"
import type { Route } from "./+types/[id]"

// What the page receives - and the ONLY fields that leave the server. A field the loader returns
// but the schema does not declare is dropped before rendering.
export const loaderOutput = t.object({ name: t.string(), avatarUrl: t.string() })

export async function loader({ params, api }: Route.LoaderArgs) {
  const res = await api.users({ id: params.id }).get()
  if (!res.ok) throw notFound()
  return res.data
}

Both halves import Route from the generated ./+types/[id]: Route.LoaderArgs carries the typed params and api, and Route.ComponentProps types data as exactly what loaderOutput lets through. nifra dev, nifra build, nifra check and nifra types write these files under .nifra/types; the app's tsconfig.json resolves them with "rootDirs": [".", "./.nifra/types"].

What may import what

FromMay import
a page, frontend/frontend code, shared/, a *.fn.ts server function (the browser gets its RPC stub)
a backend half, backend/backend code, shared/ - not frontend code (keep what both need in shared/)
shared/shared/ only

Third-party packages are allowed on any side, except that browser code may not reach a server-only package (a database driver, a node: built-in). A type-only import (import type) is allowed anywhere: it is erased before bundling. A refused import fails the build with the chain that led to it:

TS
[nifra/web] the browser build reached code that may not ship to a browser:
  - backend/db/index.ts: routes/users/[id].tsx (a route's frontend half) imports
    backend/db/index.ts (backend code): frontend code may not import backend code; reach it
    through a loader, an action or a *.fn.ts server function. A type-only import
    (`import type`) is allowed
      via routes/users/[id].tsx → backend/db/index.ts

Data and secrets

  • Every loader and action declares what it sends with loaderOutput / actionOutput. Only declared fields reach the browser, and nifra check flags a loader without one.
  • Browser code reads only public environment variables - NODE_ENV and names with the PUBLIC_ prefix. Any other process.env read in a page, frontend/ or shared/ fails the build.
  • The build scans what it emits. A client bundle, a public file or a prerendered page that contains what looks like a credential fails the build.

The same rules run in nifra dev (a refused module is answered with a 403 before its source is sent), in both bundlers, for every framework, and in nifra check. An app on the older layout moves with nifra migrate layout: a dry run that reports every move, then --write.