Skip to content

A generated fetch client is one function you call the same way everywhere:

ts
import fetchClients from "_/fetch";

const { GET } = fetchClients["users/[id]"];

const user = await GET([123]); // a component, a loader, a preload - same call

What changes underneath is the transport:

Where the call runsTransportWhat it costs
BrowserGlobal fetch, same-origin requestA normal HTTP request
Server, during SSRDirect dispatch into the API appA 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, AbortSignal semantics, etc.
  • On the server the client is constructed with a transport that speaks the same Request -> Response contract 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:

SituationTransport
pnpm dev, any folderNetwork. The dev server is always client-rendered, so there is no SSR pass to dispatch in
Production CSR folderNetwork
SSR folder, call made during render - loader, preload, createAsyncIn-process
SSR folder, call made after hydration - useEffect, onMounted, an event handlerNetwork
SSG buildIn-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 (and 301/302 from POST) rewrite to GET.
  • Without patching anything global. Only the generated clients switch transports. Every other fetch in 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:

txt
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

  • path and href always produce URL strings - they are for links, redirects and external references, not requests, so there is nothing to swap. Details ›
  • Types. The same ResponseT and 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.

Released under the MIT License.