Load and mutate data in a Nuxt app — useFetch for server-rendered data, then TanStack Query for client-side reads and mutations with caching built in.
Why: in a Nuxt page, useFetch runs on the server during the first page load — the data arrives already rendered in the HTML, with no loading flags, no exposed API keys, and no double fetch in the browser. Reach for this first; client fetching is the fallback.
<!-- app/pages/users.vue — lives at /users -->
<script setup lang="ts">
type User = { id: number; name: string }
// Runs on the server during the first page load,
// then in the browser when you navigate here from another page
const { data: users } = await useFetch<User[]>(
'https://jsonplaceholder.typicode.com/users',
)
</script>
<template>
<ul>
<li v-for="u in users" :key="u.id">{{ u.name }}</li>
</ul>
</template>Why: when data must load or refresh in the browser — polling, search-as-you-type, refetching after a click — TanStack Query handles the hard parts for you: it caches results, avoids firing the same request twice, retries failures, and refreshes data that has gone stale. A hand-rolled watcher fetch does none of that. Note: requires registering VueQueryPlugin once at the app root.
$ pnpm add @tanstack/vue-query<script setup lang="ts">
import { useQuery } from '@tanstack/vue-query'
type Repo = { name: string; stargazers_count: number }
// isPending while loading, error if it failed, data when it's ready
const { data, isPending, error } = useQuery({
queryKey: ['repo', 'tanstack/query'], // cache + dedupe by this key
queryFn: async (): Promise<Repo> => {
const res = await fetch('https://api.github.com/repos/tanstack/query')
if (!res.ok) throw new Error('Request failed')
return res.json()
},
})
</script>
<template>
<p v-if="isPending">Loading…</p>
<p v-else-if="error">Error: {{ error.message }}</p>
<p v-else-if="data">
{{ data.name }} — {{ data.stargazers_count }} stars
</p>
</template>Why: writes (POST, PUT, DELETE) go through useMutation, which tracks the in-flight state for you (isPending) and lets you mark cached reads as outdated after a change — they refetch automatically, so the screen always ends up matching the server.
<script setup lang="ts">
import { useMutation, useQueryClient } from '@tanstack/vue-query'
const { postId } = defineProps<{ postId: number }>()
const queryClient = useQueryClient()
// mutationFn does the actual write; mutate(…) below triggers it
const { mutate, isPending } = useMutation({
mutationFn: (text: string) =>
fetch('/api/comments', {
method: 'POST',
body: JSON.stringify({ postId, text }),
}),
onSuccess: () => {
// Mark the cached list stale → it refetches automatically
queryClient.invalidateQueries({ queryKey: ['comments', postId] })
},
})
</script>
<template>
<button :disabled="isPending" @click="mutate('Great post!')">
{{ isPending ? 'Sending…' : 'Add comment' }}
</button>
</template>