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
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.

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). 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.

TS
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.

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

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

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.

Proudly built with Nifra — server-rendered on Cloudflare Pages.MIT