Skip to main content

15.3 Data Fetching — useFetch, useAsyncData, $fetch, Caching Strategies

Data Fetching Overview​

The core of any web application is fetching and displaying data. Nuxt 3 provides data fetching solutions that work correctly in SSR, SSG, and CSR environments.

Three Data Fetching Methods in Nuxt 3​

MethodCharacteristicsWhen to Use
useFetchSimplest, URL-basedMost API requests
useAsyncDataMore flexible custom logicComplex async logic
$fetchBasic HTTP clientEvent handlers, server code

Data Fetching Flow in SSR​

Server                          Client
│ │
│ 1. Page request │
│◄──────────────────────────────│
│ │
│ 2. useFetch runs (server) │
│ ↓ API call │
│ ↓ Data received │
│ │
│ 3. HTML with data sent │
│──────────────────────────────►│
│ │
│ 4. Hydration
│ (no duplicate request)

In SSR, data is fetched on the server and embedded in the HTML, so no duplicate request is made on the client.


useFetch​

useFetch is the most commonly used data fetching composable in Nuxt 3.

Basic Usage​

<template>
<div>
<!-- Loading state -->
<div v-if="pending">Loading...</div>

<!-- Error state -->
<div v-else-if="error">
Error: {{ error.message }}
</div>

<!-- Data display -->
<div v-else>
<h1>{{ user.name }}</h1>
<p>{{ user.email }}</p>
</div>
</div>
</template>

<script setup lang="ts">
interface User {
id: number
name: string
email: string
}

const { data: user, pending, error, refresh } = await useFetch<User>('/api/users/1')
</script>

Return Value Details​

const {
data, // Ref<T | null> — response data
pending, // Ref<boolean> — loading state
error, // Ref<Error | null> — error info
refresh, // () => Promise<void> — re-fetch function
execute, // () => Promise<void> — manual execution (used in lazy mode)
status, // Ref<'idle' | 'pending' | 'success' | 'error'>
} = await useFetch('/api/data')

Options​

const { data } = await useFetch('/api/users', {
// HTTP method
method: 'POST',

// Request body
body: { name: 'John Smith', email: 'john@example.com' },

// Query parameters
query: { page: 1, limit: 10 },

// Headers
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},

// Base URL (recommend using runtimeConfig's public.apiBase)
baseURL: 'https://api.example.com',

// Key (cache identifier)
key: 'my-unique-key',

// Lazy execution (manually execute with execute())
lazy: false,

// Run on server only
server: true,

// Transform response data
transform: (response) => response.data,

// Caching strategy ('no-cache', 'default', etc.)
getCachedData: (key, nuxtApp) => nuxtApp.payload.data[key],

// Pre-request hook
onRequest({ request, options }) {
console.log('Request:', request)
},

// Post-response hook
onResponse({ response }) {
console.log('Response:', response.status)
},

// Error handling hook
onResponseError({ response }) {
console.error('Error:', response.status)
}
})

Dynamic URLs and Reactive Parameters​

useFetch automatically detects reactive values and re-fetches data when they change.

<template>
<div>
<input v-model="searchQuery" placeholder="Search..." />
<select v-model="currentPage">
<option v-for="p in 10" :key="p" :value="p">Page {{ p }}</option>
</select>

<div v-if="pending">Searching...</div>

<ul v-else>
<li v-for="user in users" :key="user.id">
{{ user.name }}
</li>
</ul>
</div>
</template>

<script setup lang="ts">
const searchQuery = ref('')
const currentPage = ref(1)

interface User {
id: number
name: string
}

// Refs inside the query object are automatically tracked
// Changing searchQuery or currentPage triggers an automatic re-fetch
const { data: users, pending } = await useFetch<User[]>('/api/users', {
query: {
search: searchQuery, // pass ref directly
page: currentPage, // pass ref directly
limit: 10,
},
immediate: true,
})
</script>

POST Requests and Form Handling​

<template>
<form @submit.prevent="submitForm">
<input v-model="form.name" placeholder="Name" required />
<input v-model="form.email" type="email" placeholder="Email" required />
<button type="submit" :disabled="pending">
{{ pending ? 'Processing...' : 'Submit' }}
</button>
<p v-if="error" class="error">{{ error.message }}</p>
</form>
</template>

<script setup lang="ts">
const form = reactive({
name: '',
email: '',
})

// Set lazy: true for manual execution
const { data, pending, error, execute } = await useFetch('/api/users', {
method: 'POST',
body: form,
lazy: true, // Don't execute immediately
immediate: false,
})

