npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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 with zr.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 zod

Or scaffold the recommended setup:

pnpm dlx zenrpc@latest init
# or npx zenrpc@latest init
# or yarn zenrpc@latest init
# or bunx --bun zenrpc@latest init

By default, zenrpc init creates:

src/
  zenrpc/
    api-types.ts
    client.ts
    server.ts

And 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:

  • message
  • status
  • path
  • details

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:

  • headers
  • method
  • path
  • transport
  • url

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 exact Cache-Control header
  • revalidate: emits s-maxage=<seconds>, stale-while-revalidate
  • varyHeaders: emits Vary
  • tags: emitted as x-zenrpc-cache-tags for 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-Control
  • Vary
  • x-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, including s-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.
  • tags are only emitted as x-zenrpc-cache-tags; ZenRPC does not perform provider-native tag invalidation.
  • varyHeaders only 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);