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.
// 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)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.
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). c.set keeps your plain-object return fully typed.
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.
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.
// 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.
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 paramsReserved 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 reserved-segment 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.
// 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 itThe 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.