Docs

API & typed client

Nifra is contract-first: you describe an HTTP API once - inline or as a standalone contract - and its types flow to the client with zero codegen. Inputs are validated at the trust boundary by any Standard Schema (zod, valibot, arktype, …); outputs are inferred end-to-end.

Everything on this page is just @nifrajs/core - no frontend, no build step. Use Nifra as a standalone backend the way you'd use Hono or Elysia, deploy it to any runtime, and reach for the frontend adapters only if and when you go full-stack.

An inline server

The chainable builder is the quickest start. Attach a body or query schema to a route and it's parsed-and-validated before your handler runs - c.body and c.query are the validated types, and a bad request gets a structured 422 automatically. Path params (:id) are typed from the pattern.

TS
// doc-check: skip - uses the third-party `zod` schema lib (any Standard Schema works); install it to run this.
import { server } from "@nifrajs/core/server"
import { z } from "zod"   // any Standard Schema works: zod, valibot, arktype…

export const app = server()
  .get("/users/:id", (c) => ({ id: c.params.id }))                 // c.params is typed from the path
  .post("/users", { body: z.object({ name: z.string().min(1) }) }, // body validated at the boundary
    (c) => ({ created: c.body.name }))                             // c.body is the validated type
  .get("/search", { query: z.object({ page: z.string() }) },       // query validated too
    (c) => ({ page: c.query.page }))
  .listen(3000)

A route is chosen for the path a URL parser resolves the request to, on every runtime. . and .. segments, written as-is or as %2e, and a backslash are resolved first: /users/../posts is served by /posts, never by /users/:id/posts, and c.req.url shows /posts. An encoded slash (%2f) stays part of its segment.

Optional path params

A path can end in optional params, written :name?. The route then serves the path with the param and without it, and the param is typed string | undefined:

TS
import { server } from "@nifrajs/core/server"

export const app = server()
  // GET /users and GET /users/42
  .get("/users/:id?", (c) => (c.params.id === undefined ? { all: true } : { id: c.params.id }))
  // GET /archive, GET /archive/2026 and GET /archive/2026/09
  .get("/archive/:year?/:month?", (c) => ({ year: c.params.year, month: c.params.month }))
  • Only at the end, only whole segments. Several in a row are filled left to right, so /archive/:year?/:month? serves three paths and never a month without a year. A ? anywhere else (/a/:id?/b, /v-:id?) is ordinary text, which no request path can contain; nifra check reports it as NF-C026.
  • It is one route per path it serves. /users/:id? is /users and /users/:id with the same handler, schema and hooks, and that is how it appears in app.routes(), OpenAPI (two operations; the shorter one has no id parameter) and the typed client (api.users.get() and api.users({ id }).get()).
  • An absent param is absent, not an empty string: it is missing from c.params. A params schema has to allow that, or the shorter path answers 422.
  • A path can be registered once. GET /users next to GET /users/:id? throws at registration, because both serve GET /users. Nothing of a rejected route is left registered.

Other param modifiers (:id+, :id*, :id(\d+)) are not part of the path grammar and match as literal text. Where that text is intended, put // nifra-expect NF-C026: <reason> above the registration.

Param constraints

A param can say which values it accepts, written in braces after the name. A request whose value does not fit is not served by that route, so it falls to another route or to a 404. The param is still a string.

TS
import { server } from "@nifrajs/core/server"

export const app = server()
  // /users/me is its own route; /users/42 is this one; /users/ada is a 404
  .get("/users/me", () => ({ me: true }))
  .get("/users/:id{[0-9]+}", (c) => ({ id: Number(c.params.id) }))
  // A list of values, and a count: exactly two capital letters
  .get("/img/:size{thumb|full}/:file", (c) => ({ size: c.params.size, file: c.params.file }))
  .get("/countries/:code{[A-Z]{2}}", (c) => ({ code: c.params.code }))
  // Inside a segment, and optional at the end of a path
  .get("/files/:name.:ext{png|jpg}", (c) => ({ name: c.params.name, ext: c.params.ext }))
  .get("/posts/:page{[0-9]+}?", (c) => ({ page: c.params.page ?? "1" }))

A constraint is one of two things:

  • One character class, with an optional count. [0-9], [a-z0-9_-], \d or \w, followed by nothing (exactly one character), +, {n}, {n,} or {n,m}. A class holds letters, digits, ranges of them, \d, \w and the characters . _ ~ ! $ & ' ( ) + , ; = @ -. There is no negated class, and a count starts at one.
  • A list of two or more values, separated by |: :ext{png|jpg|webp}. A value is letters, digits, ., _, ~ and -.

