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:

plugins/site-notes/src/features/notes/notes-query.ts
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:

FieldDescriptionInferred from
pluginPackage name of the target pluginApp-configured plugin registry
moduleModule path under the plugin (e.g. 'notes', 'admin/notes')Plugin's buildApiPlugin definition
pathRoute path declared inside the module ('/', '/{id}')Module's route declarations
methodHTTP 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:

plugins/site-notes/src/features/notes/pin-note.ts
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 — Pass formData instead of args.body for file uploads.
  • captchaToken — Pass captchaToken for 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:

plugins/site-notes/src/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:

apps/web/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:

plugins/site-notes/src/pages/notes-page.tsx
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 NotesPage

staleTime: "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:

plugins/site-notes/src/features/notes/use-create-note.ts
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:

HookImportSuspendsDescription
useSessionQuery()@vitnode/core/tanstack/authNoSigned-in visitor session, or null
useAdminSessionQuery()@vitnode/core/tanstack/adminYesAdminCP staff identity and permissions
useMiddlewareConfigQuery()@vitnode/core/tanstack/authYesPublic 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:

plugins/site-notes/src/pages/login-page.tsx
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 LoginPage
  • sso — Enabled social login providers.
  • isEmail — Whether an email adapter is configured (required for password reset).
  • captcha — Public site key and provider type (recaptcha or cloudflare).
  • ai.models — Configured AI models in the deployment.
  • isKnown — false if 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:

ConstantDurationRecommended use case
RECORD_STALE_TIME30 secondsFast-changing feeds, dynamic user activity, announcements
STATIC_STALE_TIME5 minutesSite 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:

FeatureUniversal (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:

plugins/site-notes/src/features/notes/client-actions.ts
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.