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

next-zero-rpc

v0.4.10

Published

Type-safe fetch for Next.js API routes — zero dependencies, zero lock-in, ~0.9KB runtime.

Readme

npx next-zero-rpc init

That's it. Five files. Full type safety. ~0.9 KB runtime.

What it does

next-zero-rpc gives you compile-time type-safe fetch for your Next.js App Router API routes — without changing how you write backends.

// ✅ Full autocomplete on paths, methods, and response types
const [data, err] = await apiFetch("/api/users/123", { method: "GET" });

// ✅ TypeScript errors on invalid paths
const [data, err] = await apiFetch("/api/typo", { method: "GET" }); // ← compile error

// ✅ TypeScript errors on invalid methods
const [data, err] = await apiFetch("/api/users/123", { method: "DELETE" }); // ← error if DELETE not exported

// ✅ Multi-variable template literals — all segments resolve independently, still fully type-safe
const orgId = "org-1";
const projectId = "proj-42";
const taskId = "task-99";
await apiFetch(`/api/orgs/${orgId}/projects/${projectId}/tasks/${taskId}`, { method: "PATCH" });

// ✅ Error type narrowing — err.code autocompletes only the errors THIS route can return
if (err) {
  const code = err.code;
  switch (code) {
    case "auth:forbidden": // ← only if this route uses createApiError("auth:forbidden", ...)
    case "system:database-error": // ← only if this route uses createApiError("system:database-error", ...)
    case "system:unknown-error": // ← always included as a fallback from apiFetch itself
      break;
    default:
      assertNever(code); // ← TypeScript errors if you miss a case
  }
}

When to use this

Use next-zero-rpc when:

  • You're already writing plain Next.js App Router route handlers and want type-safe fetch calls without restructuring your backend
  • Per-route error code narrowing matters to you
  • You want a tiny client footprint (~0.9 KB) and zero ongoing npm dependencies

The Philosophy

You own the code, not the library. next-zero-rpc is not a locked-in framework—it's a philosophy, a paradigm, and a set of methods for doing things. When you run init, we give you five files. From that moment on, they are yours to modify, extend, or delete.

  • Zero vendor lock-in — There is no black-box node_modules dependency dictating your architecture. You own the fetch client, the error codes, and the registry generator. If the maintainer disappears tomorrow, you're not blocked.
  • Zero boilerplate — You write standard Next.js API route handlers using simple response helpers — no decorators, no schema registrations, no complex abstractions. The codegen reads what already exists and builds the type bridge automatically.
  • Not a framework — It's a type bridge. It infers what your route handlers return and gives your client code full type safety over those responses.
  • Validation is yours — Input validation (Zod, Valibot, Arktype, manual checks) stays inside your route handler where it belongs. This library doesn't impose a validation layer — that's a deliberate design choice.
  • Non-invasive — You don't adopt a new API definition pattern. Your routes are regular Next.js routes. The library is invisible.
  • Architectural freedom — Want to use Server-Sent Events (SSE), WebSockets, GraphQL, or custom streaming patterns alongside it? Go ahead. next-zero-rpc just drops five plain files (apiClient.ts, apiRegistry.ts, pathResolver.ts, responses.ts, update-api-registry.mjs) into your project; it doesn't take over your Next.js server. Study the generated files yourself to verify this claim—there's no hidden magic.

Setup

1. Install

npx next-zero-rpc init

During initialization, the CLI will ask you to choose your preferred response format: Tuples ([data, error]) or Objects ({ data, error }).

This copies 5 files into lib/next-zero-rpc/ (or src/lib/next-zero-rpc/ if your project uses the src/ directory):

| File | Purpose | Client bundle | Server bundle | | ------------------------- | ------------------------------------------- | --------------------- | ------------------- | | apiClient.ts | Type-safe fetch wrapper | ✅ (~0.7 KB minified) | ❌ | | apiRegistry.ts | Auto-generated route type registry | ❌ (types only) | ❌ (types only) | | pathResolver.ts | Trie-based path resolution & type inference | ❌ (types only) | ❌ (types only) | | responses.ts | Error/success helpers + error codes | ✅ (~0.2 KB minified) | ✅ (server helpers) | | update-api-registry.mjs | Code generator + Next.js plugin | ❌ | ❌ (dev only) |