const submitForm = async () => {
await execute()
if (!error.value) {
// Handle success
navigateTo('/success')
}
}
</script>

useAsyncData​

useAsyncData handles asynchronous logic more flexibly than useFetch. Use it when dealing with complex logic beyond a simple HTTP request.

Basic Usage​

const { data, pending, error } = await useAsyncData(
'unique-key', // Cache key (required)
async () => { // Async function
// Any async logic can go here
const response = await $fetch('/api/users')
return response
}
)

useFetch vs useAsyncData​

// These two snippets behave identically
const { data } = await useFetch('/api/users')

const { data } = await useAsyncData('users', () => $fetch('/api/users'))

Complex Async Logic​

// Call multiple APIs simultaneously
const { data } = await useAsyncData('dashboard', async () => {
// Parallel requests with Promise.all
const [users, products, orders] = await Promise.all([
$fetch('/api/users'),
$fetch('/api/products'),
$fetch('/api/orders'),
])

// Combine and transform data
return {
totalUsers: users.length,
totalProducts: products.length,
pendingOrders: orders.filter(o => o.status === 'pending').length,
recentOrders: orders.slice(0, 5),
}
})

Server-Only Logic​

// Run only on the server (e.g., direct DB queries)
const { data } = await useAsyncData('server-data', async () => {
// server: true ensures this code only runs on the server
if (process.server) {
// Direct DB query (safe only on server)
const db = useDatabase()
return await db.query('SELECT * FROM users LIMIT 10')
}
return null
}, { server: true })

Dependency-Based Re-Execution​

const userId = ref(1)
const tab = ref('posts')

const { data, refresh } = await useAsyncData(
() => `user-${userId.value}-${tab.value}`, // Dynamic key
async () => {
if (tab.value === 'posts') {
return await $fetch(`/api/users/${userId.value}/posts`)
} else if (tab.value === 'comments') {
return await $fetch(`/api/users/${userId.value}/comments`)
}
},
{
// Auto re-execute when userId or tab changes
watch: [userId, tab],
}
)

$fetch​

$fetch is Nuxt's low-level HTTP client. It is based on the ofetch library and is used in event handlers, server code, plugins, and more.

Basic Usage​

// GET request
const users = await $fetch('/api/users')

// POST request
const newUser = await $fetch('/api/users', {
method: 'POST',
body: { name: 'John Smith', email: 'john@example.com' },
})

// PUT request
const updated = await $fetch('/api/users/1', {
method: 'PUT',
body: { name: 'Jane Doe' },
})

// DELETE request
await $fetch('/api/users/1', { method: 'DELETE' })

When to Use $fetch​

<template>
<button @click="deleteUser(user.id)">Delete</button>
<button @click="likePost(post.id)">Like</button>
</template>

<script setup lang="ts">
// Use $fetch in event handlers (useFetch is only for setup)
const deleteUser = async (id: number) => {
try {
await $fetch(`/api/users/${id}`, { method: 'DELETE' })
await refresh() // Refresh the list
} catch (error) {
console.error('Delete failed:', error)
}
}

const likePost = async (id: number) => {
const result = await $fetch(`/api/posts/${id}/like`, {
method: 'POST',
})
console.log('Like count:', result.likes)
}
</script>

Differences Between $fetch and useFetch​

useFetch$fetch
Return value{ data, pending, error, refresh }Direct response data
SSR duplicate preventionAutomaticManual handling needed
CachingAutomaticNone
Usage location<script setup>, setup()Anywhere
Error handlingerror reftry/catch
Reactive paramsAuto-detectedNone

Global $fetch Configuration​

// plugins/api.ts
export default defineNuxtPlugin((nuxtApp) => {
const $customFetch = $fetch.create({
// Set base URL
baseURL: useRuntimeConfig().public.apiBaseUrl,

// Add auth token to all requests
onRequest({ options }) {
const token = useCookie('auth_token')
if (token.value) {
options.headers = {
...options.headers,
Authorization: `Bearer ${token.value}`,
}
}
},

// Redirect to login on 401
onResponseError({ response }) {
if (response.status === 401) {
navigateTo('/login')
}
},
})

// Provide as a global helper
return {
provide: {
api: $customFetch,
}
}
})
<!-- Usage -->
<script setup lang="ts">
const { $api } = useNuxtApp()

// baseURL + auth header applied automatically
const users = await $api('/users')
</script>

Caching Strategies​

Default Caching Behavior​

