Server Functions & Isomorphic Fetching
When to reach for the universal fetcher, the server fetcher, or a TanStack Start server function in VitNode.
VitNode keeps backend business logic in Hono API routes. The frontend's only job is to call them - and for that, one universal fetcher covers SSR and browser navigation at once.
Decision Matrix
| Goal | Recommended tool | Rationale |
|---|---|---|
| Route data fetching | fetcher from @vitnode/core/tanstack/fetcher | One call site. SSR forwards the request; the browser calls /api/* directly. |
| API endpoints & mutations | Hono API routes | Enforces staff permissions, validation schemas, and database transactions. |
| Cookie minting on the host | createServerFn + the server fetcher | Only code inside the host request can set response headers directly. |
| Plugin server code | Hono API modules | Plugins must never declare createServerFn (uncompiled handlers resolve to undefined). |
No createServerFn in plugins
A plugin package may declare createIsomorphicFn, but never createServerFn.
Server functions belong exclusively to the host application.
Fetching does not need createIsomorphicFn
TanStack Router loaders run on the server for the first paint and in the browser for every navigation after it. That used to mean writing both halves by hand:
export const fetchDevices = createIsomorphicFn()
.server(fetchDevicesOnServer)
.client(fetchDevicesInBrowser)The universal fetcher already is that boundary, so a feature writes the fetch
once - naming the plugin, and letting the route infer:
import { fetcher } from '@vitnode/core/tanstack/fetcher'
export const fetchDevices = async () => {
const response = await fetcher({
plugin: '@vitnode/core',
method: 'get',
module: 'users',
path: '/devices',
})
return await response.json()
}import { definePluginRoute } from '@vitnode/core/routing'
export const route = definePluginRoute({
load: async () => await fetchDevices(),
})The initial render forwards the visitor's cookie, user-agent and IP through the
request-aware transport; later navigations are a direct same-origin fetch to
Hono. See Fetcher for the full contract.
createIsomorphicFn is still the right tool when the two implementations
genuinely differ - reading a cookie from the request on the server and from
document.cookie in the browser, for instance. It is no longer how you fetch.
When to use createServerFn (host app only)
Use createServerFn when your host application has to touch the response
itself - which in practice means cookies:
import { createServerFn } from '@tanstack/react-start'
import { fetcher } from '@vitnode/core/tanstack/fetcher/server'
export const signIn = createServerFn({ method: 'POST' })
.validator((body: { email: string; password: string }) => body)
.handler(async ({ data }) => {
const response = await fetcher({
plugin: '@vitnode/core',
allowSaveCookies: true,
args: { body: data },
method: 'post',
module: 'users',
path: '/sign_in',
})
return { ok: response.ok }
})The call is the same shape as the universal one - plugin, module, path,
method, args - and it is typed from the same registry. What the server
fetcher adds is exactly what a browser cannot do:
| Option | What it does |
|---|---|
allowSaveCookies | relays the API's Set-Cookie headers onto the host response |
additionalHeaders | adds headers a browser may not forge, Cookie included |
origin | calls a different API origin than the configured one |
Two things make this the exception rather than the rule:
- it needs
allowSaveCookies, which only the server fetcher offers; - it costs an extra hop - browser → server function → API. Reads should not pay
it, which is exactly why they use the universal
fetcherinstead.
Keep code that imports @vitnode/core/tanstack/fetcher/server in a
*.server.ts file or inside a server function handler. The universal fetcher
rejects these three options at compile time, so a call that needs one cannot
end up in a shared module by accident.