next-zero-rpc // v0.2.2

Witness The Magic

Hover over the variables in client.ts to observe absolute type inference across the network boundary.

>npx next-zero-rpc init
//
EXPLORER
examples/minimal
src
app
api
(core)
status
route.ts
auth
login
route.ts
extreme
[orgId]
projects
[projectId]
tasks
[...catchall]
route.ts
complex-types
route.ts
methods
route.ts
users
active
route.ts
[userId]
route.ts
client.ts
lib
next-zero-rpc
apiClient.ts
apiRegistry.ts
responses.ts
path-inference.test.ts
update-api-registry.mjs
.eslintrc.json
next-env.d.ts
next.config.mjs
package.json
tsconfig.json
vitest.config.ts
route.ts
client.ts
srcclient.ts
import { apiFetch } from "@/lib/next-zero-rpc/apiClient";
import { assertNever } from "@/lib/next-zero-rpc/responses";

// 1. Happy Path — response is fully typed from your route handler
const [ data
{ id: string; name: string; role: string } | null
, err
const err:
  ApiErrorPayload<"system:unknown-error"> |
  ApiErrorPayload<"system:database-error"> |
  null
] = await apiFetch("/api/users/123", { method: "GET" });

if (err) {
console.error(err.message);
} else {
// data.name, data.id, data.role — inferred directly from the route!
console.log(data.name, data.role);
}

// 2. Exhaustive Error Narrowing
// TypeScript narrows err.code to exactly the codes this route can return.
// assertNever() is a compile-time guard — remove any case and TS errors.
const [ data2
{ id: string; name: string; role: string } | null
, err2
const err2:
  ApiErrorPayload<"system:unknown-error"> |
  ApiErrorPayload<"system:database-error"> |
  null
] = await apiFetch("/api/users/123", { method: "GET" });

if (err2) {
const code = err2.code;
switch (code) {
case "system:database-error":
console.error("User not found!");
break;
// Intentionally missing 'system:unknown-error' to show TS error
default:
assertNever(code
Argument of type '"system:unknown-error"' is not assignable to parameter of type 'never'.
); // TS Error if you miss a case!
}
}

// 3. TypeScript Catches Typos — compile error on an invalid route path
await apiFetch("/api/uwsers/34"
Argument of type '"/api/uwsers/34"' is not assignable to parameter of type 'keyof KnownRoutes'.
, { method: "DELETE" });

// 4. TypeScript Catches Wrong Methods — POST is not exported from /api/status
await apiFetch("/api/status", { method
Type '"POST"' is not assignable to type '"GET"'.
: "POST" });

// 5a. IDE IntelliSense: Available Routes
// Place cursor inside the string — your IDE lists every registered route.
await apiFetch(""
/api/auth/login
/api/auth/login
/api/extreme/[orgId]/projects/[projectId]/tasks/[...catchall]
/api/extreme/complex-types
/api/extreme/methods
/api/status
/api/users/[userId]
/api/users/active
);

// 5b. IDE IntelliSense: Allowed Methods
// Place cursor inside method — your IDE narrows to only what this route exports.
await apiFetch("/api/users/34", { method: ""
DELETE
DELETE
GET
PUT
});

// 6. Static vs Dynamic Route Precedence
// /api/users/active matches the static route exactly — not the [userId] segment.
const [ activeUsers
const activeUsers: {
  activeUsers: {
    id: string;
    name: string;
    role: string;
  }[];
  count: number;
} | null
, errActive
const errActive: ApiErrorPayload<"system:unknown-error"> | null
] = await apiFetch("/api/users/active", { method: "GET" });

// /api/users/123 correctly resolves to the dynamic [userId] route.
const [ singleUser
const singleUser: {
  id: string;
  name: string;
  role: string;
} | null
, errSingle
const errSingle:
  ApiErrorPayload<"system:unknown-error"> |
  ApiErrorPayload<"system:database-error"> |
  null
] = await apiFetch("/api/users/123", { method: "GET" });

