Suspense and async components for Vue & Nuxt developers
When you're building modern web applications, you'll often need to load data or code asynchronously before rendering components. Vue and Nuxt provide tools to handle this gracefully, and Suspense and async components for Vue & Nuxt developers is one of the most practical patterns we use to coordinate loading states across your application. In this article, we'll walk through how to properly set up async components, use Suspense boundaries to manage fallbacks, and understand why this feature remains experimental in Vue while Nuxt has already integrated it into its page lifecycle.
Async setup with top-level await
The foundation of async component handling in Vue 3 is the ability to use top-level await directly in your component's <script setup>. This is cleaner than wrapping everything in promises and callbacks. When you use await at the top level, Vue automatically treats your component as async, meaning it becomes a suspensible component that can be caught by a parent Suspense boundary.
Here's what this looks like in practice:
<template>
<div class="user-profile">
<h3>{{ user.name }}</h3>
<p>Email: {{ user.email }}</p>
<div class="posts">
<div v-for="post in posts" :key="post.id" class="post">
<h4>{{ post.title }}</h4>
<p>{{ post.content }}</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
// Top-level await makes this component suspensible
const response = await fetch(`/api/users/123`)
const user = await response.json()
const postsResponse = await fetch(`/api/users/123/posts`)
const posts = await postsResponse.json()
</script>Notice there are no promises, no onMounted hooks, no .then() chains. The component doesn't mount until both the user and posts data are fetched. This is powerful because the entire component lifecycle waits for your async work to complete. However, the downside is that if either fetch fails, the entire component suspends indefinitely unless you have error handling in place.
In production, we wrap our fetch calls with proper error boundaries. Here's a more robust approach:
<script setup lang="ts">
import { ref } from 'vue'
interface User {
id: number
name: string
email: string
}
interface Post {
id: number
title: string
content: string
}
async function fetchUserData(): Promise<User> {
const response = await fetch(`/api/users/123`)
if (!response.ok) {
throw new Error(`Failed to fetch user: ${response.statusText}`)
}
return response.json()
}
async function fetchUserPosts(): Promise<Post[]> {
const response = await fetch(`/api/users/123/posts`)
if (!response.ok) {
throw new Error(`Failed to fetch posts: ${response.statusText}`)
}
return response.json()
}
// Await both operations
const user = await fetchUserData()
const posts = await fetchUserPosts()
</script>This structure makes it clear what data your component requires before it can render. The team can immediately see the async contract. If either fetch fails and isn't caught, an error boundary will catch it—more on that later.
<Suspense> shows fallbacks
The <Suspense> component is a wrapper that catches async components as they're resolving and displays a fallback template while loading. Think of it as a try-catch for the loading state. You define a default slot (your actual content) and a fallback slot (what shows while waiting).
Here's the basic pattern:
<template>
<Suspense>
<template #default>
<UserProfile />
</template>
<template #fallback>
<div class="loading">
<p>Loading user profile...</p>
</div>
</template>
</Suspense>
</template>
<script setup>
import UserProfile from './UserProfile.vue'
</script>When <UserProfile /> has top-level await in its setup, the Suspense boundary intercepts it. While the component is resolving, the fallback template renders. Once the async work completes, Vue swaps in the default content.
You can nest multiple async components inside a single Suspense boundary. The fallback appears until all async children have resolved:
<template>
<main>
<header>
<h1>Dashboard</h1>
</header>
<Suspense>
<template #default>
<div class="grid">
<UserCard />
<AnalyticsCard />
<RecentActivityCard />
</div>
</template>
<template #fallback>
<div class="loading-grid">
<div class="skeleton"></div>
<div class="skeleton"></div>
<div class="skeleton"></div>
</div>
</template>
</Suspense>
</main>
</template>In this dashboard, all three cards are async. The loading skeletons show until every card's data is ready. This is cleaner than managing three separate loading booleans. However, note that if one card is slow, the user waits for all three. If you want finer control, you can nest multiple Suspense boundaries—one per card.
Still experimental in Vue
It's important to know that Suspense remains marked as experimental in Vue 3. While it works well for many use cases, the API may change in future versions. The Vue team is still refining edge cases, particularly around error handling and lifecycle behavior.
What does "experimental" mean in practice? You can use it in production, but be aware that:
- Minor version updates might introduce breaking changes to Suspense behavior
- Error boundaries around Suspense are not yet fully standardized
- Combining Suspense with server-side rendering has known quirks
- The interaction between Suspense and lifecycle hooks can be surprising
Many teams work around this by building custom composables that wrap Suspense logic, making it easier to refactor if the API changes. For example, we often create a useAsyncData helper that returns a loading state and data, falling back to traditional loading patterns when needed.
If your team prefers stability over cutting-edge features, there's nothing wrong with sticking to standard async/await with manual loading states. Vue's reactivity system handles that pattern just fine.
Nuxt uses it for pages
Nuxt 3 has embraced Suspense more fully and uses it as the default mechanism for handling async page components. When you define a page with async setup, Nuxt automatically wraps it in a Suspense boundary.
Here's how this works in a Nuxt app. Your page might look like:
<template>
<div class="product-page">
<h1>{{ product.title }}</h1>
<p>{{ product.description }}</p>
<RelatedProducts :productId="product.id" />
</div>
</template>
<script setup lang="ts">
const route = useRoute()
// This top-level await is automatically caught by Nuxt's Suspense
const { data: product } = await useFetch(`/api/products/${route.params.id}`)
</script>Nuxt wraps this page in a Suspense boundary, and while the product data loads, it shows a default loading screen (which you can customize via layouts/default.vue). Once the data arrives, Vue renders your page.
You can customize the loading behavior by creating a app.vue that defines its own Suspense with a custom fallback:
<template>
<div id="app">
<Suspense>
<template #default>
<NuxtPage />
</template>
<template #fallback>
<LoadingSpinner />
</template>
</Suspense>
</div>
</template>
<script setup>
import LoadingSpinner from './components/LoadingSpinner.vue'
</script>This gives you a single place to control the loading state for all pages in your Nuxt app. The integration is seamless—no manual setup required beyond using top-level await in your components.
Nuxt also provides middleware and hooks that fire before page transitions complete. You can leverage these alongside Suspense for sophisticated loading patterns:
// middleware/analytics.ts
export default defineRouteMiddleware((to, from) => {
console.log(`Navigating from ${from.path} to ${to.path}`)
// Trigger analytics events before page loads
})
// In your page, the middleware runs, then Suspense waits for async setup
<script setup lang="ts">
const data = await fetch('/api/data').then(r => r.json())
</script>Common mistakes
We see several patterns that trip up teams when they first use Suspense:
- Forgetting error handling: If an async component throws, the Suspense boundary shows nothing. Wrap your fetches in try-catch or use Vue's error boundaries.
- Using setTimeout or manual promises: Suspense only recognizes native top-level await. If you wrap it in setTimeout or a promise that resolves later, Suspense won't catch it.
- Mixing Suspense with manual loading flags: Having both a data-loading reactive ref and a Suspense boundary is redundant and confusing. Pick one pattern.
- Not providing meaningful fallbacks: A generic "Loading..." message in a Suspense fallback is fine for prototypes, but production apps should show skeleton screens or context-aware loaders.
- Ignoring SSR implications: Suspense behavior differs on the server. Server-side rendering must await all async setup before sending HTML, which can slow your time to first byte if you're not careful.
Key takeaways
- Top-level await in
<script setup>makes a component suspensible, coordinating loading without callbacks or manual promise chains. - The
<Suspense>component catches async children and displays a fallback template during resolution, simplifying loading state management. - Suspense is still experimental in Vue 3, so be prepared for API changes and consider wrapping it in custom composables for stability.
- Nuxt 3 has adopted Suspense as the default page-loading mechanism, making async data handling seamless for SSR and SPA alike.
- Always include error boundaries alongside Suspense, and provide meaningful fallback content rather than generic loaders.
- Keep your async logic focused; avoid mixing Suspense with manual loading flags or promise wrappers that Suspense won't recognize.






Comments
Loading comments…