In a client-side app the client runs in the browser and calls your backend directly — the base URL and paths are visible in the Network tab by design. In an SSR framework (Next.js, TanStack Start) you often don't want the browser to see the backend base URL, the backend paths, or the OpenAPI document. Importing the normal client into a client component ships all of that in the JS bundle.
The bridge solves this without giving up the api.module.method() ergonomics.
Browser (client component) Server (Node / edge)
─────────────────────────── ─────────────────────────────
api.products.getProductById({ id }) createRpcHandler(realApi, { expose })
│ proxy, typed via `typeof serverApi` │ allowlist → authorize → dispatch
│ (type-only, erased at build) ▼ runs the REAL client (holds secrets)
▼ POST same-origin { module,method,args }
transport ───────────────────────────────▶ { ok: true, data } | { ok: false, error }
- The browser proxy carries zero runtime knowledge of URL/paths/OpenAPI —
types come purely from
type Api = typeof serverApi, a type-only import that is erased at build. The compiled browser bundle is a few KB and contains no backend host, paths, or axios. - The server handler is the single trust boundary: everything the browser sends is treated as hostile until validated.
// lib/api/api.config.ts (server-only module)
import { createTypedClient } from '@developerehsan/api-client'
import { createRpcHandler } from '@developerehsan/api-client/server'
import type { OperationsMap } from './types/generated/api.types'
import { generatedModules } from './types/generated/api.modules'
export const api = createTypedClient<OperationsMap>()(
{ baseURL: process.env.API_URL!, openapi: { mode: 'runtime' } },
generatedModules,
)
export type Api = typeof api
export const rpcHandler = createRpcHandler(api, {
// Deny-by-default allowlist. Module AND method names autocomplete; a typo is a
// compile error.
expose: {
products: ['getProductById', 'findPetsByStatus', 'addProduct'],
store: ['getInventory'],
},
authorize: async (ctx, call) => {
if (call.method === 'addProduct') return (await ctx.getCookie?.('editor')) === '1'
return true
},
})See it live: the full handler — with expose, maxBatchSize, a rate limiter
on onRequest, an authorize write-gate, a transformResult redactor, and
onError logging — is in
examples/nextjs/lib/api/api.config.ts.
Option A — Next.js Server Action (CSRF handled by Next):
// app/actions.ts
'use server'
import { createNextRpcAction } from '@developerehsan/api-client/server'
import { rpcHandler } from '@/lib/api/api.config'
export const rpc = createNextRpcAction(rpcHandler)→ examples/nextjs/app/actions.ts
Option B — generic HTTP route (framework-agnostic; enforces its own CSRF):
// app/api/rpc/route.ts
import { createRpcRouteHandler } from '@developerehsan/api-client/server'
import { rpcHandler } from '@/lib/api/api.config'
const handle = createRpcRouteHandler(rpcHandler)
export function POST(request: Request) { return handle(request) }→ examples/nextjs/app/api/rpc/route.ts.
Also works for TanStack Start (createStartRpcRoute) and Remix
(createRemixRpcAction).
// lib/api/rpc-client.ts
import { createRpcClient, serverActionTransport } from '@developerehsan/api-client/browser'
import type { Api } from './api.config' // ← type-only import; erased at build
import { rpc } from '@/app/actions'
export const api = createRpcClient<Api>(serverActionTransport(rpc), { batch: true })→ Server-Action variant:
examples/nextjs/lib/api/rpc-client.ts.
Generic-HTTP variant (httpTransport({ endpoint: '/api/rpc' })):
examples/nextjs/lib/api/rpc-http-client.ts.
'use client'
import { api } from '@/lib/api/rpc-client'
import { ApiError } from '@developerehsan/api-client/browser'
async function load() {
try {
const product = await api.products.getProductById({ id: 1 }) // → Product, typed
} catch (e) {
if (e instanceof ApiError) console.log(e.status, e.message) // rehydrated!
}
}→ examples/nextjs/app/ProductDemo.tsx.
Point createQueryIntegration at the bridge client and the paths-stripped
descriptor (api.rpc.ts, verbs + hasPathParams, no paths):
import { createQueryIntegration } from '@developerehsan/api-client-query/react'
import { api } from './rpc-client'
import { rpcModules } from './types/generated/api.rpc' // no backend paths
export const q = createQueryIntegration(api, { modules: rpcModules })See TanStack Query.
createRpcClient(transport, { batch: true }) coalesces same-tick calls into one
round-trip; each sub-call is validated and authorized individually
server-side (maxBatchSize, nested-batch rejection). Both example bridge clients
enable it.
Pass an AbortSignal as usual — api.products.getProductById({ id }, { signal }).
The signal is not sent over the wire (it isn't serializable); it's honored
locally and rejects the promise with an AbortError on abort.
The handler enforces all of the following before dispatch:
| Concern | Guarantee |
|---|---|
| Arbitrary method calls | Deny-by-default expose allowlist; client strings validated, never used to index blindly |
| Prototype pollution | __proto__/constructor/prototype rejected in names and input |
| Authorization | Optional authorize(ctx, call); deny returns the same error as "unknown method" (no enumeration) |
| SSRF / option injection | Client perCall dropped except a clamped timeout; no baseURL/adapter/headers/auth override |
| DoS | Input depth/breadth caps (maxInputDepth/maxInputKeys) + body-size cap on the HTTP route + rate limiting |
| CSRF | Server Actions: Next's built-in protection. HTTP route: POST + application/json + Origin/Sec-Fetch-Site check |
| Error leakage | Only { name, status, code, message } cross the wire; stacks/URLs/headers never do (details only when dev: true) |
The bridge client type mirrors your whole API surface, so
api.products.deleteProduct(...)still type-checks even if it isn't exposed — theexposeallowlist is the runtime gate, and an un-exposed call is denied.
createRpcHandler (and therefore createNextRpcAction/createRpcRouteHandler,
which both wrap an already-constructed handler) throws immediately if
constructed in a browser context — enforceServerOnly defaults to true.
This is a dev-time net that catches an accidental client-side import of
server code the moment the handler is built, instead of only discovering it
via CI's scripts/check-browser-bundle.mjs grep on the built bundle. That
grep remains the authoritative last line of defense — this guard is
defense-in-depth on top of it, not a replacement.
// Only if you have a specific, documented reason (e.g. a jsdom-based unit
// test importing server code directly) — never auto-detected, always explicit:
createRpcHandler(api, { expose: { ... }, enforceServerOnly: false })The same guard is available for your own server-only modules via
defineModule({ serverOnly: true, methods: { ... } }) — checked once at
module construction (not per call) so a module wrapping direct DB access or
other server-only concerns throws immediately if it ends up in a browser
bundle. See SECURITY.md.