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 package whose entries run on different sides gives each export subpath its own:
{
"name": "@shop/payments",
"exports": {
".": "./src/index.ts",
"./react": "./src/react.tsx",
"./schema": "./src/schema.ts"
},
"nifra": {
"environment": {
".": "backend",
"./react": "frontend",
"./schema": "library"
}
}
}An import is judged by the entry it names: the exact subpath first, then the most specific * pattern. Here a page may import @shop/payments/react but not @shop/payments, and backend code may not import the React entry, even when two entries are backed by one file. Importing an entry the map leaves out is an error. A file reached by path rather than by entry, through a relative or aliased deep import, takes the side of every mapped entry whose own imports reach it, and a file no mapped entry reaches is an error.
The app's own package.json can set sides too, so a folder that has to stay where it is, or a family of workspace packages, needs no edit of its own:
{
"name": "my-app",
"nifra": {
"zones": {
"packages": { "@shop/*": "library", "@shop/db": "backend" },
"backend": ["migrations", "src/ui/mailer.ts"],
"frontend": ["src/ui"]
}
}
}packages gives a side to a package that does not declare one, by exact name or by a prefix ending in *; a package's own declaration always wins. backend, frontend and shared list app folders, or single files, that count as that zone. A path inside another's folder overrides it, so the most specific entry decides: above, src/ui/mailer.ts is backend code in a frontend folder. One path listed under two sides is refused. Builds, dev servers, nifra check and the editor plugin all read this setting.
A project that serves no pages has no browser, so the zones do not apply to it. Declare that in its package.json with "nifra": { "kind": "api" }: nifra check then skips the zone checks and the server-only import check there, and treats every file as server code. It is read from the nearest package.json and only takes values "api" and "web". A directory with web config (nifra.config.ts exporting adapter, or backend/framework.ts) keeps its zone checks under it, and nifra check reports the clash (NF-C037); builds and dev servers never read it.
A directory with backend/app.ts and no web config is read as an API-only app whether or not it declares the kind: nifra routes, nifra context, nifra openapi and nifra mcp load backend/app.ts alone, and the tools, resources and prompts its backend declares reach nifra mcp.
Those commands, the contract lock and the route lints see only what backend/app.ts exports. An API whose app is built by a factory such as createApp(deps), with its real dependencies wired in another entry, exports the same app from backend/app.ts built with stand-in dependencies, so every operation is visible without a database or secrets; nifra check names an implemented operation it does not serve (NF-C039). See the stand-in entry.
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; in development the console names
// each dropped field, so a missing declaration never shows up as a silently blank page.
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, optional access (process.env?.["SECRET"]) included. - 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. A label in a message catalog (
{ password: "Password" }) and a documented placeholder (your-api-key) are text, not credentials.
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, which follows the imports the bundler follows: relative paths, tsconfig paths and baseUrl, package.json imports, and the clientModule the browser entry loads. An app on the older layout moves with nifra migrate layout: a dry run that reports every move, then --write. It follows the same imports, rewrites them and test-runner mocks (an alias through the same alias), and moves the config's clientModule, any module path the config builds from its own folder, and component pipeline entries with their modules. A test moves with the file beside it, snapshots included. Each tsconfig*.json keeps every file in, or out of, its program as before. It adds .nifra/ to .gitignore and leaves data files and folders zoned in package.json where they are.
A route file that holds server code splits into the page and its x.backend.ts. Data both halves read moves to x.shared.ts beside them; a value that cannot move is copied into each half and reported. A loader or action that returns data without an output schema gets a draft one, typed from what it returns: review that the browser may see every field it declares. The migration reports what still needs a hand, such as an alias with no target that covers a moved file, a specifier that names nothing installed, or a sibling workspace package that names a moved file. It never edits files outside the app.
An API-only app, one that declares "nifra": { "kind": "api" } or has no web config, no routes/ and a backend.ts that exports backend, migrates as one: only backend.ts moves, to backend/app.ts, with the imports from it and to it. Nothing is split or zoned. The migration adds backend to tsconfig include, .nifra/ to .gitignore, and the declaration to package.json when it is missing. Web config without routes/ could be either kind of app, so the migration stops and asks which. Until an app moves, nifra check fails with NF-C038 on its root backend.ts or framework.ts, since no contract gate reads them there.