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.
import { server } from "@nifrajs/core"
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.
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.
import { defineContract, implement } from "@nifrajs/core"
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 paramsResults 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.