Skip to main content
Advertisement

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.

Advertisement