Framework guides: Next.js, Vite, edge runtimes, and plain Node
Everything so far in this series works the same way conceptually across environments, but the actual setup — where the client lives, which parts run server-side vs client-side — differs enough per framework that it’s worth walking through each one concretely.
Next.js — the one with the most nuance
Problem: Next.js blurs the server/client boundary more than any other framework here, and that’s exactly where the RPC bridge from the last two posts becomes easy to accidentally defeat.
Two clients, two files, and the distinction matters:
// lib/api-server.ts — the REAL client, server-only
// never imported from a 'use client' file
export const api = createClient({
baseURL: process.env.BACKEND_URL!,
auth: { strategy: 'apiKey', getKey: () => process.env.BACKEND_KEY! },
})// lib/api-client.ts — the RPC proxy, safe for the browser
'use client'
import { createRpcClient } from '@developerehsan/api-client/rpc-client'
export const api = createRpcClient({ endpoint: '/api/rpc' })// app/api/rpc/route.ts
import { api } from '@/lib/api-server'
export const POST = createRpcHandler(api, {
expose: { products: ['getProductById'] },
}).handler- Server components / route handlers / server actions — import
lib/api-server.tsdirectly. You’re already on the server; there’s no bundle to leak into, so go straight to the real client, no bridge needed. - Client components — import
lib/api-client.ts, the RPC proxy, never the server file.
The mistake that quietly defeats the bridge: importing api-server.ts
from a file that starts as a server component but gets imported into a
client component tree without a 'use client' boundary catching it. Next.js
won’t always give you a loud error here — depending on the exact import
chain, you can end up with server-only code and secrets bundled for the
client anyway, silently. Real talk: I’ve done this exactly once, caught
it by actually checking the compiled client bundle rather than trusting that
the file organization alone was protecting me. If you’re using the RPC
bridge, get in the habit of periodically checking your client bundle output
for your backend URL — it should never appear.
Vite / plain SPA — the simpler case
Problem: no server runtime at all — everything is the browser. There’s no RPC bridge option here, because there’s no server to hold the real client. The base URL and any client-visible auth (like a public API key meant for browser use) are going into the bundle regardless of what you do.
Solution: accept that and design for it. Use the direct client, and make sure anything genuinely sensitive — a real backend secret, an internal-only endpoint — lives behind your actual backend, not in frontend config at all.
// src/api.ts
export const api = createClient({
baseURL: import.meta.env.VITE_API_URL,
auth: { strategy: 'bearer', getToken: () => localStorage.getItem('token') },
})If you need the RPC bridge’s benefits in a Vite app, you need an actual backend server in the picture somewhere — the bridge’s whole premise is a server-side handler holding real secrets, which a pure SPA build doesn’t have.
Edge runtimes (Vercel Edge, Cloudflare Workers)
Problem: edge runtimes don’t have Node’s full API surface — no
node:async_hooks in some edge environments, restricted or absent Axios
support since Axios assumes a Node-like environment in places.
Solution: the client auto-detects an edge runtime and switches to the
fetch-based adapter instead of Axios, without you configuring anything.
The one thing worth knowing: AsyncLocalStorage-based tenancy context from
the multi-tenancy post isn’t available on every edge runtime — check your
specific platform’s Node compatibility, and fall back to explicit per-call
tenantId (also covered in that post) if ambient context isn’t supported
where you’re deploying.
// works unmodified on Vercel Edge — adapter is chosen automatically
export const config = { runtime: 'edge' }
export default async function handler(req: Request) {
const user = await api.users.get(new URL(req.url).searchParams.get('id')!)
return Response.json(user)
}Plain Node scripts
Problem: a data-sync script, a cron job, a one-off migration — no framework, no request/response cycle, sometimes no concept of “a user” at all.
Solution: this is actually the simplest setup of the four, and it’s also
where safeMode (from the error-handling post) earns its keep the most —
scripts processing many records benefit from the result-object pattern more
than typical UI code does.
// scripts/sync-users.ts
import { api } from '../lib/api'
const users = await api.users.list()
for (const user of users) {
const result = await api.legacy.syncUser(user, { safeMode: true })
if (!result.ok) console.error(`failed: ${user.id}`, result.error)
}A decision table for “which client do I use here”
| Environment | Client | Notes |
|---|---|---|
| Next.js server component / route handler | Real client, direct | No bundle risk, no bridge needed |
| Next.js client component | RPC proxy client | Never import the real client here |
| Vite / plain SPA | Real client, direct | No server exists to hold a bridge |
| Edge runtime | Real client, direct | Adapter auto-switches to fetch |
| Node script / cron job | Real client, direct | Consider safeMode for bulk operations |
The pattern across all of these: the real client only ever lives where secrets are already safe to be — a real backend, a server component, an edge function, a script. The RPC proxy exists specifically for the one case where code runs in the browser but still needs a way to reach data behind those secrets.
What sticks from this post
- Next.js is the one that needs real discipline — two separate client files, and periodically check your actual compiled client bundle, not just your file organization, to confirm secrets aren’t leaking through an unexpected import chain.
- Vite/SPA apps have no server to hold a bridge — design around the fact that the base URL and any client-visible config are public by construction.
- Edge runtimes auto-switch to the fetch adapter; check platform-specific
support before relying on
AsyncLocalStorage-based tenancy. - Node scripts are the simplest setup, and often the best fit for
safeModegiven how much of that code is bulk, sequential operations.
Next: testing — the mock client, and how to write tests against your API layer without hitting a real network or maintaining a parallel set of fake fetches.
- developerehsan