* From responses.ts: Client gets isApiErrorPayload type guard (~50 bytes) and assertNever helper (~50 bytes). Server gets createApiError, createApiSuccess, createServiceError, createServiceSuccess, and HTTP status constants. ERROR_CODES are types only and erased at runtime, though some bundlers may include them until production tree-shaking removes them. Total client runtime: ~0.9 KB minified.

The CLI also:

  • Runs the registry generator once to detect existing routes
  • Adds an "infer-api" script to your package.json for manual regeneration

2. Add the plugin to next.config.ts

// If using src/ directory:
import { withApiRegistry } from "./src/lib/next-zero-rpc/update-api-registry.mjs";

// If NOT using src/ directory:
import { withApiRegistry } from "./lib/next-zero-rpc/update-api-registry.mjs";

const nextConfig = {};

export default withApiRegistry(nextConfig);

The plugin auto-updates apiRegistry.ts whenever you create, modify, or delete route.ts files during development. In production builds, it runs once and skips the file watcher.

3. Use it

Route handlers — createApiSuccess / createApiError

// app/api/users/[userId]/route.ts
import {
  createApiSuccess,
  createApiError,
  HTTP_STATUS_ERROR,
  HTTP_STATUS_SUCCESS,
} from "@/lib/next-zero-rpc/responses";

export async function GET(req: Request, { params }: { params: Promise<{ userId: string }> }) {
  const { userId } = await params;
  const user = await db.users.find(userId);

  if (!user) {
    return createApiError("resource:not-found", HTTP_STATUS_ERROR.NOT_FOUND);
  }

  return createApiSuccess({ id: user.id, name: user.name });
}

export async function DELETE(req: Request, { params }: { params: Promise<{ userId: string }> }) {
  const { userId } = await params;
  await db.users.delete(userId);

  // 204 No Content — no body per HTTP spec
  return createApiSuccess();
}

[!IMPORTANT] Always export your HTTP method handlers (GET, POST, PUT, PATCH, DELETE). If a method is not exported, it will not be inferred by the TypeScript type bridge and will not receive type safety.

Client components — apiFetch

"use client";
import { apiFetch } from "@/lib/next-zero-rpc/apiClient";

const [data, err] = await apiFetch("/api/users/123", { method: "GET" });

if (err) {
  console.error(err.code, err.message); // err.code is narrowed to only this route's errors
} else {
  console.log(data.name); // ← fully typed, payload returned directly
}

Features

Error Type Narrowing

The standout feature: when you check err.code after an apiFetch call, TypeScript narrows the union to only the error codes that specific route handler can actually return — not every error code in the system.

// Route handler returns createApiError("auth:unauthorized", 401)
//                    or createApiError("validation:missing-required-fields", 400)

const [data, err] = await apiFetch("/api/auth/login", { method: "POST" });

if (err) {
  // err.code is: "auth:unauthorized" | "validation:missing-required-fields" | "validation:invalid-payload" | "system:unknown-error"
  // NOT the full 37+ error code union — only what this route can produce
  // (system:unknown-error is always included as a fallback from apiFetch itself)
}

This works because:

  1. createApiError is generic: createApiError<C extends ErrorCode>(code: C, ...) → NextResponse<ApiErrorPayload<C>>
  2. TypeScript infers the literal C from each call site in your handler
  3. UnwrapNextResponse extracts the union of all ApiErrorPayload<C> types from the handler's return type
  4. The client sees only those specific error codes

Predictable Returns (Tuples or Objects)

Every apiFetch call returns a predictable response — never throws. Depending on what you choose during setup, you can use:

Tuples (Default):

const [data, err] = await apiFetch("/api/orders/checkout", {
  method: "POST",
  body: JSON.stringify({ ticketId: "abc", quantity: 2 }),
});

Objects:

