A generated fetch client is one function you call the same way everywhere:
import fetchClients from "_/fetch";
const { GET } = fetchClients["users/[id]"];
const user = await GET([123]); // a component, a loader, a preload - same callWhat changes underneath is the transport:
| Where the call runs | Transport | What it costs |
|---|---|---|
| Browser | Global fetch, same-origin request | A normal HTTP request |
| Server, during SSR | Direct dispatch into the API app | A function call and an object - no socket, no localhost hop |
There is nothing to configure and no second client to import.
Nothing is patched
Worth saying plainly, because "isomorphic fetch" elsewhere often means monkey-patching: globalThis.fetch is never touched - not in the browser, not in Node.
The transport is a parameter of the generated client, not a global that gets swapped:
- In the browser no transport is passed, so the client calls the platform's own fetch, pristine - with every redirect, credentials, caching,
AbortSignalsemantics, etc. - On the server the client is constructed with a transport that speaks the same
Request -> Responsecontract and hands the request to the API app instead of to the network.
What decides which transport you get
Not a setting - where and when the call happens:
| Situation | Transport |
|---|---|
pnpm dev, any folder | Network. The dev server is always client-rendered, so there is no SSR pass to dispatch in |
| Production CSR folder | Network |
SSR folder, call made during render - loader, preload, createAsync | In-process |
SSR folder, call made after hydration - useEffect, onMounted, an event handler | Network |
| SSG build | In-process (see below) |
The rule underneath is simple: a fetch that fires while the server is rendering the page runs in the server's process; a fetch that fires in the browser runs in the browser.
The dev server never shows you the in-process path
Dev is CSR, so the fetch you are watching in the network tab is the network one - even in a folder with SSR enabled. Run pnpm preview to exercise the real SSR path.
What the in-process path actually does
It is not a shortcut around your API. The SSR build bundles the backend in, and the client hands it a real Request:
- Dispatched into the app instance -
app.fetch(request)for backends that expose it, an in-memory injection for the Node-style one. Either way the request goes through the whole chain: routing, global and cascading middleware, validation, your handler, error handling, response shaping. - With the page request's headers as defaults. Cookies,
authorization, tracing headers from the incoming request are forwarded, so an authenticated page renders authenticated data. Anything you set on the call itself wins over a forwarded value. - Following redirects in-process - up to the five hops the fetch spec allows, including the
303(and301/302fromPOST) rewrite toGET. - Without patching anything global. Only the generated clients switch transports. Every other
fetchin your app - a call to a third-party API, say - behaves exactly as it always did.
Client-side validation is skipped under SSR
The client's pre-flight check exists to save a round trip. During SSR there is no round trip, so it is disabled automatically and validation runs on the API endpoint only. Details ›
Hydration does not refetch
A request made during SSR is not repeated in the browser:
- React and Solid reuse the result through their built-in hydration.
- Vue, Svelte and MDX reuse it through the loader: the result is serialized into the page during SSR and read on the client before the loader would run.
So the common pattern - a loader that fetches, a component that renders it - costs exactly one call, made in the server's process, and the browser starts from the finished result.
When an SSR fetch fails
A failing call during SSR is not the same event as a failing call in the browser, and it is worth knowing what the server does with it.
The in-process transport throws on a non-2xx response, and the error is also stashed on the request-scoped store - because some frameworks swallow a rejected loader and render a partial tree anyway, which would otherwise produce a half-rendered page with no visible error.
When the render finishes and that error is present, the SSR output is discarded and the client shell is served instead. The browser then renders the page itself, where the failure reaches your own error boundaries and surfaces the same way it would in a CSR app.
The server logs it:
WARN: SSR failed, fallback to CSR
SSRFetchError: /api/users/123: 500 [ Internal Server Error ]That recovery needs an untouched response, so it applies to string-rendered routes - the default. A streamed route has already flushed its status line and opening HTML by the time a fetch fails, so there is nothing left to replace: streaming routes must handle fetch failures in the page itself.
The fallback is a deliberate trade - one consistent place to handle errors instead of server-side boundaries that behave differently in every framework - but it has a practical consequence worth internalising:
A page that renders fine can still have lost its SSR
If a page silently arrives as an empty shell in production, check the server log before checking the client. A single failing API call during render is enough to drop the whole page back to CSR, and the page still works - it just stopped being server-rendered.
To catch that without reading logs, give the renderers an onError hook - it is called with the error that ended the render, so a lost SSR becomes an event in your monitoring rather than a line on stdout. It reports only; the fallback happens either way.
This is a serving-time trade, and it applies to SSR only: at build time there is no visitor waiting, so SSG writes nothing and fails the build instead.
Fetch clients always throw on failure, on both sides - but not the same object. In the browser the thrown error carries the response and the parsed error body; during SSR the transport throws first, with the route and status in its message. And a ValidationError can only ever come from the browser, since the pre-flight check is not there under SSR. Details ›
SSG runs the same path at build time
Static generation is not a third mode. The build starts a disposable SSR server, requests each route from it, and writes the returned HTML to disk - so every fetch made during those renders takes the in-process path, inside the build.
Two consequences:
- Your API runs during the build, so the build needs the access production has - the real database, the CMS, whatever your routes read. That is a CI concern rather than a laptop one: the usual shape is a workflow that ships the sources to the production environment - or to a runner with the same credentials and network reach - and builds there, so every page is rendered against exactly the data production would have served.
- A failed fetch costs you the whole build. SSG does not fall back to a client shell: pre-rendering goes through every path, and if any of them failed it writes nothing and throws the collected errors. Details ›
What does not change
pathandhrefalways produce URL strings - they are for links, redirects and external references, not requests, so there is nothing to swap. Details ›- Types. The same
ResponseTand parameter types apply on both sides; nothing about the call signature depends on where it runs. - Your framework's data model. Loaders,
createAsync,useLoaderData- the client is a plain promise-returning function and the framework owns everything above it. Details ›
Read more on why the boundary is there and why keeping it does not have to cost a round trip.