The SSR RPC bridge, part 1: your backend URL is in the browser bundle right now
I’ve promised this post since part one of the series, so here it is, properly. This is the feature I’m most opinionated about in the whole package, split into two parts because it genuinely deserves the room — this post is the problem and the architecture, the next one is the full security model.
Go check your own bundle right now
Open a Next.js (or any SSR framework) app that imports its API client directly into a client component, build it, and open the Network tab. Look at the JS chunk for that component. Your backend’s base URL is in there, in plaintext. So are the internal paths of every endpoint that client touches. None of that was a deliberate choice — it’s just what happens when a module gets imported into code that ships to the browser.
Problem: this isn’t really about “hiding” your API — a determined attacker can always find your API surface by watching Network requests anyway. It’s about unintentional exposure. Internal-only paths that were never meant to be publicly known, API keys accidentally baked into client config, base URLs for staging/internal environments that shouldn’t be public knowledge. And it’s about control — even if nothing is technically secret, you probably don’t want every endpoint your backend exposes to be directly callable from any browser that loads your page, with no server-side checkpoint in between.
The naive fix, and why it’s tedious
The obvious fix is: don’t call the backend directly from client components, route everything through your own server. In practice, that means hand writing a Next.js API route for every single endpoint you want to expose to the browser:
// app/api/products/[id]/route.ts — one file, per endpoint, forever
export async function GET(req: Request, { params }: { params: { id: string } }) {
const product = await backendApi.products.get(params.id)
return Response.json(product)
}This works. It’s also a proxy route per endpoint, written by hand, that someone has to remember to add every time a new endpoint needs client-side access — and it’s easy for these routes to drift from whatever validation or auth checks the “real” client-side calling code assumes is happening.
The RPC bridge — one mechanism instead of N hand-written routes
Solution: keep exactly one real client — the one holding your base URL, your auth config, your secrets — on the server. Expose an explicit, deny-by-default allowlist of which module-and-method pairs the browser is even allowed to call. The browser gets a proxy client with the identical method signatures, but every call actually becomes one generic, same-origin POST request carrying the module name, method name, and arguments — routed through a single handler, not N hand-written files.
flowchart TD
A[Browser: api.products.getProductById id] --> B[Same-origin POST<br/>module, method, args]
B --> C{On allowlist?}
C -->|No| D[403, request rejected]
C -->|Yes| E[Real server-side client<br/>has baseURL, secrets]
E --> F[Actual backend call]
F --> G[Response back to browser]// server — the ONLY place baseURL and secrets ever live
export const rpcHandler = createRpcHandler(api, {
expose: {
products: ['getProductById', 'search'],
reviews: ['listForProduct'],
// anything not listed here is unreachable from the browser, period
},
})// browser — same method signatures, zero backend knowledge
import { createRpcClient } from '@developerehsan/api-client/rpc-client'
export const api = createRpcClient({ endpoint: '/api/rpc' })
const product = await api.products.getProductById('42')That last line reads identically to a direct call — same signature, same
return type, same await — but it’s actually a POST to /api/rpc, same
origin, no backend URL anywhere in the browser bundle. Check the compiled
output for the browser bundle after switching to this: it contains types
(erased at build time, zero runtime cost) and nothing else. No baseURL, no
paths, no OpenAPI metadata.
Why “deny-by-default” is the important word
Problem with an allow-everything default: if the bridge exposed every module and method unless you explicitly excluded something, one forgotten exclusion — a new admin-only module someone adds and forgets to lock down — becomes silently callable from any browser the moment it’s deployed.
Solution: the expose map is the only thing reachable. Add a new
module to your server-side client and it’s automatically unreachable from
the RPC bridge until someone deliberately adds it to expose — the safe
failure mode is “nothing new is accidentally public,” not “everything’s
public unless someone remembers to lock it down.” I’d rather debug “why
isn’t this callable yet” (an annoying but safe failure) than discover an
unintentionally public admin endpoint in a security review.
What this does and doesn’t solve
Being precise about scope here, because I don’t want to oversell it: the RPC bridge solves unintentional exposure and lack of a server-side checkpoint. It is not, by itself, authentication or authorization — a call reaching the allowlisted handler still needs your actual auth logic to run on the server side, checking who’s making the request and whether they’re allowed to. The bridge controls which methods can be reached at all; your own auth/authz still controls who can reach the ones that are exposed. Conflating those two is exactly the kind of mistake that turns “structurally safer” into “false sense of security” — which is the whole subject of the next post.
What sticks from this post
- Importing your normal client into client components leaks your backend’s base URL and internal paths into the browser bundle, unintentionally.
- Hand-written per-endpoint proxy routes work but don’t scale and are easy to forget for new endpoints.
- The RPC bridge is one generic handler plus a deny-by-default
exposeallowlist — new modules are unreachable from the browser until explicitly added. - It solves exposure and adds a server-side checkpoint — it is not a substitute for real auth/authz on top of it.
Next: the full security model — the threats this actually defends against, one by one, and where the responsibility boundary sits between the bridge and your own code.
- developerehsan