Client
Learn how the typed client gives you one callable surface for your whole API, typed end to end.
The client is how you call your API. Every procedure your extensions expose (core WordPress, installed packages, your own) shows up on the client as a typed method under its namespace:
const { data } = await client.posts.list({ query: { perPage: 10 } })
const { data } = await client.woocommerce.products.list()
const { data } = await client.featured.get({ params: { id: 42 } })You never write the client. It mirrors your server's router: add a procedure and
it appears; change a procedure's output and every call site updates with it.
Two clients, one surface
Installation gives you two clients. They expose the same namespaces and call style. What differs is where they run and how they reach the server:
- Server client (
@/lib/kizlo/server) holds the real router in-process and calls your handlers directly, with no HTTP. Use it in Server Components, route handlers, and server-to-server code. - Browser client (
@/lib/kizlo/client) ships no server code and calls your API over HTTP, through the route handler. Use it in Client Components and the browser.
// the server client comes back from createKizlo, alongside the router and handler
export const { client, handler } = createKizlo()import { createKizloClient } from "kizlo/nextjs"
import { contract } from "./server/generated"
// the browser client is built from the generated contract
export const client = createKizloClient(contract)Throughout the docs client is used generically. When it matters which one, the docs say server client or
browser client; in your code it's whichever of the two you imported.
Calling a procedure
Every call returns the same result object, a discriminated union you check instead of a value that might throw:
const result = await client.posts.get({ params: { identifier: "hello-world" } })
if (result.error) {
// result.error is a typed KizloError; narrow on result.error.code
return
}
result.data // ✅ fully typed from the procedure's `output`The argument mirrors the procedure's typed input (params, query, body, headers), autocompleted and
checked against its schemas (see Input & output).
The result is { data, error, success }:
- On success,
datais youroutputanderrorisnull. - On failure,
dataisundefinedanderroris aKizloErrorwhosecodeis typed to exactly the failures that procedure declares.
Known errors are values, not exceptions, so you don't need try/catch for them.
When you'd rather throw (a Server Component that should error its route, say), every method also has a
.call() variant. It returns the data directly and throws the KizloError instead of returning it:
// returns data, throws KizloError on failure
const post = await client.posts.get.call({ params: { identifier: "hello-world" } })Same call, two ergonomics: client.x() for { data, error } you branch on, client.x.call() for a value
you await and let throw. Error codes stay typed end to end either way; see
Errors for how a procedure declares them.
Scope on the client
A procedure's scope, set when you author it, decides how it shows up here:
internalis server-only. It's dropped from the browser client's types entirely and rejected at runtime if you reach for it anyway, so it only ever appears on the server client. The built-inseoprocedures areinternal.apiandremoteappear on both clients. They differ in types over the wire:apireturns plain JSON, so aDatearrives as astring;remotedeserializes back to your original types, so aDatestays aDate. The client's return types match each case.
import { client } from "@/lib/kizlo/server"
const { data } = await client.seo.sitemaps.index() // ✅ internal: fine on the server clientimport { client } from "@/lib/kizlo/client"
client.seo.sitemaps.index() // ❌ omitted from the browser client; rejected at runtimeYou configure none of this on the client. The browser client reads each procedure's scope from the contract and dispatches accordingly. Authoring scope lives under Scope; here it's just the behaviour you get.
The contract
The server client can call handlers directly because it runs on the server and has the router. The browser can't: it has no access to your handlers, validation schemas, or WordPress credentials, and it shouldn't. So how is the browser client fully typed against a server it can't import?
The contract: a plain JSON object generated from your extensions' routers. It's the one piece both sides agree on, derived from a single source of truth, your server router.
Generated from your routers
The server router is the only place your API is defined. The kizlo CLI
walks every extension's router and emits the contract into server/generated/:
-
contract.jsonis the contract itself. For every procedure it stores the endpoint configuration: thescopeand therouteit's reached by (method+path), nested to mirror the router. No handlers, no schemas, no secrets, just the call config the client needs to dispatch each request. -
generated/index.tsis a small barrel that imports that JSON and re-types it as your server'stypeof router, so the client is typed end to end:src/lib/kizlo/server/generated/index.ts import type { router } from ".." import contractJson from "./contract.json" export const contract = contractJson as unknown as typeof router
The JSON is the real artifact; the cast only layers types on top. At build time that gives contract the
full type of your router: every namespace, input, output, and error code flows into the browser client. At
runtime it's just the small JSON object. The server code is never bundled, yet the client stays fully
typed.
This is why you regenerate the contract when procedures change. kizlo generate does it once for builds and
CI; kizlo dev does it on every save in development, so the client stays typed against the latest server.
See CLI.
A clean call surface
Because the contract already knows each procedure's method and path, you never pass them. Calling your API reads like reaching into a nested object and invoking a function: no URLs, no HTTP verbs, no endpoint strings.
// the contract knows this is GET /featured/{id}, so you just call it
const { data } = await client.featured.get({ params: { id: 42 } })You hand the contract to createKizloClient and it becomes the client's routing table. For each call, the
client looks up the procedure in the contract and dispatches by its scope:
apisends a REST request to the procedure'smethod+path.remotesends an RPC request.internalis refused: it never leaves the client.
The server client skips all of this. It has the real router in-process, so it never touches the contract or the network. Same surface, same types, a different path to the handler.