const { data, error: err } = await apiFetch("/api/orders/checkout", {
  method: "POST",
  body: JSON.stringify({ ticketId: "abc", quantity: 2 }),
});

Regardless of your choice, the result is fully typed:

if (err) {
  // Handle error — err is fully typed with route-specific error codes
  return;
}

// data is fully typed — no casting needed
console.log(data.orderId, data.status);

Exhaustive Error Checking

Use assertNever to guarantee you handle every possible error code at compile time:

import { assertNever } from "@/lib/next-zero-rpc/responses";

const [data, err] = await apiFetch("/api/users/123", { method: "GET" });

if (err) {
  const code = err.code;
  switch (code) {
    case "system:database-error":
      showToast("Database error, please try again");
      break;
    case "system:unknown-error":
      showToast("Something went wrong");
      break;
    default:
      assertNever(code); // ← TypeScript errors if you miss a case
  }
  return;
}

Dynamic Route Matching

The type system supports Next.js dynamic segments and catch-all routes:

// Dynamic segments: [id], [userId], [slug]
const [data, err] = await apiFetch("/api/users/abc-123", { method: "GET" });

// Catch-all segments: [...catchall]
const [data, err] = await apiFetch("/api/extreme/org1/projects/proj1/tasks/a/b/c", {
  method: "POST",
});

// Optional catch-all segments: [[...slug]] (zero or more segments)
const [data, err] = await apiFetch("/api/docs", { method: "GET" }); // zero segments
const [data, err] = await apiFetch("/api/docs/getting-started/intro", { method: "GET" }); // multiple segments

// Query strings are stripped before matching
const [data, err] = await apiFetch("/api/users/123?include=profile", { method: "GET" });

Static vs Dynamic Route Precedence

If you have overlapping static and dynamic routes (e.g., /api/users/active and /api/users/[userId]), next-zero-rpc correctly gives exact static matches precedence over dynamic segments at compile time.

// Safely infers the type of the `active` route, completely ignoring the `[userId]` route
const [activeUsers] = await apiFetch("/api/users/active", { method: "GET" });

// Safely infers the type of the `[userId]` route
const [singleUser] = await apiFetch("/api/users/123", { method: "GET" });

Route Groups Support

Next.js route groups like (groupName) are natively supported. They are automatically ignored in the URL path mapping and the generated TypeScript types, perfectly matching Next.js behavior:

// File: app/api/(admin)/users/route.ts

// The fetch URL correctly skips the (admin) group
const [data, err] = await apiFetch("/api/users", { method: "GET" });

[!NOTE] Conflict-safe: You don't have to worry about multiple route groups accidentally mapping to the exact same generated TypeScript identifier. Next.js enforces uniqueness during development and at build time. It will automatically raise a Conflicting routes at /api/... error if you try to create two identical endpoints (e.g., (admin)/users/route.ts and (public)/users/route.ts).

HTTP Method Validation

TypeScript only allows methods that your route handler actually exports:

// If route.ts only exports GET and POST:
await apiFetch("/api/auth/login", { method: "POST" }); // ✅
await apiFetch("/api/auth/login", { method: "GET" }); // ✅ (if exported)
await apiFetch("/api/auth/login", { method: "DELETE" }); // ❌ compile error

Function Overloading on createApiSuccess

createApiSuccess uses TypeScript function overloading for precise return types:

// With data → NextResponse<T>
return createApiSuccess({ id: "123", name: "John" });

// Without data (204) → NextResponse<undefined>
return createApiSuccess();

// Without data (explicit 200 OK) → NextResponse<undefined>
return createApiSuccess(undefined, HTTP_STATUS_SUCCESS.OK);

// The overload enforces: if statusCode is NO_CONTENT, data must be undefined
return createApiSuccess({ id: "123" }, HTTP_STATUS_SUCCESS.NO_CONTENT); // ← type error

Safely Handling Empty Responses

The apiClient.ts uses an incredibly robust approach to handle empty API responses, leveraging how Next.js and JavaScript parse JSON primitives.