Anything else in braces (:id{int}, :id{[0-9]+|[a-z]+}, :id{.+}) is not a constraint. It is literal text, as before, and nifra check reports it as NF-C026. In a JavaScript string \d is written "\\d".

  • The value is checked as it was sent, before percent-decoding. /users/4%32 does not fit :id{[0-9]+}, although it decodes to 42. A handler on a constrained route therefore never sees a decoded character the constraint does not allow. A broader route beside it still serves that request and sees 42.
  • The narrowest route answers, whatever the order of registration. Literal text is tried first, then a list, then a class, then a bare :param, then a wildcard. Between two constraints of a kind, the one that accepts fewer values is tried first, so :id{[0-9]+} is tried before :id{[0-9a-f]+}.
  • A method the narrowest route does not have is a 405. With GET /users/:id{[0-9]+} and POST /users/:name, POST /users/42 answers 405 with Allow: GET, HEAD. It is the same rule a literal route follows beside a param route.
  • Two spellings of one constraint are one route. :id{[0-9]+}, :id{\d+} and :id{[0-9]{1,}} accept the same values, so registering two of them for one method throws DUPLICATE_ROUTE, and different methods on them share one Allow list.
  • Inside a segment, the text around the params is placed first. /files/:name.:ext{png|jpg} splits a.b.png at its first dot, into a and b.png, and then the constraint refuses it. The router does not look for another split.
  • Overlap checks know about constraints. /users/me and /users/:id{[0-9]+} serve no request in common, so nifra check (NF-C024) has nothing to report. A constrained route beside a bare :param route still overlaps and is still reported; mark the pair with // nifra-expect NF-C024: <reason> where it is intended.
  • OpenAPI and the typed client use the bare name. The path is /users/{id}; the parameter's schema is { type: "string", pattern: "^[0-9]+$" } for a class and an enum for a list, unless the route declares a params schema. The client call is api.users({ id: "42" }).get(). The client does not check the value: a value that does not fit is sent, and answered by whichever route it does match. Where two param routes share a position, give their params different names so that the call picks the route by name.
  • Not in a page route's file name. @nifrajs/web refuses a constraint in routes/, because links and prerendered paths are rebuilt from the param's name. Check the value in the page's loader.

Several methods, custom methods (all, method)

all() registers one handler under every standard method, and method() under the method or methods you name, including one outside the standard seven. Both come from @nifrajs/core/methods and are applied with use():

TS
import { server } from "@nifrajs/core/server"
import { all, method } from "@nifrajs/core/methods"

export const app = server()
  // GET, POST, PUT, PATCH, DELETE, HEAD and OPTIONS /echo
  .use(all("/echo", (c) => ({ method: c.req.method })))
  // A method outside the standard seven
  .use(method("PURGE", "/cache/:key", (c) => ({ purged: c.params.key })))
  // A list of methods, one handler
  .use(method(["GET", "POST"], "/search", (c) => ({ q: c.query.get("q") })))
  • Each method is an ordinary route. It shows in app.routes(), takes the same schema and hooks, works inside group(), and collides with a route already registered for that method and path. One call is one registration: if any of its routes is refused, none is added.
  • There is no catch-all. all() is the seven standard methods, and a request with any other method is still a 405 with an Allow header. To hand every request under a path to another handler whatever its method, use mount().
  • A method name is case-insensitive and registered uppercase: letters, digits and hyphens, starting with a letter, at most 32 characters. TRACE, CONNECT and TRACK are refused, and so is any other value, with INVALID_METHOD when method() is called.
  • A custom method has no typed-client call and no OpenAPI entry. The standard methods in the same call keep both. Call a custom one with fetch(url, { method: "PURGE" }).
  • An assurance rule names standard methods only. A rule with methods never matches a custom-method route; one with exceptMethods does, unless the route is under a method it lists. securityBaseline() holds a custom method to its mutation rules, CSRF included, and csrf() publishes its evidence for one. Left unmatched, a route is reported as unclassified-route.

Whether a custom method reaches the app is up to the runtime's HTTP parser. PROPFIND, REPORT, PURGE and QUERY arrive on Bun, Node, Deno and workerd. A token the parser does not know is answered by the runtime itself on Bun, Node and workerd, before the app sees it. The compact server from @nifrajs/edge has no use() and takes neither function.

Status, headers & cookies (c.set)

Return a plain object and Nifra serializes it with a 200 (or 204 when you return undefined). To shape the response without giving up the typed return, use c.set: assign c.set.status, mutate c.set.headers, or call c.set.cookie(name, value, opts?) - cookies are HttpOnly + Secure + SameSite=Lax + Path=/ by default, and c.set.deleteCookie(name) expires one. It's lazy: a handler that never touches c.set allocates nothing.

