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.
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 moduleThe zones
| Where | Holds | Reaches the browser |
|---|---|---|
routes/x.tsx (.svelte, .vue, .mdx) | a page: the component, meta | yes |
routes/x.backend.ts | that page's loader, action, loaderOutput, actionOutput, hydrate, revalidate, middleware (in _layout.backend.ts) | never |
frontend/ | components, hooks, browser-only code | yes |
backend/ | app.ts (the API), framework.ts, the database, auth, secrets | never |
shared/ | schemas, types, pure helpers both sides import | yes |
public/ | static files | yes |
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.
// 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>
)
}// 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
| From | May 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:
[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.tsData and secrets
- Every loader and action declares what it sends with
loaderOutput/actionOutput. Only declared fields reach the browser, andnifra checkflags a loader without one. - Browser code reads only public environment variables -
NODE_ENVand names with thePUBLIC_prefix. Any otherprocess.envread in a page,frontend/orshared/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.