Nuxt automatically stores the results of useFetch and useAsyncData in the payload. By default, navigating away and returning to a page uses the cached data.

// Default: cached data is used when returning to a page
const { data } = await useFetch('/api/users')

// Disable cache: always make a fresh request
const { data } = await useFetch('/api/users', {
getCachedData: () => null, // Always returns null = no cache
})

Controlling Cache with getCachedData​

const { data } = await useFetch('/api/products', {
// Use cache if the same request was made within 5 minutes
getCachedData(key, nuxtApp) {
const cachedData = nuxtApp.payload.data[key]
if (!cachedData) return undefined // No cache → make fresh request

// Check cache expiry (5 minutes)
const expiresAt = new Date(cachedData._cachedAt)
expiresAt.setMinutes(expiresAt.getMinutes() + 5)

if (expiresAt < new Date()) {
return undefined // Expired → fresh request
}

return cachedData // Return valid cache
},

// Add timestamp to response
transform(response) {
return {
...response,
_cachedAt: new Date().toISOString(),
}
}
})

Implementing Global Cache with useState​

// composables/useProducts.ts
export const useProducts = () => {
// Store cache in global state
const products = useState('products-cache', () => null)
const lastFetched = useState('products-last-fetched', () => 0)

const fetchProducts = async (force = false) => {
const now = Date.now()
const CACHE_DURATION = 5 * 60 * 1000 // 5 minutes

// Reuse cache if it exists and hasn't expired
if (!force && products.value && (now - lastFetched.value) < CACHE_DURATION) {
return products.value
}

// Fresh request
const data = await $fetch('/api/products')
products.value = data
lastFetched.value = now

return data
}

const invalidateCache = () => {
products.value = null
lastFetched.value = 0
}

return { products, fetchProducts, invalidateCache }
}

Server-Side Cache (Nitro)​

// server/api/products/index.get.ts
export default defineCachedEventHandler(async (event) => {
// The result of this handler is cached on the server for 1 hour
const products = await fetchProductsFromDatabase()
return products
}, {
maxAge: 60 * 60, // 1 hour (in seconds)
name: 'products-list',

// Generate cache key (includes query params)
getKey: (event) => {
const query = getQuery(event)
return `products-${query.category || 'all'}-${query.page || 1}`
},

// Stale time (how long to serve stale cache while revalidating)
staleMaxAge: 60 * 5, // 5 minutes
})

Practical Example: Product Listing Page​

A complete product listing page with pagination, search, and filtering.

Server API​

// server/api/products/index.get.ts
interface Product {
id: number
name: string
price: number
category: string
stock: number
}

export default defineCachedEventHandler(async (event) => {
const query = getQuery(event)

const page = parseInt(query.page as string) || 1
const limit = parseInt(query.limit as string) || 12
const search = (query.search as string) || ''
const category = (query.category as string) || ''
const sortBy = (query.sortBy as string) || 'id'
const order = (query.order as string) || 'asc'

// In practice, query the database
let products: Product[] = [
{ id: 1, name: 'Wireless Keyboard', price: 79, category: 'electronics', stock: 50 },
{ id: 2, name: 'Mouse', price: 45, category: 'electronics', stock: 30 },
{ id: 3, name: 'Monitor Stand', price: 35, category: 'accessories', stock: 100 },
{ id: 4, name: 'Webcam', price: 89, category: 'electronics', stock: 20 },
{ id: 5, name: 'Laptop Bag', price: 65, category: 'accessories', stock: 45 },
]

// Search filter
if (search) {
products = products.filter(p =>
p.name.toLowerCase().includes(search.toLowerCase())
)
}

// Category filter
if (category) {
products = products.filter(p => p.category === category)
}

// Sort
products.sort((a, b) => {
const valA = a[sortBy as keyof Product]
const valB = b[sortBy as keyof Product]
if (order === 'asc') return valA > valB ? 1 : -1
return valA < valB ? 1 : -1
})

// Pagination
const total = products.length
const offset = (page - 1) * limit
const paginatedProducts = products.slice(offset, offset + limit)

return {
products: paginatedProducts,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
}
}
}, {
maxAge: 60, // 1-minute cache
getKey: (event) => {
const q = getQuery(event)
return `products-${JSON.stringify(q)}`
},
})

Product Listing Page​

<!-- pages/products/index.vue -->
<template>
<div class="products-page">
<!-- Search and filters -->
<div class="filters">
<input
v-model="searchQuery"
type="search"
placeholder="Search products..."
class="search-input"
/>

