Documentation

Vue Query

Generate typed query, mutation, and infinite-query options for Vue Query from named endpoints, with optional Nuxt SSR setup.

Nuxt Endpoints can generate ordinary Vue Query options from named endpoint contracts. Endpoint input and output types stay in Nuxt Endpoints; Vue Query owns caching, retries, invalidation, optimistic updates, polling, and infinite-query state.

Use Generated Client or useEndpoint when a request does not need Vue Query's server-state features.

Install and enable

Install Vue Query as an optional peer dependency:

vp add @tanstack/vue-query

Enable generation in nuxt.config.ts:

export default defineNuxtConfig({
  modules: ['nuxt-endpoints'],
  endpoints: {
    client: {
      query: true,
    },
  },
})

This generates #endpoints/query. query: true uses the conservative external setup mode: the application owns its QueryClient and Vue plugin. Use automatic SSR setup when the module should own that wiring.

All generated factories require a supported HTTP method and a non-reserved operation name because operation names become stable query and mutation keys. Names such as then and constructor are skipped because they conflict with callable client properties:

export const endpoint = defineEndpoint({
  operation: 'getUser',
  params: UserParams,
  responses: {
    200: User,
    404: NotFound,
  },
})

Factories are generated by HTTP method:

  • GET and HEAD: endpointQueryOptions and endpointInfiniteQueryOptions.
  • POST, PUT, PATCH, and DELETE: endpointMutationOptions.

Other methods (OPTIONS, CONNECT, TRACE) map to neither queries nor mutations, so no factory is generated for them — the build logs a warning when such an operation exists while the query adapter is enabled. They remain callable through $endpoint.

Queries

Pass the generated options directly to useQuery:

import { useQuery } from '@tanstack/vue-query'
import { endpointQueryOptions } from '#endpoints/query'

const user = useQuery(
  endpointQueryOptions.getUser({
    params: { id: '123' },
  }),
)

user.data.value?.name

The request can be a getter when route params or filters are reactive. The key and request update together:

const userId = ref('123')

const user = useQuery(
  endpointQueryOptions.getUser(() => ({
    params: { id: userId.value },
  })),
)

Vue Query's AbortSignal is forwarded to the endpoint request, so cancellation still reaches the underlying HTTP call.

Mutations and invalidation

Mutation variables are inferred from the endpoint request contract:

import { useMutation, useQueryClient } from '@tanstack/vue-query'
import { endpointMutationOptions, endpointQueryOptions } from '#endpoints/query'

const queryClient = useQueryClient()

const updateUser = useMutation({
  ...endpointMutationOptions.updateUser(),
  onSuccess: (_data, variables) => {
    queryClient.invalidateQueries({
      queryKey: endpointQueryOptions.getUser.key({
        params: { id: variables.params.id },
      }),
    })
  },
})

updateUser.mutate({
  params: { id: '123' },
  body: { name: 'Ada' },
})

Nuxt Endpoints does not guess which queries a mutation affects. Use Vue Query lifecycle callbacks and the generated keys for invalidation or optimistic updates.

Calling factory.key() with no request returns the operation prefix and matches every cached request variant for that operation. Passing the required request returns the exact typed key for that cache entry. For an operation without input, key({}) creates the exact key while key() remains the prefix.

Declared non-2xx responses

The default factory follows normal Vue Query behavior: a declared non-2xx response rejects and enters Query error state.

Use the explicit .result() factory when statuses such as 404 or 422 should be successful cached data:

const user = useQuery(
  endpointQueryOptions.getUser.result({
    params: { id: 'missing' },
  }),
)

if (user.data.value?.status === 404) {
  user.data.value.body.message
}

Result-mode cache data is serializable and contains only { status, ok, body }. Native response headers are deliberately excluded from the Query cache and SSR payload. Use the low-level $endpoint(...).result() API when response headers are required.

Transport failures reject in both data and result modes.

Query keys and request identity

Query and Infinite Query keys include the operation plus normalized params, query, body, and idempotencyKey when present. Object key insertion order does not change cache identity.

Ordinary request headers and credentials are never included in a key. idempotencyKey is a special typed request input that becomes a header on the wire, but it remains part of cache identity and is visible in Query Devtools. Use an opaque request identifier, never a secret.

