Data fetching in Nuxt
Nuxt data fetching explained within the context of Nuxt's SSR lifecycle
Intro
One of the most common questions when building Nuxt applications is “how do I fetch data correctly?”. In isomorphic frameworks like Nuxt, code runs on both server and client, making asking “where” and “when” equally as important.
Before diving into the tools, we’ll unpack Nuxt’s render lifecycle to understand how state gets serialized, transferred, and rehydrated across the server-client boundary.
With a full understanding of isomorphic rendering, we’ll then dig into the individual $fetch, useAsyncData and useFetch helpers before covering general usage, reference and examples.
Disclaimer: as with most of my Nuxt articles I wrote this to clarify my own understanding of Nuxt’s JS voodoo!
Problem space
Traditional vs Isomorphic rendering
Traditional applications
In traditional web applications, data fetching is straightforward because it happens in one place.
Traditional server-side applications (such as PHP or Ruby on Rails) fetch data on the server, render the HTML, then send it to the browser. The browser simply displays the HTML. This approach is simple and provides fast initial loads, but offers limited interactivity since new content requires requesting and rendering new pages from scratch.
Traditional client-rendered applications (single-page apps) work differently. The browser loads skeleton HTML and JavaScript, then the application starts, fetches data, and renders the application. This provides rich interactivity and smooth navigation between pages, but results in slower initial loads and poor SEO since search engines see empty pages.
Isomorphic applications
Nuxt uses universal rendering (aka isomorphic (opens new window) rendering) by default, which combines the best of both approaches.
To do this, the same Vue component code runs in both server and the client environments.
On any given page load:
- The server fetches any data and renders static HTML, providing a fast initial load and good SEO
- The client re-renders the same page, and hydrates the DOM tree, turning the static page into a live application
- On subsequent page visits, the client fetches any data and re-renders the page, with no need for hydration
This creates a complex problem: where and when should data fetching happen?
The “double-fetch” problem
The vanilla (non-Nuxt) way
Let’s walk through what happens when a user visits your blog’s posts page for the first time.
Your site is using universal rendering, so whilst the following code looks reasonable, it actually runs twice:
<script setup>
// ⚠️ runs once on server, then again on the client
const data = await fetch('/api/posts').then(r => r.json())
</script>
<template>
<!-- html is rendered once on the server, then hydrated once on the client -->
<div v-for="post in data" :key="post.id">
{{ post.title }}
</div>
</template>
On the server:
- The component code runs
- It makes an HTTP request to
/api/poststo get the data - It renders the HTML with all your post titles
In the browser:
- The fully-rendered HTML is received with all the posts visible immediately
- Vue begins the hydration process to make the page interactive (to do this, it runs the component code again)
- The component makes an HTTP request to
/api/poststo get the data (even though the data is already rendered)
You’ve now made two identical requests for the same data. This is known as the “double fetch problem.”
Why this is bad:
- Wasted network requests and server resources
- Slower page interactivity (waiting for the second fetch to complete)
- Risk of hydration mismatches (opens new window) if the data changed between requests
- Doubled load on your API
The idiomatic (Nuxt) way
For our posts page, we should use useFetch instead of plain fetch:
<script setup>
// ✅ This fetches once, either on server or on the client
const { data: posts } = await useFetch('/api/posts')
</script>
<template>
<div v-for="post in posts" :key="post.id">
{{ post.title }}
</div>
</template>
On the server:
- The component code runs
- Internally it directly calls the handler (opens new window) for
/api/posts(rather than making an HTTP request) - It renders the HTML with all your post titles (as before)
- It embeds a copy of the data via a payload (opens new window) in the HTML
In the browser:
- The rendered HTML is immediately visible and the hydration process re-renders the component (as before)
- The data is immediately available to the component from Nuxt’s state cache, pre-populated from the payload
- Nuxt skips the second fetch because the data is already there
On subsequent visits to the page:
- The component runs client-side only
- It does make an HTTP request to
/api/posts - It blocks the navigation transition until the data is ready
As you can see, Nuxt is jumping through a lot of hoops on your behalf to mitigate the issues of server/client boundaries!
If you’re interested in seeing that as a sequence diagram, I’ve attempted to illustrate that here (opens new window).
What exactly is hydration?
Although not directly related to data fetching, I feel the mechanism of “hydration” is often glossed over, so I’ll attempt to define it here for the purpose of completeness.
As mentioned, for a Nuxt app to deliver SEO-friendly HTML as quickly as possible on initial load, it renders the current page on the server. This process involves routing, component loading, setup scripts running, data fetching, template rendering etc, but not anything that would only happen in the client (such as executing onMount or adding click handlers, etc).
A snapshot of the current state of the app is then rendered as raw HTML and is passed to the client (the browser) but crucially at this point, without JavaScript or interactivity. However, the user expects a running application, so we need a way to make the various page elements interactive. This process is called hydration (opens new window).
The way it works is, the running instance of Vue on the client builds a brand new copy of the current page, and if everything goes well (opens new window) we get exactly the same HTML/DOM structure as the non-interactive server-rendered version. However, rather than replacing the existing page (as it would in pure client-side rendering) Vue walks the existing nodes in tandem with its new tree of virtual nodes.
As it traverses this tree, Vue attaches event listeners to elements (opens new window), connects ref objects to their corresponding DOM nodes, and sets up the reactivity system to track data changes. This transforms the static HTML into a live component tree where data flows through props, reactive state updates trigger re-renders, and user interactions invoke the correct event handlers.
For more information on Vue’s’ hydration constraints check the Vue docs (opens new window).
Nuxt’s isomorphic-aware tooling
Now we’ve explored isometric orchestration, let’s review Nuxt’s data-fetching helpers.
$fetch- Nuxt’s core HTTP client
- works anywhere (browser, server, API routes)
- no double-fetch protection
useAsyncData- wraps any async function to make it SSR-aware
- orchestrates state between server and client
useFetch- combines
useAsyncDataand$fetchfor SSR-aware URL fetching - convenience function for common case of fetching from a URL
- combines
In practice, you’ll likely use
useFetchmost often – but to understand why and when to use each tool, we’ll build up from the foundational helpers$fetchanduseAsyncData.
$fetch - a better HTTP client
$fetch is Nuxt’s built-in HTTP client (powered by the ofetch library).
It’s a better fetch() with automatic JSON parsing, error handling, and base URL resolution.
When to use it:
- Inside API routes on the server
- Client-side only operations (event handlers, user interactions)
- Anywhere outside component setup (event handlers, middleware, API routes)
- When you explicitly don’t need SSR behaviour (there is no double-fetch protection)
Where it works:
- Anywhere: pages, components, composables, API routes, middleware
What it does:
- Makes an HTTP request
- Returns a promise with the response
- That’s it - no SSR magic, no state management
Server usage:
export default defineEventHandler(async (event) => {
return $fetch('https://api.example.com/data')
})
Client usage:
<script>
async function handleFormSubmit() {
const res = await $fetch('/api/submit', {
method: 'POST',
body: { /* form data */ }
})
}
</script>
In the example above, $fetch is used inside an event handler that only runs on a user interaction (client-side only), so the double-fetch problem doesn’t apply.
useAsyncData - adding SSR awareness
useAsyncData wraps any async operation to make it SSR-aware, orchestrating when data loads and preventing navigation until complete.
It solves the double-fetch problem by:
- executing your function on the server during SSR
- serializing the result and transferring it to the client (via the payload)
- skipping execution on the client during hydration (it already has the data)
When to use it:
- You need to wrap custom async logic (not just URL fetching)
- You’re using a third-party library with its own query layer (like a CMS SDK)
- You want fine-grained control over caching and execution
Where it works:
- Only: pages, components, composables
What it returns:
- Reactive state objects for
data,statusanderror - Functions to
refreshandclearthe data
Basic usage:
<script setup>
// Parallel fetch example (access via: deals.value.coupons, deals.value.offers)
const { data: deals, refresh } = await useAsyncData('my-deals', async () => {
const [coupons, offers] = await Promise.all([
$fetch('/api/coupons'),
$fetch('/api/offers')
])
return { coupons, offers }
})
</script>
<template>
<pre>{{ deals }}</pre>
<button @click="refresh">Show latest deals</button>
</template>
Note the first argument 'my-deals', a unique key used to identify and cache the result. See the cookbook for more info.
useFetch - the convenience wrapper
useFetch wraps both useAsyncData and $fetch, providing SSR-aware URL fetching with minimal boilerplate.
When to use it:
- You’re fetching from a URL (internal API or external)
- You want the simplest, most common pattern
- You need SSR behaviour
Where it works:
- Pages, components, composables
Most common usage:
<script setup>
const { data: posts } = await useFetch('/api/posts')
</script>
<template>
<div v-for="post in posts" :key="post.id">
{{ post.title }}
</div>
</template>
This is equivalent to:
<script setup>
const { data: posts } = await useAsyncData('/api/posts', () => {
return $fetch('/api/posts')
})
</script>
See the cookbook for more examples.
General usage
Where to fetch data
The tool you choose depends on where your code runs.
In pages and components
Components and pages execute on both server (SSR) and client (hydration). Use useFetch or useAsyncData to coordinate data loading - they prevent double fetching and ensure users don’t see loading states by blocking server rendering and client navigation until data is ready.
They must be called synchronously at the top level of your <script setup> (or setup() function) or from a composable that is also called synchronously within setup:
<script setup>
// Fetches on server, transfers to client
const { data: user } = await useFetch('/api/user')
</script>
They cannot be called conditionally, inside loops, regular functions, or lifecycle hooks like onMounted.
For user-triggered actions that only happen client-side, use $fetch in event handlers:
<script setup>
async function saveProfile() {
// Only runs on user click (client-side)
await $fetch('/api/profile', {
method: 'PUT',
body: { /* user data */ }
})
}
</script>
<template>
<button @click="saveProfile">Save</button>
</template>
In API routes
API routes only run on the server; there’s no client or hydration so use $fetch or call your logic directly:
// server/api/posts.js
export default defineEventHandler(async (event) => {
// Fetch from external API
const example = await $fetch('https://api.example.com/data')
// Direct database call
const posts = await db.query('SELECT * FROM posts')
return posts
})
Non-blocking navigation
By default, useFetch and useAsyncData block navigation until the data loads - waiting for the fetch to complete on the server, or blocking route transitions during client-side navigation. Sometimes you want to show the page immediately and load data progressively.
The lazy variants (useLazyFetch and useLazyAsyncData) don’t block in either context - on the server, they render immediately without waiting; during client navigation, they allow the route transition to complete. They begin the fetch in parallel and return with status: 'pending', letting you handle the loading state manually.
Note that if the server finishes rendering before the fetch completes, the client will need to fetch again - trading double-fetch prevention for faster initial page display.
When data is not critical for initial render (below-the-fold content, secondary data):
<script setup>
// no "await" required
const { status, data: comments } = useLazyFetch('/api/comments')
</script>
<template>
<div v-if="status === 'pending'">
Loading comments...
</div>
<div v-else>
<Comment v-for="comment in comments" :key="comment.id" :comment="comment" />
</div>
</template>
This is just a shortcut for:
const { data } = useFetch('/api/comments', { lazy: true })
Reference
Common options
useFetch('/api/data', {
// Execution control
lazy: false, // If true, don't block navigation
server: true, // If false, only fetch on client
immediate: true, // If false, don't fetch until execute() is called
// Caching and refetching
key: 'my-key', // Custom cache key
watch: [someRef], // Refetch when these values change
dedupe: 'defer', // 'defer' (reuse pending) or 'cancel' (cancel previous)
getCachedData: (key) => { /* custom cache logic */ },
// Data transformation
transform: (data) => data, // Process data before caching
pick: ['id', 'title'], // Only include these fields
default: () => [], // Default value before data loads
deep: true, // Deep reactivity (true) or shallow (false)
// Request options (passed to $fetch)
method: 'GET',
query: { page: 1 },
body: { /* data */ },
headers: { /* headers */ }
})
Return values and state
Both useFetch and useAsyncData return the same reactive state object:
<script setup>
const {
data, // Ref containing the fetched data
status, // Ref: 'idle' | 'pending' | 'success' | 'error'
error, // Ref containing error if fetch failed
refresh, // Function to manually refetch
execute, // Alias for refresh (more semantic)
clear // Function to reset data to undefined
} = await useFetch('/api/posts')
</script>
Interestingly, the return value from
useAsyncDatais actually an enhancedPromiseobject; you canawaitit or notawaitit and it will still work, and destructure! See this article (opens new window) from Michael Thiessen to see exactly how it’s done.
Note that the returned properties are Vue refs, so access them with .value in JavaScript:
<script setup>
const { data: posts } = await useFetch('/api/posts')
// In script, use .value
console.log(posts.value)
// In template, .value is automatic
</script>
<template>
<div>{{ posts }}</div>
</template>
Status values:
'idle': Fetch hasn’t started (only withimmediate: false)'pending': Currently fetching'success': Fetch completed successfully'error': Fetch failed
Conclusion
Summary
Which tool to use:
- Use
useFetchin page and component setup for fetching URLs - Use
useAsyncDatain page and component setup for custom async fetch logic - Use
$fetchanywhere outside component setup, such as client-side event handlers and API routes
General operation:
- Understand that Nuxt runs code on both server and client
- Let the composables handle SSR for you - they prevent double fetching
- Use keys strategically to control caching and data sharing
- Handle loading states when using
lazyorserver: false
Cookbook
For further reading I’ve reorganised Nuxt’s own data fetching docs (opens new window) examples into a cookbook: