Docs

Loaders & actions

Loaders fetch data on the server; actions mutate it. Both live in a route's .backend.ts half, never in the page, and both are typed against your contract - the client never throws, it returns { data, error }.

Loaders

A route's loader runs on the server and calls your backend in-process during SSR - no network hop. Its loaderOutput schema is the contract with the page: the page receives that shape as data, and nothing else the loader returns leaves the server. Declare a timestamp as t.date() and the page gets a Date in the server render and in the browser alike; under t.string() it travels as its ISO string. t.json() declares a field that is any JSON value, passed through whole - a tool-call payload or a settings blob the page does not pick apart. A loader without a schema is flagged by nifra check. See Project structure for the route pair.

TS
// routes/users/[id].backend.ts - a loader runs on the server: in-process during SSR (no network
// round-trip), typed against your backend contract. This file never reaches the browser.
import { t } from "@nifrajs/schema"
import { notFound } from "@nifrajs/web"
import type { Route } from "./+types/[id]"

// The page receives exactly this shape. Anything else the loader returns stays on the server.
export const loaderOutput = t.object({ id: t.string(), name: t.string() })

// Route.Loader checks what this returns against loaderOutput at compile time.
export const loader: Route.Loader = async ({ params, api }) => {
  const res = await api.users({ id: params.id }).get()
  if (!res.ok) throw notFound()
  return res.data
}
TS
// routes/users/[id].tsx - the page. `data` is typed from loaderOutput.
import type { Route } from "./+types/[id]"

export default function User({ data }: Route.ComponentProps) {
  return <h1>{data.name}</h1>
}

Annotating the loader with Route.Loader from the generated ./+types/<name> checks what it returns against loaderOutput at compile time: a field of the wrong type, a defer(...) whose value does not match its t.deferred(...) field, or any data from a loader with no loaderOutput is a type error. A redirect, a Response and returning nothing stay allowed. Route.Action does the same against actionOutput, revalidate(paths, data) included. A field the schema does not declare is not a type error: the guard drops it before it ships. The annotation makes the loader's type the schema's, so nifra schemas cannot read what an annotated loader returns. It reports a generated schema over one as unchecked: annotate a loader whose schema you write, and leave a loader whose schema is generated unannotated.

nifra schemas drafts the schemas from your code for review: it reads each loader's and action's TypeScript return type, and with --write puts a schema above every one that has none. It leaves out a field named like a credential and anything that is not JSON, says so in a comment, and marks what it wrote with a nifra:generated line. While that line stays, a later nifra schemas --write keeps the schema in step with the return type, and nifra check fails (NF-C033) when the two have drifted apart, or when the schema under the line was edited (reformatting it is not an edit); delete the line to own the schema by hand. A hand-written schema is never rewritten: the report lists the returned fields it drops, by path. Nothing is derived at request time - the schema in the file is the one the guard enforces. nifra fix --code NF-C030 writes the same draft for one flagged route, and nifra migrate layout drafts one for each route it splits whose loader or action returns data without a schema.

A field named like a credential (password, token, apiKey) fails the route when it loads unless its schema is wrapped in t.declassified("why it may reach the browser", ...); a schema library that exposes no JSON Schema, such as valibot, gets that check only from nifra check, and only where the schema is written out in the backend half. The contract covers data, not responses you build: a loader or action returning a 2xx Response is refused, but a redirect or an error-status Response is sent exactly as written, so its body must hold nothing the browser may not see.

Actions & revalidation

An action handles the route's POST. After a client-side submit the page's loader revalidates (no full reload); with JS disabled the native form POST re-renders - progressive enhancement, same code. What the action returns reaches the page as actionData, bounded by actionOutput.

TS
// routes/users/new.backend.ts - an action handles the route's POST.
import { t } from "@nifrajs/schema"
import type { Route } from "./+types/new"

export const actionOutput = t.object({ ok: t.boolean() })

export const action: Route.Action = async ({ api, request }) => {
  const form = await request.formData()
  await api.users.post({ name: String(form.get("name")) })
  return { ok: true }                  // the page's loaders revalidate automatically
}
TS
// routes/users/new.tsx - works with JS off (a native form POST) and on (a client submit, no reload).
export default function NewUser() {
  return (
    <form method="post">
      <input name="name" />
      <button type="submit">Create</button>
    </form>
  )
}