If the same request shape can produce different data by user, tenant, locale, or feature flag, add a non-sensitive logical keyScope:

endpointQueryOptions.getUser({
  params: { id: '123' },
  keyScope: { tenant: tenantId },
})

Clear or scope the browser cache when signed-in identity changes. Auto mode and the external recipe below create a request-scoped QueryClient and isolate SSR requests.

Automatic SSR setup

Choose auto when the application does not already install Vue Query:

export default defineNuxtConfig({
  modules: ['nuxt-endpoints'],
  endpoints: {
    client: {
      query: {
        setup: 'auto',
        staleTime: 60_000,
      },
    },
  },
})

Auto mode creates a request-scoped QueryClient, installs VueQueryPlugin, transfers dehydrated state through Nuxt, forwards the incoming request context for SSR endpoint calls, hydrates on the client, and clears the server cache after dehydration. staleTime defaults to 60 seconds in auto mode to avoid an immediate duplicate browser request after hydration.

Do not combine auto mode with another application-installed VueQueryPlugin; choose exactly one QueryClient owner.

Pages that require query data in the server-rendered HTML must await the query during SSR:

<script setup lang="ts">
import { useQuery } from '@tanstack/vue-query'
import { onServerPrefetch } from 'vue'
import { endpointQueryOptions } from '#endpoints/query'

const user = useQuery(endpointQueryOptions.getUser({ params: { id: '123' } }))
onServerPrefetch(() => user.suspense())
</script>

External SSR setup

Keep query: true or set setup: 'external' when the application already owns Vue Query configuration. A Nuxt plugin must create the QueryClient inside the plugin body so it is request-scoped during SSR:

// app/plugins/vue-query.ts
import type { DehydratedState } from '@tanstack/vue-query'
import { QueryClient, VueQueryPlugin, dehydrate, hydrate } from '@tanstack/vue-query'
import { defineNuxtPlugin, useState } from 'nuxt/app'

export default defineNuxtPlugin((nuxtApp) => {
  const state = useState<DehydratedState | null>('vue-query', () => null)
  const queryClient = new QueryClient({
    defaultOptions: { queries: { staleTime: 60_000 } },
  })

  nuxtApp.vueApp.use(VueQueryPlugin, { queryClient })

  if (import.meta.server) {
    nuxtApp.hooks.hook('app:rendered', () => {
      state.value = dehydrate(queryClient)
      queryClient.clear()
    })
  }

  if (import.meta.client) {
    hydrate(queryClient, state.value)
  }
})

Do not hoist new QueryClient() to module scope; that would share cached data across SSR requests.

During SSR, call generated factories while Nuxt context is active—for example, inside component setup, a Nuxt plugin, or route middleware. Do not construct their options at module scope or after losing Nuxt context: the adapter captures the request-aware fetcher when the factory is called.

Infinite queries

Infinite queries require an explicit page-param-to-request mapping. The adapter does not assume a field name such as cursor, page, or offset:

import { useInfiniteQuery } from '@tanstack/vue-query'
import { endpointInfiniteQueryOptions } from '#endpoints/query'

const users = useInfiniteQuery(
  endpointInfiniteQueryOptions.searchUsers({
    initialPageParam: undefined,
    keyScope: { tenant: tenantId },
    request: (pageParam) => ({
      query: { cursor: pageParam, term: 'ada' },
    }),
    getNextPageParam: (page) => page.nextCursor,
  }),
)

Set keyScope beside initialPageParam, not inside request. Infinite-query identity is derived from request(initialPageParam) plus the top-level keyScope.

Wrap the whole factory call in computed when filters are reactive, so a filter change creates a new key and request function together.

The page body remains fully typed. With current Vue Query types, data.value.pageParams may widen to unknown[]; this does not affect runtime pagination.

Prefetching and Devtools

The factories return standard Vue Query options, so normal APIs work unchanged:

await queryClient.prefetchQuery(endpointQueryOptions.getUser({ params: { id } }))
const user = await queryClient.ensureQueryData(endpointQueryOptions.getUser({ params: { id } }))

@tanstack/vue-query-devtools also works without adapter-specific setup. Generated keys use the visible nuxt-endpoints namespace and operation name.