// 7. Multi-Variable Template Literals
// All three runtime variables resolve independently — TypeScript still correctly
// matches the route /api/extreme/[orgId]/projects/[projectId]/tasks/[...catchall]
const orgId = "acme";
const projectId = "proj-99";
const catchall = "step1/step2/step3";
const [ task
const task: {
  resolvedOrgId: string;
  resolvedProjectId: string;
  dynamicSegments: string[];
  deeplyNestedMatrix: {
    layer1: {
      layer2: {
        layer3: readonly [readonly [{
          readonly x: 1;
          readonly y: 2;
        }, {
          readonly x: 3;
          readonly y: 4;
        }], readonly [{
          readonly x: 5;
          readonly y: 6;
        }, {
          readonly x: 7;
          readonly y: 8;
        }]];
      };
    };
  };
} | null
, taskErr
const taskErr: ApiErrorPayload<"system:unknown-error"> | null
] = await apiFetch(
`/api/extreme/${orgId}/projects/${projectId}/tasks/${catchall}` ,
{ method: "GET" }
);

// 8. Deeply Nested Catch-All Routes
const [ res3
const res3: {
  resolvedOrgId: string;
  resolvedProjectId: string;
  dynamicSegments: string[];
  deeplyNestedMatrix: {
    layer1: {
      layer2: {
        layer3: readonly [readonly [{
          readonly x: 1;
          readonly y: 2;
        }, {
          readonly x: 3;
          readonly y: 4;
        }], readonly [{
          readonly x: 5;
          readonly y: 6;
        }, {
          readonly x: 7;
          readonly y: 8;
        }]];
      };
    };
  };
} | null
, err3
const err3: ApiErrorPayload<"system:unknown-error"> | null
] = await apiFetch(
"/api/extreme/acme/projects/xyz/tasks/a/b/c",
{ method: "GET" }
);

// 9. Strict Method Matching — each method has its own unique response type
const [ resGet
const resGet: {
  method: "GET";
  data: number[];
} | null
, errGet
const errGet: ApiErrorPayload<"system:unknown-error"> | null
] = await apiFetch("/api/extreme/methods", { method: "GET" });

// 10. Extreme Recursive Type Inference
// Discriminated unions, recursive trees, intersections — all preserved end-to-end.
const [ res2
const res2: {
  union: {
    type: "success";
    payload: {
      id: string;
      metrics: Record<string, number[]>;
    };
  };
  tree: RecursiveTree<DiscriminatedUnion>;
  intersection: {
    base: string;
  } & {
    variantB: boolean;
  };
  matrixStringTuple: readonly ["a", "b", "c"];
  nullableField: null;
  optionalField: string | undefined;
  bigIntSimulate: string;
} | null
, err2b
const err2b:
  ApiErrorPayload<"system:unknown-error"> |
  ApiErrorPayload<"system:internal-server-error"> |
  ApiErrorPayload<"validation:invalid-payload"> |
  null
] = await apiFetch("/api/extreme/complex-types", { method: "POST" });

// 11. Query Strings — safely stripped before validating the route path
const [ withQuery
const withQuery: {
  id: string;
  name: string;
  role: string;
} | null
, errQuery
const errQuery:
  ApiErrorPayload<"system:unknown-error"> |
  ApiErrorPayload<"system:database-error"> |
  null
] = await apiFetch("/api/users/123?include=profile", { method: "GET" });

The Philosophy

You Own The Code

next-zero-rpc is not a black-box framework—it's a paradigm. When you run init, we drop four files into your project. From that moment on, they are yours to modify, extend, or delete. Zero vendor lock-in.

Architectural Freedom

Because we don't take over your Next.js server, you are free to mix and match architectures. Need Server-Sent Events (SSE), WebSockets, or GraphQL alongside it? Go ahead. Study our generated files to verify this—there is no hidden framework magic.

Zero Boilerplate

You write standard Next.js API route handlers. No decorators, no schema registrations, no complex abstractions. The codegen reads what already exists and builds the type bridge automatically.

Validation Is Yours

Input validation stays inside your route handler where it belongs. This library doesn't impose a validation layer—that's a deliberate design choice to keep things non-invasive.

Installation

Deploy the type-safe bridge into your architecture.

01

Scaffold Core Files

Use the CLI to scaffold everything, or copy the files manually.

$ npx next-zero-rpc init

Creates lib/next-zero-rpc/ and generates the initial apiRegistry.ts automatically. Skip to Step 02.

02

Setup Next.js Watcher

Inject the plugin so the registry auto-updates on every route change during dev. Optional — applies to both CLI and manual installs.

next.config.ts
import { withApiRegistry } from "./src/lib/next-zero-rpc/update-api-registry.mjs";

const nextConfig = {};

export default withApiRegistry(nextConfig);

Skip this step? Just run npm run infer-api manually whenever you add or change API routes.