If a route needs to return a 204 No Content or an empty 200 OK, createApiSuccess(undefined) intelligently evaluates to new NextResponse(null) to completely strip the HTTP body, avoiding Next.js Response.json(undefined) serialization crashes.

On the client side, apiFetch reads res.text() before attempting to parse JSON. Because valid JSON primitives like 0, null, false, or "" serialize into strings with a length > 0 (e.g. "0", '""'), they evaluate to truthy and are safely passed into JSON.parse. Only a genuinely empty HTTP body resolves to an empty string (""), which evaluates to falsy, allowing the client to safely resolve payload = undefined without throwing a JSON syntax error.

API Reference

responses.ts

Error Codes

Error codes follow a prefix:description convention enforced by the PrefixedError<T> template literal type. The built-in categories are:

| Category | Prefix | Examples | | -------------- | ------------- | ---------------------------------------------------------- | | System | system: | system:internal-server-error, system:database-error | | Authentication | auth: | auth:unauthorized, auth:token-expired | | Validation | validation: | validation:invalid-payload, validation:duplicate-entry | | Resource | resource: | resource:not-found, resource:already-exists | | Network | network: | network:timeout, network:connection-refused | | Upload | upload: | upload:file-too-large, upload:quota-exceeded |

To add a custom category:

export const PAYMENT_ERRORS = [
  "payment:card-declined",
  "payment:insufficient-funds",
  "payment:expired-card",
] as const satisfies PrefixedError<"payment">[];

// Then add to ERROR_CODES:
export const ERROR_CODES = [
  ...SYSTEM_ERRORS,
  ...AUTH_ERRORS,
  ...VALIDATION_ERRORS,
  ...RESOURCE_ERRORS,
  ...NETWORK_ERRORS,
  ...UPLOAD_ERRORS,
  ...PAYMENT_ERRORS, // ← add here
] as const;

HTTP Status Constants

Pre-defined, typed HTTP status code objects to prevent magic numbers:

HTTP_STATUS_SUCCESS.OK; // 200
HTTP_STATUS_SUCCESS.CREATED; // 201
HTTP_STATUS_SUCCESS.ACCEPTED; // 202
HTTP_STATUS_SUCCESS.NO_CONTENT; // 204

HTTP_STATUS_ERROR.BAD_REQUEST; // 400
HTTP_STATUS_ERROR.UNAUTHORIZED; // 401
HTTP_STATUS_ERROR.FORBIDDEN; // 403
HTTP_STATUS_ERROR.NOT_FOUND; // 404
HTTP_STATUS_ERROR.METHOD_NOT_ALLOWED; // 405
HTTP_STATUS_ERROR.NOT_ACCEPTABLE; // 406
HTTP_STATUS_ERROR.CONFLICT; // 409
HTTP_STATUS_ERROR.PAYLOAD_TOO_LARGE; // 413
HTTP_STATUS_ERROR.UNPROCESSABLE_ENTITY; // 422
HTTP_STATUS_ERROR.TOO_MANY_REQUESTS; // 429
HTTP_STATUS_ERROR.INTERNAL_SERVER_ERROR; // 500
HTTP_STATUS_ERROR.BAD_GATEWAY; // 502
HTTP_STATUS_ERROR.SERVICE_UNAVAILABLE; // 503
HTTP_STATUS_ERROR.GATEWAY_TIMEOUT; // 504

Types

| Type | Description | | ----------------------- | ---------------------------------------------------------------------------------------------------------- | | ErrorCode | Union of all error code string literals | | SuccessHttpStatusCode | 200 \| 201 \| 202 \| 204 | | ErrorHttpStatusCode | 400 \| 401 \| 403 \| ... \| 504 | | ApiErrorPayload<C> | { code: C; details?: Record<string, string[]>; message?: string } — generic over the specific error code | | ServiceError<C> | { code: C; message: string; details?: ... } — generic over the specific error code | | ServiceResponse<S, E> | [S, null] \| [null, E] — Predictable return type for server actions |

Functions

