zenrpc
v2.0.1
Published
Type-safe RPC for Next.js app and pages router
Readme
ZenRPC
Tiny type-safe RPC for Next.js, now with REST-style endpoint paths.
ZenRPC keeps the mental model simple:
- Define your server API once with
createServer() - Declare reads with
zr.query()and writes withzr.mutation() - Validate every endpoint with
zod - Call endpoints from the client with full TypeScript inference
- Reuse the same server object directly on the server
It works with both the Next.js App Router and Pages Router.
Install
pnpm add zenrpc zodOr scaffold the recommended setup:
pnpm dlx zenrpc@latest init
# or npx zenrpc@latest init
# or yarn zenrpc@latest init
# or bunx --bun zenrpc@latest initBy default, zenrpc init creates:
src/
zenrpc/
api-types.ts
client.ts
server.tsAnd a catch-all route handler:
- App Router:
src/app/api/rpc/[[...path]]/route.ts - Pages Router:
src/pages/api/rpc/[[...path]].ts
Quick Start
1. Define your server
import "server-only";
import zr from "zenrpc";
import { z } from "zod";
export const server = zr.createServer({
tasks: zr.createServer({
list: zr.query({
args: z.object({ taskListId: z.string() }),
cache: { revalidate: 60 },
handler: async (_ctx, { taskListId }) => {
return [{ id: taskListId, text: "Ship ZenRPC" }];
}
}),
add: zr.mutation({
args: z.object({ text: z.string() }),
handler: async (_ctx, { text }) => {
return { id: crypto.randomUUID(), text };
}
})
})
});Every endpoint must be declared with a config object and an explicit args schema.
2. Export the API type
import type { server } from "./server";
export type PublicApi = typeof server;3. Create the client
import zr from "zenrpc";
import type { PublicApi } from "./api-types";
export const rpc = zr.createClient<PublicApi>({
url: "/api/rpc"
});4. Add the route handler
App Router:
import zr from "zenrpc";
import { server } from "@/zenrpc/server";
const handlers = zr.createRouteHandlers(server);
export async function GET(request: Request) {
return handlers.GET(request);
}
export async function POST(request: Request) {
return handlers.POST(request);
}Pages Router:
import zr from "zenrpc";
import { server } from "@/zenrpc/server";
export default zr.createPagesHandler(server);REST Path Mapping
ZenRPC now resolves endpoint calls by path:
rpc.tasks.list({ taskListId: "default" })->GET /api/rpc/tasks/list?input=...rpc.tasks.add({ text: "Write docs" })->POST /api/rpc/tasks/add
The client automatically discovers whether an endpoint uses GET or POST, so you still call it as rpc.tasks.list(...) without extra ceremony.
Calling From The Client
"use client";
import { useEffect, useState } from "react";
import { rpc } from "@/zenrpc/client";
type Task = {
id: string;
text: string;
};
export default function HomePage() {
const [tasks, setTasks] = useState<Task[]>([]);
useEffect(() => {
void rpc.tasks.list({ taskListId: "default" }).then(setTasks);
}, []);
return (
<main>
{tasks.map((task) => (
<div key={task.id}>{task.text}</div>
))}
<button
onClick={async () => {
const newTask = await rpc.tasks.add({ text: "New task" });
setTasks((current) => [...current, newTask]);
}}
>
Add task
</button>
</main>
);
}Per-request headers
Use the client directly when you do not need extra headers:
await rpc.tasks.list({ taskListId: "default" });Scope headers to a single chain like this:
await rpc({ "x-api-key": "example-123312312" }).tasks.list({
taskListId: "default"
});Or with .withHeaders():
await rpc.withHeaders({ authorization: "Bearer token" }).tasks.add({
text: "Secure task"
});If the server throws, the client throws a RpcClientError with:
messagestatuspathdetails
Calling From The Server
The same server object is directly callable on the server with full types:
import { server } from "@/zenrpc/server";
await server.tasks.list({ taskListId: "default" });
await server.tasks.add({ text: "Write docs" });Direct server headers
Bind headers when you want the handler to see request-like metadata:
await server.withHeaders({ "x-api-key": "example-123312312" }).tasks.list({
taskListId: "default"
});You can also use the top-level helper:
import zr from "zenrpc";
import { server } from "@/zenrpc/server";
await zr.withHeaders(server, { "x-api-key": "example-123312312" }).tasks.list({
taskListId: "default"
});Handler Context
Handlers receive (ctx, args):
const getTask = zr.query({
args: z.object({ taskId: z.string() }),
cache: { revalidate: 30, varyHeaders: ["x-api-key"] },
handler: async (ctx, { taskId }) => {
const apiKey = ctx.headers.get("x-api-key");
return {
apiKey,
method: ctx.method,
path: ctx.path,
taskId,
transport: ctx.transport
};
}
});ctx includes:
headersmethodpathtransporturl
Caching Queries
zr.query() supports cache metadata:
zr.query({
args: z.object({ taskListId: z.string() }),
cache: {
revalidate: 60,
varyHeaders: ["x-api-key"],
tags: ["tasks"]
},
handler: async (_ctx, args) => {
return [];
}
});Available fields:
cacheControl: use an exactCache-Controlheaderrevalidate: emitss-maxage=<seconds>, stale-while-revalidatevaryHeaders: emitsVarytags: emitted asx-zenrpc-cache-tagsfor observability
Queries default to Cache-Control: no-store unless you opt into caching.
Provider Compatibility
ZenRPC does not implement a vendor-specific cache layer. Query caching works by returning standard HTTP response headers on GET endpoints:
Cache-ControlVaryx-zenrpc-cache-tags
That means the cache behavior is portable anywhere the response is served behind an HTTP cache or CDN that respects origin headers.
- Vercel: works with Vercel's CDN because it honors
Cache-Control, includings-maxage. - Cloudflare: works when the route is cacheable in Cloudflare. Standard headers are respected, but dynamic/API routes may still require Cloudflare cache rules or Worker-level cache configuration depending on how the app is deployed.
- AWS: works behind CloudFront when the distribution uses origin cache headers. If you vary by request headers, the CloudFront cache policy must also include those headers in the cache key.
- GCP: works behind Cloud CDN when the backend is configured to use origin headers. JSON and HTML responses are not cached by default unless you send explicit cache headers.
Important limits:
- Only
zr.query()endpoints participate in HTTP caching. tagsare only emitted asx-zenrpc-cache-tags; ZenRPC does not perform provider-native tag invalidation.varyHeadersonly helps if your CDN is configured to include those headers in its cache key.- Direct server calls like
await server.tasks.get(...)do not go through an HTTP cache because no HTTP response is involved.
In practice, ZenRPC is compatible with Vercel, Cloudflare, AWS, and GCP caching by relying on standard HTTP semantics, but the actual cache hit behavior depends on the platform sitting in front of your route.
Calling From Outside The App
Queries use GET plus a JSON-encoded input search param:
curl "http://localhost:3000/api/rpc/tasks/list?input=%7B%22taskListId%22%3A%22default%22%7D"Mutations use POST to the endpoint path:
curl -X POST http://localhost:3000/api/rpc/tasks/add \
-H "content-type: application/json" \
-d '{
"text": "Created from another app"
}'Successful responses look like:
{
"ok": true,
"result": {
"id": "task_123",
"text": "Created from another app"
}
}API
createServer()
Builds a nested RPC router from plain objects.
const server = zr.createServer({
health: zr.query({
args: z.object({}),
handler: async () => "ok"
})
});query()
Creates a GET-backed endpoint with mandatory args.
const getPost = zr.query({
args: z.object({ postId: z.string() }),
cache: { revalidate: 120 },
handler: async (_ctx, { postId }) => {
return { id: postId };
}
});mutation()
Creates a POST-backed endpoint with mandatory args.
const addPost = zr.mutation({
args: z.object({ title: z.string() }),
handler: async (_ctx, { title }) => {
return { id: crypto.randomUUID(), title };
}
});createClient()
Creates a typed client from your server type.
const rpc = zr.createClient<PublicApi>({
headers: async () => ({
authorization: "Bearer token"
}),
url: "/api/rpc"
});createRouteHandlers()
Creates App Router GET and POST handlers for a catch-all route.
const handlers = zr.createRouteHandlers(server);
export async function GET(request: Request) {
return handlers.GET(request);
}
export async function POST(request: Request) {
return handlers.POST(request);
}createPagesHandler()
Creates a Pages Router handler for pages/api/rpc/[[...path]].ts.
export default zr.createPagesHandler(server);