Data Fetching
Universal type-safe API client, route loaders, cache invalidation, and built-in queries in TanStack Start.
In TanStack Start, data fetching bridges two execution environments: server-side rendering during SSR and in-app client navigation afterwards.
VitNode's universal fetcher from @vitnode/core/tanstack/fetcher eliminates duplicate transport logic. During SSR, it relays visitor cookies, IP addresses, and headers to the API; in the browser, it calls /api/* directly with credentials: "include". One call, end-to-end type inference, zero drama.
The universal call
Call fetcher inline at the call site. The router automatically infers available modules, HTTP methods, arguments, and return types from your plugin's API registry:
import { fetcher } from '@vitnode/core/tanstack/fetcher'
const response = await fetcher({
plugin: '@acme/site-notes',
method: 'get',
module: 'notes',
path: '/',
})
if (response.ok) {
const { notes } = await response.json()
}Four fields identify an endpoint:
| Field | Description | Inferred from |
|---|---|---|
plugin | Package name of the target plugin | App-configured plugin registry |
module | Module path under the plugin (e.g. 'notes', 'admin/notes') | Plugin's buildApiPlugin definition |
path | Route path declared inside the module ('/', '/{id}') | Module's route declarations |
method | HTTP method declared for that path ('get', 'post', etc.) | Declared routes for that path |
Arguments: body, params, and query
The args object is required whenever a route declares Zod schemas for parameters, queries, or request bodies:
import { fetcher } from '@vitnode/core/tanstack/fetcher'
export const pinNote = async (id: string, pinned: boolean) => {
const response = await fetcher({
plugin: '@acme/site-notes',
args: {
body: { pinned },
params: { id },
},
method: 'post',
module: 'notes',
path: '/{id}/pin',
})
if (response.status === 404) return null
// Narrowed to 200: typed as the pinned Note object
return await response.json()
}params— Interpolated into path parameters like/{id}.query— Serialized into the URL query string (e.g.?search=term&page=1).body— Serialized as JSON.formData— PassformDatainstead ofargs.bodyfor file uploads.captchaToken— PasscaptchaTokenfor captcha-protected endpoints.
Type inference & compile checks
The fetcher validates endpoints through ApiPluginRegistry. An invalid method, typo in the path, or missing parameter produces an immediate compile-time error:
await fetcher({
plugin: '@acme/site-notes',
// @ts-expect-error Type '"delete"' is not assignable to type '"get"'.
method: 'delete',
module: 'notes',
path: '/',
})The response is status-aware: response.status is typed as a union of declared HTTP codes (e.g. 200 | 404). Once you narrow on response.status, response.json() automatically narrows to that status code's specific schema.
How the registry types your calls
Each plugin exports a lightweight contract type from its config.api.ts:
import type { ApiPluginContract } from '@vitnode/core/lib/fetcher'
export type VitNodeApiPlugin = ApiPluginContract<
ReturnType<typeof siteNotesApiPlugin>
>ApiPluginContract strips away runtime internals (Hono handlers, database connections, and background workers) and extracts only what the compiler needs: the plugin ID, module paths, route endpoints, methods, and Zod schemas.
When you run vite dev or vite build, the vitnode:plugin-routes plugin automatically updates your app's src/api-registry.gen.ts:
import type { VitNodeApiPlugin as ApiPlugin0 } from '@acme/site-notes/config.api'
declare module '@vitnode/core/lib/fetcher/registry' {
interface ApiPluginRegistry {
'@acme/site-notes': ApiPlugin0
}
}
export type { ApiPluginRegistry } from '@vitnode/core/lib/fetcher/registry'Types only, never a value
All imports in the registry use import type. This guarantees that
server-side code, database credentials, and internal handlers never leak into
the browser bundle. The actual API factory executes exclusively in
vitnode.api.config.ts.
Loading data in plugin routes
Plugin routes load data using definePluginRoute({ load }). The loader runs during SSR and client navigation, handing typed loaderData to the page component:
import {
definePluginRoute,
type PluginRoutePageProps,
} from '@vitnode/core/routing'
import { notesQuery } from '../features/notes/notes-query'
interface Note {
id: string
title: string
}
export const route = definePluginRoute({
load: async ({ context }) => {
return await context.queryClient.query({
...notesQuery(),
staleTime: 'static',
})
},
})
const NotesPage = ({ loaderData }: PluginRoutePageProps<Note[]>) => {
return (
<div className="container mx-auto flex max-w-3xl flex-col gap-4 p-4">
{loaderData.map((note) => (
<h2
key={note.id}
className="text-xl font-semibold tracking-tight text-balance"
>
{note.title}
</h2>
))}
</div>
)
}
export default NotesPagestaleTime: "static" makes route loaders fast and cheap: a cached entry is returned instantly without redundant network trips on repeat visits, while the component's useQuery keeps data fresh in the background.
Mutations & cache invalidation
After creating, editing, or deleting records, call queryClient.invalidateQueries to refetch fresh data, then display a sonner toast notification:
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { fetcher } from '@vitnode/core/tanstack/fetcher'
import { toast } from 'sonner'
import { notesQueryKey } from './notes-query'
export const useCreateNote = () => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async (title: string) => {
const response = await fetcher({
plugin: '@acme/site-notes',
args: { body: { title } },
method: 'post',
module: 'notes',
path: '/',
})
if (!response.ok) {
throw new Error('Failed to create note.')
}
return await response.json()
},
onSuccess: async () => {
// Invalidate cache and trigger sonner toast
await queryClient.invalidateQueries({ queryKey: notesQueryKey })
toast.success('Note created successfully!')
},
})
}Built-in system queries
Core provides three pre-warmed query hooks for common application state:
| Hook | Import | Suspends | Description |
|---|---|---|---|
useSessionQuery() | @vitnode/core/tanstack/auth | No | Signed-in visitor session, or null |
useAdminSessionQuery() | @vitnode/core/tanstack/admin | Yes | AdminCP staff identity and permissions |
useMiddlewareConfigQuery() | @vitnode/core/tanstack/auth | Yes | Public deployment configuration before sign-in |
Deployment configuration
useMiddlewareConfigQuery() reads public server capabilities without requiring an active session, allowing login and registration screens to adapt dynamically:
import { useMiddlewareConfigQuery } from '@vitnode/core/tanstack/auth'
const LoginPage = () => {
const { data: config } = useMiddlewareConfigQuery()
return (
<div className="container mx-auto max-w-md p-4">
{config.captcha ? <p>Captcha enabled</p> : null}
{config.sso?.length ? <p>Social sign-in available</p> : null}
</div>
)
}
export default LoginPagesso— Enabled social login providers.isEmail— Whether an email adapter is configured (required for password reset).captcha— Public site key and provider type (recaptchaorcloudflare).ai.models— Configured AI models in the deployment.isKnown—falseif the API was unreachable (UNKNOWN_MIDDLEWARE_CONFIG), ensuring login forms still render.
Freshness constants
VitNode provides standard stale times in @vitnode/core/lib/query-freshness:
| Constant | Duration | Recommended use case |
|---|---|---|
RECORD_STALE_TIME | 30 seconds | Fast-changing feeds, dynamic user activity, announcements |
STATIC_STALE_TIME | 5 minutes | Site configuration, navigation menus, role permissions |
Universal vs. server-only fetcher
fetcher from @vitnode/core/tanstack/fetcher is the universal client. For operations that run exclusively on the server (server functions, cron tasks, or external secrets), use @vitnode/core/tanstack/fetcher/server:
| Feature | Universal (fetcher) | Server-only (fetcher/server) | Description |
|---|---|---|---|
| Standard queries & mutations | ✓ | ✓ | End-to-end typed requests |
args, formData, captchaToken | ✓ | ✓ | Request parameter serialization |
allowSaveCookies | ✓ | Relays API Set-Cookie headers back to the browser | |
additionalHeaders | ✓ | Forwards internal credentials or custom headers | |
origin | ✓ | Directs requests to an alternative upstream API host |
Keep server code out of browser bundles
Import from @vitnode/core/tanstack/fetcher/server only inside *.server.ts
files or createServerFn handlers. Value imports of backend factories or
server fetchers should never reach client bundles.
Browser-only requests with fetcherClient
For client code that never executes on the server—such as browser event listeners, interactive dialogs, or framework-neutral utilities—call fetcherClient from @vitnode/core/lib/fetcher-client:
import { fetcherClient } from '@vitnode/core/lib/fetcher-client'
export const deleteNote = async (id: string) => {
const response = await fetcherClient({
plugin: '@acme/site-notes',
args: { params: { id } },
method: 'delete',
module: 'notes',
path: '/{id}',
})
return response.ok
}fetcherClient shares the exact same call signature and type inference as the universal fetcher, but is built exclusively for the browser with credentials: "include" and automatic rate-limiting notifications.
Content Engine routes
Public routes created via Content Engine (publicApi) are automatically typed under module: "content/<path>":
const response = await fetcher({
plugin: '@vitnode/blog',
args: { params: { slug: 'welcome' } },
method: 'get',
module: 'content/posts',
path: '/{slug}',
})For generated AdminCP content modules where schemas are dynamic, use rawFetcher from @vitnode/core/tanstack/fetcher instead. See Content Engine for defining content types, schema modeling, and generating CRUD APIs.