| Function | Signature | Description | | ------------------------- | -------------------------------------------------------------------- | -------------------------------------- | | createApiError<C> | (code: C, statusCode, options?) → NextResponse<ApiErrorPayload<C>> | Create a typed API error response | | createApiSuccess<T> | (data: T, statusCode?) → NextResponse<T> | Create a typed API success response | | createApiSuccess | (undefined, NO_CONTENT) → NextResponse<undefined> | Overload for 204 No Content | | isApiErrorPayload | (payload: unknown) → payload is ApiErrorPayload<ErrorCode> | Runtime type guard for error payloads | | createServiceError<C> | (code: C, options?) → [null, ServiceError<C>] | Predictable error for server actions | | createServiceSuccess<T> | (data?: T) → [T \| undefined, null] | Predictable success for server actions | | assertNever | (value: never) → never | Compile-time exhaustiveness guard |

apiClient.ts

apiFetch<Path, Method>(path, options)

Type-safe fetch wrapper that returns predictable [data, error] tuples (or { data, error } objects).

Type parameters:

  • Path — validated against KnownRoutes at compile time
  • Method — restricted to only the HTTP methods exported by the matched route

Returns: Promise<[SuccessType, null] | [null, ErrorType | ApiErrorPayload<"system:unknown-error">]> (or equivalent object).

  • Success: [data, null] where data is inferred from the route handler's createApiSuccess calls
  • Error: [null, error] where error.code is narrowed to only the error codes used in that route's createApiError calls, plus "system:unknown-error" as a fallback
  • 204 No Content: [undefined, null]

Runtime behavior:

  1. Handles 204 No Content — returns [undefined, null] without parsing body
  2. Parses JSON responses based on Content-Type header
  3. Falls back to text for non-JSON responses
  4. Validates errors via isApiErrorPayload runtime type guard
  5. Catches network errors (offline, CORS) and wraps them as system:unknown-error

apiRegistry.ts

Auto-generated file containing:

  1. KnownRoutes — A type map from route path strings to their typeof module types
  2. RouteTrie — A nested object type (prefix trie) keyed by path segment. Each node may have:
    • "__terminal__": the full route string if this node ends a route
    • Literal segment keys: static segments (e.g. "users", "api")
    • "[param]" keys: dynamic param segments
    • "[...param]" keys: required catchall segments
    • "[[...param]]" keys: optional catchall segments

pathResolver.ts

Type-level path resolution engine that resolves runtime paths to their registry entries using trie traversal:

  • TrieLookup<Node, Seg> — Looks up one segment in a trie node. Priority: exact literal match > dynamic [param] > catchall
  • CheckCatchallsInNode<Node, Head> — Checks if a trie node has a catchall child ([...param] or [[...param]]) that matches the remaining path
  • TrieWalk<Node, Segs> — Walks the trie segment by segment, resolving the full path to a terminal route key
  • Split<S> — Splits the input path string into a tuple of segments
  • StripQuery<Path> — Strips query string before matching
  • FindMatchingRoute<Path> — Resolves a runtime path to its KnownRoutes key (using an O(1) fast-path for exact static routes, falling back to trie walk)
  • CheckPath<Path> — Validates a path at compile time, falling back to autocomplete hints on mismatch
  • RouteMethods<Path> — Extracts the available HTTP methods for a resolved route
  • RouteSuccessResult<Path, M> — Extracts the success payload type from a route handler's return type
  • RouteErrorResult<Path, M> — Extracts the error payload type (narrowed ApiErrorPayload<C>) from a route handler's return type
  • UnwrapNextResponse<T> — Extracts the inner payload type U from a route handler returning NextResponse<U>

update-api-registry.mjs

Code generator and Next.js plugin:

  • updateApiRegistry() — Scans app/api/ for route.ts files, generates type imports and the KnownRoutes / RouteTrie types
  • withApiRegistry(nextConfig) — Next.js plugin that runs the generator on startup and watches for changes in development mode
  • Automatically detects src/ vs root directory layout
  • Groups imports and type entries by top-level API directory
  • De-duplicates watcher setup via globalThis.__apiRegistryWatcherSetup
  • Debounces file system events (100ms) to prevent redundant regeneration

