The foundations of efficient data loading
When building production applications with Nuxt, how your data flows from server to client directly impacts performance, user experience, and your ability to scale. Data fetching in Nuxt: a Vue & Nuxt guide for developers covers the essential patterns we've refined across e-commerce, CRM, and SaaS platforms. In this article, we'll explore how useFetch and useAsyncData work together to handle server-side rendering while maintaining clean, reusable code on the client.
The challenge is real: fetch data on the server to avoid waterfalls, reuse that data on the client without refetching, handle loading states gracefully, and invalidate caches when needed. Nuxt 3's composable-based approach gives us the tooling to solve these patterns elegantly.
useFetch — Fetch during SSR and reuse on the client
useFetch is the primary composable for retrieving data in Nuxt applications. It handles both server-side rendering and client-side hydration automatically. When called during SSR, the data fetches on the server before the page renders. When the client hydrates, it reuses that server-fetched data instead of making a redundant request.
Consider an e-commerce product listing page. We need product data available immediately on initial page load, whether the user arrives from a direct URL or navigates within the application.
// pages/products.vue
<script setup lang="ts">
interface Product {
id: string
name: string
price: number
image: string
}
const { data: products, pending, error, refresh } = await useFetch<Product[]>(
'/api/products',
{
method: 'GET',
headers: {
'Accept': 'application/json'
}
}
)
</script>
<template>
<div class="products-container">
<div v-if="pending" class="loading">
Loading products...
</div>
<div v-else-if="error" class="error">
Failed to load products: {{ error.message }}
</div>
<div v-else class="products-grid">
<div v-for="product in products" :key="product.id" class="product-card">
<img :src="product.image" :alt="product.name" />
<h3>{{ product.name }}</h3>
<p>{{ product.price }}</p>
</div>
</div>
</div>
</template>The key insight here is that useFetch must be called at the top level of your component's setup function or directly in the script setup block. Nuxt uses this restriction to track data fetching promises and ensure they complete before sending the initial HTML to the client. The await keyword pauses component initialization until the fetch completes—critical for SSR correctness.
The returned object gives us everything we need: data contains the response payload, pending indicates whether the request is in flight, and error captures any network or server errors. The refresh function allows manual data invalidation, which we'll explore further.
Keys — Unique keys for caching and deduplication
Nuxt caches fetch results using a key-based system. By default, useFetch generates a key from the request URL and method. In many scenarios, this works fine. However, when you have multiple requests that might collide or when you need explicit control over caching behavior, you must specify a key manually.
Imagine a CRM application where the same endpoint serves different data based on query parameters. Without explicit keys, Nuxt might reuse cached data when it shouldn't.
// pages/customers/[id].vue
<script setup lang="ts">
const route = useRoute()
const customerId = route.params.id
// Without explicit key, caching could fail for different customers
const { data: customer } = await useFetch(
`/api/customers/${customerId}`,
{
key: `customer-${customerId}` // Explicit key ensures proper caching
}
)
// Another request on the same page
const { data: orders } = await useFetch(
`/api/customers/${customerId}/orders`,
{
key: `customer-orders-${customerId}`
}
)
</script>The key parameter is particularly important when requests are parameterized by user input, route params, or reactive state. Each unique data stream should have a unique key. This prevents cache collisions and ensures Nuxt properly deduplicates requests—if the same key is requested twice during the same render cycle, Nuxt waits on a single HTTP request instead of firing two.
In trading platforms we've worked on, explicit keys prevented race conditions where rapid navigation between instrument pages caused stale data to leak into views.
Lazy fetching — lazy: true for non-critical data
Not all data is equally critical for the initial page render. Some data is below the fold, loaded after user interaction, or nice-to-have supplementary information. Marking these requests as lazy prevents them from blocking the initial server response.
When lazy: true is set, Nuxt doesn't await the fetch during SSR. Instead, it returns immediately with pending: true, and the data loads client-side. This is a powerful optimization tool but requires careful judgment about which data fits this pattern.
Consider a product detail page with a main product and related items:
// pages/products/[id].vue
<script setup lang="ts">
const route = useRoute()
const productId = route.params.id
// Critical—blocks SSR, included in initial HTML
const { data: product } = await useFetch(
`/api/products/${productId}`,
{
key: `product-${productId}`
}
)
// Non-critical—loads after client hydration
const { data: recommendations } = await useFetch(
`/api/products/${productId}/recommendations`,
{
key: `recommendations-${productId}`,
lazy: true // Doesn't block SSR
}
)
// Reviews might be paginated and heavy
const { data: reviews } = await useFetch(
`/api/products/${productId}/reviews?page=1`,
{
key: `reviews-${productId}-1`,
lazy: true
}
)
</script>
<template>
<div>
<h1>{{ product?.name }}</h1>
<p>{{ product?.description }}</p>
<section v-if="recommendations" class="recommendations">
<h2>Recommended for you</h2>
<div v-for="item in recommendations" :key="item.id">
{{ item.name }}
</div>
</section>
<section v-if="reviews" class="reviews">
<h2>Customer reviews</h2>
<div v-for="review in reviews" :key="review.id">
{{ review.text }}
</div>
</section>
</div>
</template>The trade-off is explicit: faster initial page load at the cost of additional client-side requests. For Core Web Vitals and user experience, this often makes sense. The critical product data renders immediately, the user sees the page, and supplementary data streams in as the browser fetches it. Ensure your templates handle the loading and error states gracefully.
Refresh — refresh() and watch options
Data doesn't live forever. Users filter lists, change date ranges, or expect fresh information after mutations. Nuxt provides two mechanisms for invalidating and refetching data: the refresh() function and the watch option.
refresh() is a manual trigger. Call it after a user action or when you know data has changed:
// pages/orders.vue
<script setup lang="ts">
const filters = ref({
status: 'pending',
dateFrom: '2024-01-01'
})
const { data: orders, refresh } = await useFetch(
'/api/orders',
{
query: filters // Include filters in the request
}
)
const handleFilterChange = () => {
// Refetch with new filters
refresh()
}
const handleCreateOrder = async (newOrder: any) => {
await $fetch('/api/orders', { method: 'POST', body: newOrder })
// After mutation succeeds, refresh the list
await refresh()
}
</script>
<template>
<div>
<button @click="handleFilterChange">Apply Filters</button>
<button @click="handleCreateOrder">Create Order</button>
<div v-for="order in orders" :key="order.id">
{{ order.id }}: {{ order.status }}
</div>
</div>
</template>The watch option is more declarative. When reactive dependencies change, Nuxt automatically refetches:
// pages/search.vue
<script setup lang="ts">
const route = useRoute()
const searchQuery = ref(route.query.q || '')
const { data: results } = await useFetch(
'/api/search',
{
query: { q: searchQuery }, // Dependency
watch: [searchQuery] // Refetch when searchQuery changes
}
)
const updateSearch = (newQuery: string) => {
searchQuery.value = newQuery
// useFetch automatically refetches due to watch
}
</script>In ride-hailing platforms, we use watch to refetch driver locations as the user's map viewport changes. In travel applications, we watch date ranges and destination filters to refresh available flights.
One subtlety: the watch option uses shallow watchers by default. If your dependency is an object and you modify nested properties, the watcher won't trigger. Use a getter or computed property for complex dependencies:
const filters = reactive({ minPrice: 0, maxPrice: 1000 })
const filterString = computed(() =>
`${filters.minPrice}-${filters.maxPrice}`
)
const { data: products } = await useFetch(
'/api/products',
{
watch: [filterString] // Watches the computed string value
}
)Common mistakes
- Calling useFetch inside conditionals or event handlers: Nuxt tracks data fetching promises during SSR initialization. Conditionally calling
useFetchbreaks this tracking. If you need conditional fetching, useuseAsyncDatawith a custom function instead. - Forgetting the await:
useFetchanduseAsyncDatareturn promises. Withoutawait, Nuxt won't wait for the request to complete before sending the initial HTML, causing hydration mismatches. - Not handling error states: Network requests fail. Ignoring the
errorreturn value leaves users confused when data doesn't appear. Always render conditional UI for error cases. - Over-aggressive caching with lazy: Setting
lazy: trueon truly critical data degrades perceived performance. The initial page looks incomplete. Reserve lazy fetching for genuinely secondary content. - Redundant keys across different pages: If multiple pages use the same key for different requests, cached data leaks between pages. Always scope keys to include enough context to avoid collisions.
- Watching objects without a getter: If your watch dependency is a reactive object and you modify nested properties, the watcher won't fire. Use a computed property that returns a new reference when dependencies change.
Key takeaways
useFetchis the primary tool for data fetching in Nuxt. It handles SSR seamlessly, reuses server-fetched data on the client, and providespending,error, andrefreshreturn values for complete control.- Explicit keys prevent cache collisions and enable proper request deduplication. When requests are parameterized by user input or route params, always provide a unique key.
- The
lazy: trueoption improves initial page load time by deferring non-critical data fetches to the client. Use this strategically for below-the-fold or supplementary content. - Use
refresh()for manual invalidation after mutations, and thewatchoption for automatic refetching when reactive dependencies change. Combine both patterns depending on your use case. - Always handle loading and error states explicitly in your templates. Production applications must gracefully degrade when networks fail or requests take time.
- Nuxt's caching strategy is automatic and intelligent, but understanding keys and watch dependencies gives you precision control over data freshness and performance trade-offs.






Comments
Loading comments…