<select v-model="selectedCategory">
<option value="">All Categories</option>
<option value="electronics">Electronics</option>
<option value="accessories">Accessories</option>
</select>

<select v-model="sortBy">
<option value="id">Default</option>
<option value="price">Price: Low to High</option>
<option value="name">Name</option>
</select>
</div>

<!-- Loading skeleton -->
<div v-if="pending" class="product-grid">
<div
v-for="i in 12"
:key="i"
class="product-skeleton"
/>
</div>

<!-- Error state -->
<div v-else-if="error" class="error-state">
<p>Failed to load products.</p>
<button @click="refresh()">Try again</button>
</div>

<!-- Product list -->
<template v-else>
<p class="result-count">
{{ result?.pagination.total }} products found
</p>

<div v-if="result?.products.length === 0" class="empty-state">
No results found.
</div>

<div v-else class="product-grid">
<ProductCard
v-for="product in result?.products"
:key="product.id"
:product="product"
@add-to-cart="addToCart"
/>
</div>

<!-- Pagination -->
<Pagination
:current-page="currentPage"
:total-pages="result?.pagination.totalPages || 1"
@change="currentPage = $event"
/>
</template>
</div>
</template>

<script setup lang="ts">
interface Product {
id: number
name: string
price: number
category: string
stock: number
}

interface ProductsResponse {
products: Product[]
pagination: {
page: number
limit: number
total: number
totalPages: number
}
}

// Filter state
const searchQuery = ref('')
const selectedCategory = ref('')
const sortBy = ref('id')
const currentPage = ref(1)

// Reset page when search or category changes
watch(searchQuery, () => { currentPage.value = 1 })
watch(selectedCategory, () => { currentPage.value = 1 })

// Data fetching (reactive query params)
const { data: result, pending, error, refresh } = await useFetch<ProductsResponse>(
'/api/products',
{
query: {
search: searchQuery,
category: selectedCategory,
sortBy: sortBy,
page: currentPage,
limit: 12,
},
server: true,
}
)

// Add to cart
const cartStore = useCartStore()
const addToCart = async (product: Product) => {
await cartStore.add(product)
useToast().success(`${product.name} added to cart.`)
}

// SEO
useHead({
title: 'Products',
meta: [
{ name: 'description', content: 'Browse and purchase a wide range of products.' }
]
})
</script>

Product Detail Page (Parallel Data Requests)​

<!-- pages/products/[id].vue -->
<template>
<div v-if="product" class="product-detail">
<div class="product-images">
<NuxtImg
v-for="image in product.images"
:key="image"
:src="image"
:alt="product.name"
preset="product"
/>
</div>

<div class="product-info">
<h1>{{ product.name }}</h1>
<p class="price">{{ formatPrice(product.price) }}</p>

<div class="reviews-summary">
<span>⭐ {{ reviewStats?.averageRating.toFixed(1) }}</span>
<span>({{ reviewStats?.totalReviews }} reviews)</span>
</div>

<button
@click="addToCart"
:disabled="product.stock === 0"
>
{{ product.stock === 0 ? 'Out of Stock' : 'Add to Cart' }}
</button>
</div>

<!-- Reviews section -->
<section class="reviews">
<h2>Reviews ({{ reviewStats?.totalReviews }})</h2>
<div v-for="review in reviews" :key="review.id" class="review">
<strong>{{ review.author }}</strong>
<p>{{ review.content }}</p>
</div>
</section>

<!-- Related products -->
<section class="related">
<h2>Related Products</h2>
<div class="product-grid">
<ProductCard
v-for="p in relatedProducts"
:key="p.id"
:product="p"
/>
</div>
</section>
</div>
</template>

<script setup lang="ts">
const route = useRoute()
const productId = route.params.id as string

// Call multiple APIs in parallel
const [
{ data: product },
{ data: reviews },
{ data: reviewStats },
{ data: relatedProducts },
] = await Promise.all([
useFetch(`/api/products/${productId}`),
useFetch(`/api/products/${productId}/reviews`, {
query: { limit: 10 }
}),
useFetch(`/api/products/${productId}/reviews/stats`),
useFetch(`/api/products/${productId}/related`, {
query: { limit: 4 }
}),
])

// Handle 404
if (!product.value) {
throw createError({ statusCode: 404, message: 'Product not found.' })
}

const formatPrice = (price: number) =>
new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(price)

const cartStore = useCartStore()
const addToCart = () => {
if (product.value) {
cartStore.add(product.value)
}
}

