ehsan.blog
~/blog/api-client-part-16-framework-guides — zsh
cat api-client-part-16-framework-guides.md

Framework guides: Next.js, Vite, edge runtimes, and plain Node

·5 min read

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:

ts
// 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! },
})
ts
// 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' })
ts
// 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.ts directly. 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.

ts
// 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.

ts
// 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.

ts
// 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”

EnvironmentClientNotes
Next.js server component / route handlerReal client, directNo bundle risk, no bridge needed
Next.js client componentRPC proxy clientNever import the real client here
Vite / plain SPAReal client, directNo server exists to hold a bridge
Edge runtimeReal client, directAdapter auto-switches to fetch
Node script / cron jobReal client, directConsider 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 safeMode given 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
ls ./related
cat ./comments

Comments are not configured yet. Enable GitHub Discussions and paste the giscus repo-id / category-id into src/consts.ts.