How it works

┌─────────────────────────────────────────────────────────────────┐
│                        Build / Dev Time                         │
│                                                                 │
│  app/api/***/route.ts  ──→  update-api-registry.mjs             │
│                               │                                 │
│                               ▼                                 │
│                         apiRegistry.ts                          │
│                    (KnownRoutes + RouteTrie)                    │
│                               │                                 │
│                               ▼                                 │
│                        pathResolver.ts                          │
│              (trie traversal + type inference)                  │
│                               │                                 │
│                               ▼                                 │
│  apiClient.ts ◄──── TypeScript infers paths, methods,           │
│                     success types, AND error types              │
└─────────────────────────────────────────────────────────────────┘

┌───────────────────────────────────────────────────────────────────┐
│                         Runtime (~0.9 KB)                         │
│                                                                   │
│  apiFetch("/api/users/123", { method: "GET" })                    │
│       │                                                           │
│       ▼                                                           │
│  fetch(path, options)                                             │
│       │                                                           │
│       ├─ 204?          → [undefined, null]                        │
│       ├─ JSON + ok?    → [payload, null]                          │
│       ├─ JSON + !ok?   → [null, ApiErrorPayload]  (narrowed)      │
│       ├─ non-JSON?     → [text, null]                             │
│       └─ network fail? → [null, { code: "system:unknown-error" }] │
└───────────────────────────────────────────────────────────────────┘
  1. The withApiRegistry plugin scans your app/api/ directory for route.ts files
  2. It generates import type * as ... statements in apiRegistry.ts mapping each route path to its module type, plus a RouteTrie for trie-based path matching
  3. pathResolver.ts walks the RouteTrie to resolve runtime paths (with dynamic segments, catch-alls, and query strings) to their KnownRoutes key, and extracts success/error types via UnwrapNextResponse
  4. createApiError<C> preserves the literal error code C in the return type, enabling per-route error narrowing
  5. apiFetch uses these types to validate paths, methods, and infer both success AND error shapes at compile time
  6. At runtime, apiFetch is just a thin fetch wrapper returning predictable [data, error] tuples (or objects)

The registry auto-updates when you create, modify, or delete route files during development.

Server Actions / Service Layer

For server-side code (server actions, service functions), use the predictable service helpers (these match your tuple/object preference):

// services/user.ts
import { createServiceError, createServiceSuccess } from "@/lib/next-zero-rpc/responses";

export async function getUser(userId: string) {
  const user = await db.users.find(userId);

  if (!user) {
    return createServiceError("resource:not-found", { message: "User not found" });
  }

  return createServiceSuccess({ id: user.id, name: user.name });
}

// Usage in a server action or component:
const [user, err] = await getUser("123");

if (err) {
  console.error(err.code, err.message);
  return;
}

console.log(user.name);

Customization

Since you own all the source files, you can customize anything:

  • Add error codes — Add new arrays in responses.ts with the PrefixedError<"prefix"> constraint
  • Add auth headers — Modify apiFetch to inject tokens automatically
  • Add request body types — Extend the type inference in apiClient.ts
  • Change the output path — Update REGISTRY_FILE in update-api-registry.mjs
  • Override the generator — The withApiRegistry plugin and updateApiRegistry() function are fully yours to modify

Examples

Run locally

# Clone just the example folder (no git history)
npx degit caocchinh/next-zero-rpc/examples/minimal my-app
cd my-app
npm install
npm run dev

CLI Usage

npx next-zero-rpc              # Install files (same as init)
npx next-zero-rpc init         # Install files into your project
npx next-zero-rpc --force      # Overwrite existing files
npx next-zero-rpc --yes        # Skip prompts and use defaults (Tuples)
npx next-zero-rpc --help       # Show help

Requirements

| Requirement | Minimum Version | Reason | | ----------- | --------------- | ------------------------------------------ | | Next.js | 14.0 | App Router (stable since 14.0) | | TypeScript | 4.9 | satisfies keyword used in responses.ts | | Node.js | 18 | Native fetch API required by apiFetch |

License

MIT