TS
export const app = server()
  .post("/login", { body: z.object({ email: z.string() }) }, (c) => {
    c.set.status = 201                       // override the default 200 (204 when you return undefined)
    c.set.headers["x-request-id"] = reqId    // add/override a response header
    c.set.cookie("session", token, {         // HttpOnly + Secure + SameSite=Lax + Path=/ by default
      maxAge: 60 * 60 * 24,
    })
    return { ok: true }                      // still a plain object - the typed client stays in sync
  })
  .post("/logout", (c) => {
    c.set.deleteCookie("session")            // expire it immediately
    return { ok: true }
  })

Prefer c.set over returning a raw Response. A Response return makes the typed client infer data: never, so you silently lose drift detection for that route (nifra check flags it, NF-C007). c.set keeps your plain-object return fully typed. The check reads the functions a route registration (on server() or createWebApp()) or an implement() map passes, and a local function one returns a call to. It counts a Response held in a local before it is returned, a handler's c.json()/c.text(), and a call to a function imported from a relative module that returns one, reported at each route that returns it. A helper that answers an auth stage, or a handler registered from a list of route objects, is not read.

A Response a handler or hook returns or throws still gets the c.set.headers it lacks and the queued cookies, so a guard's cache-control: no-store reaches it. Its own headers win, content-type, content-length, content-encoding, content-range and transfer-encoding come from it alone, and c.set.status does not change its status. A Response shared across requests is copied first, so one request's headers never reach another.

