Fetcher
Call VitNode API routes with the universal, type-safe fetcher from @vitnode/core/tanstack/fetcher. Covers arguments, errors, captcha, the plugin API registry, built-in queries and freshness constants.
fetcher from @vitnode/core/tanstack/fetcher is how app and plugin code calls a VitNode API route. It is universal, so the same call runs during server-side rendering and in the browser. On the server it forwards the visitor's cookies, user agent and IP to the API; in the browser it calls /api/* with credentials: "include". TypeScript checks every call against the plugin's routes, so a typo is a compile error.
The universal call
Name the plugin, the module, the path and the method, inline at the call site:
import { fetcher } from '@vitnode/core/tanstack/fetcher'
const response = await fetcher({
plugin: 'site-notes',
method: 'get',
module: 'notes',
path: '/',
})
if (response.ok) {
const notes = await response.json() // { id: number; title: string; body: string }[]
}Inside a plugin, write plugin: manifest.id with import { manifest } from '@/manifest.gen', so the id is spelled in one place.
| Field | Description | Checked against |
|---|---|---|
plugin | The plugin id ('site-notes', '@vitnode/core') | The app's plugin API registry |
module | The module path, any depth ('notes', 'admin/notes') | The plugin's modules |
path | The route path inside the module ('/', '/{id}') | The module's routes |
method | The HTTP method declared for that path ('get', 'post') | The routes declared for that path |
Errors and status codes
fetcher resolves with the Response for every status except 500, so check response.ok or response.status before you read the body. It rejects on a 500 (with the response body in the message) and on a network failure. Other 4xx and 5xx answers are also logged to the console.
response.status is typed as the union of the statuses the route declares, such as 200 | 404. Narrowing on status or ok narrows response.json() to that status's schema. The body is typed as JSON arrives, so a z.date() field is an ISO string; convert it with new Date(value) when you need a date.
Inside a queryFn or a mutationFn, throw when the response is not what you expect, so TanStack Query reports an error instead of caching a failed answer.
Arguments
args is required exactly when the route declares a body, path parameters or a query, and each part is typed by the route's Zod schema:
const response = await fetcher({
plugin: 'site-notes',
method: 'get',
module: 'notes',
path: '/{id}',
args: { params: { id: 2 } },
})
if (response.status === 404) return null
return await response.json() // narrowed to 200: one note| Option | Description |
|---|---|
args.params | Path parameters, interpolated into /{id}. |
args.query | The query string, such as { search: 'term' }. |
args.body | The JSON body. |
formData | A FormData body for file uploads, sent instead of args.body. |
captchaToken | A solved captcha token. See Captcha. |
withPagination | For cursor-paginated routes, fills query.first with 10 and query.search with '' unless set. Only applies when the call passes a query. |
options | Extra fetch options, such as { signal } from an AbortController. |
Captcha
A route built with withCaptcha: true refuses a request without a solved captcha. Pass the token as captchaToken, next to args:
const response = await fetcher({
plugin: "@vitnode/core",
method: "post",
module: "users",
path: "/sign_up",
args: { body: values },
captchaToken,
});The fetcher sends it in the x-vitnode-captcha-token header, from the browser and during SSR alike. An AutoForm with captcha turned on hands the solved token to its onSubmit; for your own forms, see Captcha on custom forms.
How the registry types your calls
TypeScript, not the router, knows which routes exist. Each plugin exports a contract type from its config.api.ts. The plugin generator already writes it:
import type { ApiPluginContract } from '@vitnode/core/api/lib/plugin'
export type VitNodeApiPlugin = ApiPluginContract<
ReturnType<typeof siteNotesApiPlugin>
>ApiPluginContract keeps only what the compiler needs: the plugin id, the module tree, and each route's method, path and schemas. Hono handlers, database code and secrets stay out.
Every vitnode dev and vitnode build rewrites your app's src/api-registry.gen.ts with one entry per configured plugin that has an API:
import type { VitNodeApiPlugin as ApiPlugin0 } from 'site-notes/config.api'
declare module '@vitnode/core/lib/fetcher/registry' {
interface ApiPluginRegistry {
'site-notes': ApiPlugin0
}
}The registry uses import type only, so server code never reaches the browser
bundle. The API factory itself runs only in vitnode.api.config.ts.
An invalid call fails to compile at the wrong property:
await fetcher({
plugin: 'site-notes',
// @ts-expect-error The notes module declares no DELETE on "/".
method: 'delete',
module: 'notes',
path: '/',
})Load data and invalidate it
In a route loader, call the fetcher through TanStack Query, so the page, a hover preload and later navigations share one cached request. After a write, invalidate the query and show a toast. Both patterns have their own guides:
- Add a route: a page whose loader fetches one note.
- Cache app data: warm a query in the loader, read it in the page, and invalidate it after a write.
Built-in queries
Core ships three hooks for application state:
| Hook | Import | Suspends | Returns |
|---|---|---|---|
useSessionQuery() | @vitnode/core/tanstack/auth | No | { data, isError, isPending }; data.user is null for a guest |
useAdminSessionQuery() | @vitnode/core/tanstack/admin | Yes | The AdminCP staff session and permissions |
useMiddlewareConfigQuery() | @vitnode/core/tanstack/auth | Yes | The deployment's public configuration, readable before sign-in |
Deployment configuration
useMiddlewareConfigQuery() lets sign-in and registration screens adapt to the deployment:
import { useMiddlewareConfigQuery } from '@vitnode/core/tanstack/auth'
export const SignInHint = () => {
const { data: config } = useMiddlewareConfigQuery()
return config.sso.length > 0 ? <p>Social sign-in is available.</p> : null
}| Field | Description |
|---|---|
sso | Enabled social sign-in providers. |
password | Whether password sign-in is enabled. |
passkeys | Whether passkey sign-in is enabled. |
isEmail | Whether an email adapter is configured, which password reset needs. |
captcha | The public site key and provider, 'cloudflare_turnstile' or 'recaptcha_v3', when captcha is on. |
ai.models | The AI models configured in the deployment. |
isKnown | false when the API could not be read. The other fields then hold safe defaults (password sign-in only). |
Freshness constants
@vitnode/core/lib/query-freshness exports two staleTime values for your own queries:
| Constant | Value | Use it for |
|---|---|---|
OPERATIONAL_STALE_TIME | 15 seconds | Data that changes from minute to minute, such as counters and queues |
RECORD_STALE_TIME | 60 seconds | Records people read more often than they change, such as content and containers |
For records, invalidate the query after a write instead of shortening the stale time.
Server-only fetcher
@vitnode/core/tanstack/fetcher/server exports a fetcher with the same call shape plus three options a browser must never have: allowSaveCookies, additionalHeaders and origin. Use it only in *.server.ts files and server functions. Server functions explains when you need it.
SSR skips the network when the API lives next door
When your app mounts the API in its own process (the default single app does,
via configureInProcessApi from
@vitnode/core/lib/fetcher/in-process-api.server), server-side calls hand the
request straight to it - no HTTP round trip, no trip through your proxy or
CDN, same cookies, headers and signed visitor IP. Set VITNODE_API_URL to a
different origin, or pass origin on a call, and the request goes over the
network as before. Without VITNODE_API_URL and without an in-process API,
that network call goes to VITNODE_WEB_URL - never to the incoming request's
Host, which a visitor controls.
Browser-only code with fetcherClient
Code that never runs on a server, such as a framework-neutral browser utility, can call fetcherClient from @vitnode/core/lib/fetcher-client. It takes the same call and infers the same types, sends credentials: "include", and reports a 429 as a vitnode:rate-limit window event:
import { fetcherClient } from '@vitnode/core/lib/fetcher-client'
export const countNotes = async () => {
const response = await fetcherClient({
plugin: 'site-notes',
method: 'get',
module: 'notes',
path: '/',
})
return response.ok ? (await response.json()).length : 0
}Everything that renders on the server, including shared views/* modules, uses fetcher.
Content Engine routes
A content type's public routes (publicApi) are typed under module: 'content/<publicApi.path>'. The blog plugin's publicApi.path is blog:
const response = await fetcher({
plugin: '@vitnode/blog',
method: 'get',
module: 'content/blog',
path: '/{slug}',
args: { params: { slug: 'welcome' }, query: { locale: 'en' } },
})Generated AdminCP content modules have no type to infer from, so they use rawFetcher from @vitnode/core/tanstack/fetcher. See Content Engine.