A form can post to another site and the browser sends that site's cookies with it, so an action refuses a POST whose Origin is another site's with 403, before middleware, gates or the action run. A request without an Origin, such as one from a server, passes, and so does a form on the app's own page sent under Referrer-Policy: no-referrer, which arrives as Origin: null with Sec-Fetch-Site: same-origin. Same-origin is judged from the request URL's host, so a proxy in front must forward the Host header. A route that takes posts from elsewhere, such as an identity provider's form_post callback, exports allowedOrigins from its backend file: an array of exact origins, or a function that receives the origin (null when absent). It checks where the browser says the request comes from, not who sent it, so keep the action's own authentication.

Layout loaders on navigation

A _layout.backend.ts can have a loader too; its output reaches the layout component as data. On a client navigation that keeps the layout on screen, the browser already holds that data, so the loader runs again only when a param the layout owns changes (org for orgs/[org]/_layout.tsx) or the query changes. After an action, every loader runs. A layout that exports gate = true runs on every request; a layout loader without it is not an authorization boundary: a client navigation can skip it, and an action below it runs without it. Put a check that refuses access in a gate or in middleware. In development, nifra warns the first time a layout loader without gate refuses a request with a redirect, a Response, or a 401 or 403, and nifra check reports one whose loader refuses in its own body (NF-C042). A _layout.backend.ts that exports middleware guards a directory, with or without a layout component - see Route middleware.

A layout's shouldRevalidate overrides that default. It runs on the server and receives defaultShouldRevalidate with the URL and params being left and the ones being loaded. Returning false keeps the browser's copy. It is not asked on a document request, after an action, or for a gate. A page's loader runs on every navigation: query keys a page never reads belong in its searchClientKeys, which skips the request.

TS
// routes/orgs/[org]/_layout.backend.ts
import { t } from "@nifrajs/schema"
import type { ShouldRevalidate } from "@nifrajs/web"

export const loaderOutput = t.object({ name: t.string() })

export async function loader({ params }: { params: { org: string } }) {
  const res = await fetch("https://api.example.com/orgs/" + encodeURIComponent(params.org))
  const org = (await res.json()) as { name: string }
  return { name: org.name }
}

// The org sidebar ignores the query: filtering a list beneath it keeps the org data,
// switching orgs loads it again.
export const shouldRevalidate: ShouldRevalidate = ({ currentParams, nextParams }) =>
  currentParams.org !== nextParams.org

Content collections

A content collection turns a folder of Markdown into a typed, schema-validated data source - no hand-rolled readdir + frontmatter parsing. defineCollection (from @nifrajs/content/fs) validates each file's frontmatter against a t schema (a typo'd field fails the build, not production) and renders the Markdown body to HTML; all() / get(slug) return fully-typed entries you read in a loader. The collection lives in backend/: it reads the filesystem, so a page cannot import it. Framework-agnostic - the rendered html drops into any adapter (React dangerouslySetInnerHTML, Vue v-html, Svelte {@html}).

TS
// backend/content.ts - a typed, validated collection over a folder of Markdown.
import { defineCollection } from "@nifrajs/content/fs"
import { t } from "@nifrajs/schema"

export const blog = defineCollection({
  dir: "content/blog",
  schema: t.object({ title: t.string(), date: t.string(), draft: t.boolean() }),
})

// routes/blog/index.backend.ts - typed + validated entries, no manual fs/frontmatter parsing:
export const loaderOutput = t.object({
  posts: t.array(t.object({ slug: t.string(), title: t.string(), html: t.string() })),
})

export async function loader() {
  const posts = (await blog.all()).filter((p) => !p.frontmatter.draft)
  posts.sort((a, b) => b.frontmatter.date.localeCompare(a.frontmatter.date))
  return { posts: posts.map((p) => ({ slug: p.slug, title: p.frontmatter.title, html: p.html })) }
}

For optimistic UI, concurrent fetchers, and a keyed query cache, the same primitives compose on both React and Solid.