When you genuinely want a Response - an error short-circuit from a derive / beforeHandle (auth, rate limits) - c.json(body, status?) and c.text(body, status?) build one in a line: throw c.json({ error: "unauthorized" }, 401) instead of new Response(JSON.stringify(…), { status: 401, headers: … }). The second arg is a status number or a full ResponseInit, and both work whether you return or throw them. (In a route's happy path keep returning a plain object, as above, so the typed client stays in sync.)

The request is on c.req, also available as c.request - the same name a page loader/action receives (which in turn also accepts ctx.req), so one name works in both places.

Typed error responses

Declare a route's error bodies in errors, keyed by status, and answer with status(code, body, { headers }) from @nifrajs/core/server. It ends the request from a handler or any hook, returned or thrown, without building a Response: the body is serialized like a plain return, the headers ride with it, and queued cookies still apply. On the typed client, checking res.status === 409 narrows res.data to the declared 409 body; a status the route does not declare falls into one arm whose data is unknown.

The code may be a runtime value. Typed as a literal union (200 | 409), it splits into one arm per code. Typed as plain number, it cannot be sorted into success or error, so its body types res.data: a 2xx from that return can only carry it.

TS
import { client } from "@nifrajs/client"
import { server, status } from "@nifrajs/core/server"
import { t } from "@nifrajs/schema"

const conflict = t.object({ code: t.literal("conflict"), retryAfter: t.number() })
const locked = new Set(["7"])

export const app = server().post("/orders/:id/pay", { errors: { 409: conflict } }, (c) => {
  if (locked.has(c.params.id))
    // The body is the typed contract; the header serves proxies and plain HTTP clients.
    return status(409, { code: "conflict", retryAfter: 5 }, { headers: { "retry-after": "5" } })
  return { paid: true }
})

const api = client<typeof app>("https://api.example.com")
const res = await api.orders({ id: "7" }).pay.post()
if (!res.ok && res.status === 409) {
  const wait: number = res.data.retryAfter // narrowed to the declared 409 body
}

The client's Result carries the body, not the response headers. A value the caller acts on, such as how long to wait, belongs in the declared body; set the matching header too when proxies or plain HTTP clients read it.

Typed request cookies (schema.cookies)

c.cookies is the request's Cookie header as a name-to-value record, parsed on first read: values are URL-decoded, and when a name repeats the first one wins. Declare a cookies schema on the route and it is validated before the handler, like query or body: a missing or malformed cookie is a 422 (onValidationError receives the kind "cookies"), and c.cookies becomes the schema's typed output.

TS
import { server } from "@nifrajs/core/server"
import { t } from "@nifrajs/schema"

export const app = server().get(
  "/dashboard",
  { cookies: t.cookies({ session: t.string(), page: t.optional(t.integer()) }) },
  (c) => ({
    session: c.cookies.session, // string - a request without it is a 422 before this runs
    page: c.cookies.page ?? 1, // number | undefined - coerced from the cookie's text
  }),
)

Use t.cookies for this slot. Cookie values are always text, so it coerces declared t.integer() / t.number() / t.boolean() fields, and it is open: a browser sends every cookie the site has set (analytics, consent, other routes' sessions), so the ones a route does not declare pass through rather than failing the request. A strict t.object here would reject real traffic. A signed cookie arrives as its raw value.signature text - declare it as t.string() and verify it with unsignValue; when a session must not be ambiguous, reject a repeated name with hasDuplicateCookie. The OpenAPI document lists each declared field as an in: cookie parameter.

Typed request headers (schema.headers)

A headers schema is validated before the handler like the other slots: a missing or malformed header is a 422 (onValidationError receives the kind "headers"), and c.headers becomes the schema’s typed output. Every client sends headers no route declares - host, user-agent, accept - so a closed t.object validates only the names it declares, each read case-insensitively, which is also how the OpenAPI document lists them: one in: header parameter per declared name.

TS
import { server } from "@nifrajs/core/server"
import { t } from "@nifrajs/schema"

export const app = server()
  // Closed: validates only the names it declares, whatever else the client sends.
  .get("/reports", { headers: t.object({ "x-tenant": t.string() }) }, (c) => ({
    tenant: c.headers["x-tenant"], // string - a request without it is a 422
  }))
  // Open: every header arrives, names lower-cased, declared values coerced from text.
  .get("/jobs", { headers: t.headers({ "x-retry": t.optional(t.integer()) }) }, (c) => ({
    retry: c.headers["x-retry"] ?? 0, // number
  }))

t.headers is the open form: it receives every header with its name lower-cased, so it takes lower-case names only, and like t.query it coerces declared t.integer() / t.number() / t.boolean() fields from text. A schema nifra cannot read the names of - a Zod .strict() object or a Valibot strictObject - receives every header, so declare it open.

Requests no route matched (notFound)

A path no route matches is answered 404 with { "ok": false, "error": "not_found" }. To answer it yourself, apply notFound(handler) from @nifrajs/core/not-found. The handler returns a Response, or undefined to keep the default body.

TS
import { notFound } from "@nifrajs/core/not-found"
import { server } from "@nifrajs/core/server"

export const app = server()
  .get("/users/:id", (c) => ({ id: c.params.id }))
  .use(
    notFound(({ pathname, header }) => {
      // A section that moved: a redirect is sent as the redirect it is.
      if (pathname.startsWith("/old/")) {
        return new Response(null, {
          status: 308,
          headers: { location: `/docs/${pathname.slice(5)}` },
        })
      }
      // A browser gets a page. It is returned as a 200 here and sent as a 404.
      if (header("accept")?.includes("text/html")) {
        return new Response("<h1>Nothing here</h1>", {
          headers: { "content-type": "text/html; charset=utf-8" },
        })
      }
      return undefined // anything else keeps the default { ok: false, error: "not_found" }
    }),
  )
  • It answers a 404 and nothing else. A path that exists under another method is still a 405 with Allow, a malformed path parameter is still a 400, and a 404 a route or a mounted app returned itself is left alone.
  • The status stays honest. A 2xx answer is sent as a 404 with the body and headers you gave it, so a crawler or a cache never sees a missing page as a hit. A 3xx, 4xx or 5xx answer is sent unchanged.
  • It sees the request line and headers, never the body. The input is method, url, pathname, headers, header(name), signal and platform. pathname is the path as it was sent, not percent-decoded: escape it before writing it into HTML, and never use it as a redirect target without checking it.
  • A fault is a plain 500. A throw, a rejection, or a returned value that is not a Response is logged once and answered { "ok": false, "error": "internal_error" } - the error's text never reaches the client. A thrown Response is an answer, as it is in a route.
  • It is bounded. With requestTimeoutMs set, an async handler that outlives it has signal aborted and the request answered 503. A deadline header on the request is not consulted for a request no route matched.
  • The answer takes the normal response path: fixed response headers and onResponse hooks apply to it like any other response.

One handler per server: a second notFound() throws, as does one applied inside a group() (apply it to the parent) or after listen(). merge() does not carry a merged server's handler across. To serve unmatched paths - a single-page app's shell, or another app behind this one - register a wildcard route or a mount instead: those are matches, so they keep their own status, the request body, and the full route lifecycle.

Contract-first (defineContract + implement)

For larger apps - or when the contract is shared across services - declare it with defineContract (methods, paths, schemas; no handlers), then implement it. Handlers are checked against the contract, so a wrong path param, body, or return type is a compile error. The result is the same app the inline builder produces.

TS
// doc-check: skip - uses the third-party `zod` schema lib + an illustrative `users` repo; install zod to run this.
import { defineContract, implement } from "@nifrajs/core/contract"
import { z } from "zod"

// 1. Declare the contract - methods, paths, and input schemas, no handlers.
//    Share this object between server and (optionally) other services.
export const contract = defineContract({
  listUsers: { method: "GET", path: "/users" },
  getUser:   { method: "GET", path: "/users/:id" },
  createUser:{ method: "POST", path: "/users", body: z.object({ name: z.string() }) },
  search:    { method: "GET", path: "/search", query: z.object({ page: z.string() }) },
})

// 2. Implement it - handlers are checked against the contract (path params, body, query all typed).
export const app = implement(contract, {
  listUsers: () => users.all(),
  getUser:   (c) => users.find(c.params.id),
  createUser:(c) => users.create(c.body.name),
  search:    (c) => ({ page: c.query.page }),
})

The end-to-end-typed client

@nifrajs/client takes the server's type (client<typeof app>) and exposes a fluent, fully-typed proxy - no generated SDK. Path params are call arguments; the body and query are typed from the route's schema.

TS
import { client } from "@nifrajs/client"
import type { app } from "./server"

// Infers the server's types directly - no codegen, no schema duplication.
const api = client<typeof app>("https://api.example.com")

const { data } = await api.users({ id: "1" }).get()         // path param → /users/1
await api.users.post({ name: "Ada" })                       // POST body
await api.search.get({ query: { page: "3" } })              // query string
await api.users({ id: "1" }).posts({ postId: "2" }).get()   // nested params
await api.files("report.json").get()                        // /files/:name.json → the segment as sent

Chain the routes the client calls. Each registration returns a new type that records its route, so typeof app holds only the routes chained onto the expression app is assigned from. app.get("/x", handler) written as a statement of its own still serves /x, but the client has no call for it. Chain the registration, or declare the routes on their own server() and merge() it. nifra check reports such a statement on a server whose type is exported or read by typeof (NF-C045, advisory). The same holds for createWebApp: its type holds the routes chained onto it, and the routes of the api it mounts keep their type on that backend server.

A segment that is part literal, part param - /files/:name.json, /post-:id, /v:major.:minor - has no single param to name, so it is called with the segment as the request carries it: api.files("report.json"), api("post-42"), api("v1.2"). The argument is typed as the segment's literal text around any string, so api.files("report.txt") does not compile, and it is sent as one encoded segment: a / in it never adds a path level. RequestPath from @nifrajs/core is the same reading for a whole path: RequestPath<"/files/:name.json"> is `/files/${string}.json`.

Reserved proxy keys. The client proxy resolves a fixed set of property names before path segments: the seven HTTP verbs (get/post/put/patch/delete/head/options, any casing) call the route, and subscribe, ws, index, and then (exact match) are the SSE, WebSocket, root-path, and thenable-guard keys. A route whose path contains a static segment spelling one of these - .post("/api/delete", …) - cannot be reached by dot access: api.delete.post resolves the delete verb, not the segment. The typed spelling is a call on the parent node - api.api("delete").post() sends POST /api/delete - the same call params use, accepting exactly the colliding segment names. The client type rejects the dot access at compile time with that guidance, and nifra check reports the collision (NF-C018, advisory). Prefer a verb-free segment (e.g. /api/remove) when you control the path; mark a route served only to non-typed-client consumers with // nifra-expect NF-C018: <reason> above its registration.

Results never throw

Every call resolves to a discriminated Result: branch on ok (or destructure { data, error }). Success carries the typed data; failure carries a structured ApiError - a stable error code plus validation issues - and the HTTP status. No try/catch, no surprise exceptions on a 404 or 422.

TS
// The client NEVER throws - every call returns a discriminated Result:
const res = await api.users({ id: "1" }).get()
if (res.ok) {
  res.data        // ^? { id: string }   (typed success body)
} else {
  res.status      // the HTTP status
  res.error.error // a stable error code, e.g. "not_found"
  res.error.issues // validation issues (message + path), when the body/query was rejected
}

// Or destructure { data, error } directly:
const { data, error } = await api.users.post({ name: "" })  // 422 if the schema rejects it

A call that gets no HTTP answer it can read has status: 0 and one of five codes: network_error, timeout, response_too_large, malformed_response, or invalid_path. malformed_response means the body did not decode as its content type says - a proxy's HTML error page labelled JSON, say. invalid_path means a param value was . or ..: a URL reads those as steps to another path, in every encoding, so the client sends nothing rather than reach a route the call does not name. .subscribe() reports it through onError and closes; .ws() throws.

The same client runs in the browser and on the server. During SSR, a route's loader calls it in-process (no network hop) via ctx.api. Next: file routing, loaders & actions, and plugins.