// Dynamic SEO
useSeoMeta({
title: product.value?.name,
description: product.value?.description,
ogImage: product.value?.images[0],
ogTitle: product.value?.name,
})
</script>

Expert Tips​

1. Optimistic Updates with useAsyncData​

// composables/useLike.ts
export const useLike = (postId: number) => {
const isLiked = ref(false)
const likeCount = ref(0)

const { data: post } = await useFetch(`/api/posts/${postId}`)

if (post.value) {
isLiked.value = post.value.isLiked
likeCount.value = post.value.likeCount
}

const toggleLike = async () => {
// Optimistic update: change the UI first
const previousState = isLiked.value
isLiked.value = !isLiked.value
likeCount.value += isLiked.value ? 1 : -1

try {
await $fetch(`/api/posts/${postId}/like`, {
method: 'POST',
})
} catch (error) {
// Rollback on failure
isLiked.value = previousState
likeCount.value += isLiked.value ? 1 : -1
console.error('Like action failed')
}
}

return { isLiked, likeCount, toggleLike }
}

2. Implementing Infinite Scroll​

<script setup lang="ts">
const page = ref(1)
const items = ref<any[]>([])
const hasMore = ref(true)
const loading = ref(false)

const loadMore = async () => {
if (loading.value || !hasMore.value) return

loading.value = true
const data = await $fetch('/api/items', {
query: { page: page.value, limit: 20 }
})

items.value.push(...data.items)
hasMore.value = data.items.length === 20
page.value++
loading.value = false
}

// Detect scroll end with Intersection Observer
const sentinel = ref(null)
const { stop } = useIntersectionObserver(sentinel, ([entry]) => {
if (entry.isIntersecting) {
loadMore()
}
})

// Clean up observer on unmount
onUnmounted(stop)

// Load initial data
await loadMore()
</script>

<template>
<div>
<div v-for="item in items" :key="item.id">
{{ item.name }}
</div>

<!-- Scroll sentinel -->
<div ref="sentinel" class="sentinel">
<span v-if="loading">Loading...</span>
<span v-else-if="!hasMore">All items loaded.</span>
</div>
</div>
</template>

3. Standardizing Error Handling​

// composables/useApi.ts
export const useApi = () => {
const toast = useToast()

const handleError = (error: any) => {
if (error?.status === 401) {
toast.error('You need to be logged in.')
navigateTo('/login')
} else if (error?.status === 403) {
toast.error('You do not have permission to access this.')
} else if (error?.status === 404) {
toast.error('The requested resource was not found.')
} else if (error?.status >= 500) {
toast.error('A server error occurred. Please try again later.')
} else {
toast.error(error?.message || 'An unknown error occurred.')
}
}

const safeRequest = async <T>(fn: () => Promise<T>): Promise<T | null> => {
try {
return await fn()
} catch (error) {
handleError(error)
return null
}
}

return { safeRequest, handleError }
}
<script setup lang="ts">
const { safeRequest } = useApi()

const updateProfile = async (data: ProfileData) => {
const result = await safeRequest(() =>
$fetch('/api/profile', { method: 'PUT', body: data })
)

if (result) {
// Handle success
useToast().success('Profile updated successfully.')
}
}
</script>

4. Server-to-Client Data Hydration​

// server/api/config.get.ts — Fetch config from server
export default defineEventHandler(() => {
return {
featureFlags: {
newCheckout: process.env.FEATURE_NEW_CHECKOUT === 'true',
darkMode: true,
},
maintenance: false,
}
})
// plugins/config.ts — Provide for use across the whole app
export default defineNuxtPlugin(async () => {
const { data: config } = await useFetch('/api/config')

return {
provide: {
config: config.value,
}
}
})
<!-- Accessible in any component -->
<script setup lang="ts">
const { $config } = useNuxtApp()
const isNewCheckout = $config?.featureFlags.newCheckout
</script>

Summary​

Nuxt 3 data fetching is achieved through the right combination of three tools.

  • useFetch: Most commonly used. Simple URL-based data fetching with automatic SSR support.
  • useAsyncData: For complex async logic and combining data from multiple sources.
  • $fetch: For event handlers, manual execution needs, and server-side code.

Caching strategies:

  • Client cache: Managed with getCachedData and useState
  • Server cache: Nitro-level caching with defineCachedEventHandler
  • Page-level caching with swr in routeRules

Choosing the right data fetching strategy lets you improve both performance and user experience at the same time.