The Nuxt data layer has undergone a revolutionary transformation, bringing developers five game-changing features that will fundamentally change how you handle data in your applications. Gone are the days of wrestling with complex state management patterns – Nuxt's new data layer is here to make your development experience smoother, more intuitive, and significantly more powerful.
In this comprehensive guide, we'll dive deep into each new feature with practical examples, real-world scenarios, and best practices that will transform you from a data layer novice to a master. Whether you're building a simple blog or a complex enterprise application, these features will become your new best friends.
1. Reactive Server State with Enhanced $fetch
The enhanced $fetch
utility is perhaps the most exciting addition to Nuxt's arsenal. It's not just about making HTTP requests anymore – it's about creating a seamless bridge between your server and client with built-in reactivity.
Basic Enhanced Fetching
Let's start with a simple example that demonstrates the power of reactive server state:
<template>
<div class="user-profile">
<div v-if="pending" class="loading-spinner">
<div class="spinner"></div>
<p>Loading user profile...</p>
</div>
<div v-else-if="error" class="error-container">
<h3>Oops! Something went wrong</h3>
<p>{{ error.message }}</p>
<button @click="refresh()" class="retry-btn">Try Again</button>
</div>
<div v-else class="profile-content">
<img :src="data?.avatar" :alt="`${data?.name}'s avatar`" />
<h2>{{ data?.name }}</h2>
<p>{{ data?.email }}</p>
<div class="stats">
<span>Posts: {{ data?.postsCount }}</span>
<span>Followers: {{ data?.followersCount }}</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
interface User {
id: number
name: string
email: string
avatar: string
postsCount: number
followersCount: number
}
interface UserResponse {
user: User
lastUpdated: string
}
const route = useRoute()
const userId = computed(() => route.params.id as string)
// The magic happens here - reactive server state!
const { data, pending, error, refresh } = await $fetch<UserResponse>(`/api/users/${userId.value}`, {
key: `user-${userId.value}`,
server: true,
default: () => null,
transform: (response: UserResponse) => response.user,
onResponse({ response }) {
console.log('Fresh data received:', response._data)
},
onResponseError({ error }) {
console.error('Failed to fetch user:', error)
}
})
// Watch for route changes and automatically refetch
watch(userId, (newId) => {
if (newId) {
refresh()
}
})
</script>
What makes this special? The enhanced $fetch
automatically handles loading states, error states, and provides reactive data that updates across your entire application. The key
parameter ensures proper caching and deduplication – if another component requests the same user, it won't make a duplicate request.
Advanced Conditional Fetching
Sometimes you need more control over when your data fetches. Here's how to implement conditional fetching with dependency tracking:
<template>
<div class="dashboard">
<div class="filters">
<select v-model="selectedCategory" class="category-select">
<option value="">All Categories</option>
<option value="tech">Technology</option>
<option value="design">Design</option>
<option value="business">Business</option>
</select>
<input
v-model="searchQuery"
type="text"
placeholder="Search articles..."
class="search-input"
/>
<label class="checkbox-container">
<input v-model="includeUnpublished" type="checkbox" />
Include unpublished articles
</label>
</div>
<div v-if="shouldFetch" class="articles-container">
<ArticleCard
v-for="article in articles"
:key="article.id"
:article="article"
/>
<div v-if="articlesLoading" class="loading-more">
Loading more articles...
</div>
</div>
<div v-else class="no-search">
<p>Enter a search term or select a category to see articles</p>
</div>
</div>
</template>
<script setup lang="ts">
interface Article {
id: number
title: string
excerpt: string
category: string
published: boolean
createdAt: string
}
interface ArticlesResponse {
articles: Article[]
total: number
hasMore: boolean
}
const selectedCategory = ref<string>('')
const searchQuery = ref<string>('')
const includeUnpublished = ref<boolean>(false)
// Computed property to determine if we should fetch
const shouldFetch = computed(() =>
selectedCategory.value !== '' || searchQuery.value.trim() !== ''
)
// Debounced search query to avoid excessive API calls
const debouncedSearchQuery = refDebounced(searchQuery, 300)
// Build query parameters reactively
const queryParams = computed(() => ({
category: selectedCategory.value,
search: debouncedSearchQuery.value,
includeUnpublished: includeUnpublished.value,
limit: 20
}))
// Conditional fetching with reactive dependencies
const {
data: articlesData,
pending: articlesLoading,
error: articlesError
} = await $fetch<ArticlesResponse>('/api/articles', {
key: 'filtered-articles',
query: queryParams,
server: false, // Client-side only for dynamic filtering
default: () => ({ articles: [], total: 0, hasMore: false }),
// Only fetch when we have search criteria
skip: () => !shouldFetch.value
})
const articles = computed(() => articlesData.value?.articles || [])
// Reset search when category changes
watch(selectedCategory, () => {
searchQuery.value = ''
})
</script>
This example demonstrates how the new data layer intelligently handles conditional fetching. The skip
function prevents unnecessary requests, while the reactive queryParams
automatically trigger refetches when dependencies change.
2. Advanced Caching and Invalidation
The new caching system is a developer's dream come true. It provides granular control over cache behavior while maintaining simplicity for common use cases.
Smart Cache Management
<template>
<div class="blog-post">
<article v-if="post" class="post-content">
<header>
<h1>{{ post.title }}</h1>
<div class="meta">
<span>By {{ post.author.name }}</span>
<time :datetime="post.publishedAt">{{ formatDate(post.publishedAt) }}</time>
<span class="reading-time">{{ post.readingTime }} min read</span>
</div>
</header>
<div class="content" v-html="post.content"></div>
<footer class="post-footer">
<div class="tags">
<span v-for="tag in post.tags" :key="tag" class="tag">
#{{ tag }}
</span>
</div>
<div class="actions">
<button @click="toggleLike" :class="{ liked: isLiked }" class="like-btn">
❤️ {{ post.likesCount + (isLiked ? 1 : 0) }}
</button>
<button @click="sharePost" class="share-btn">Share</button>
</div>
</footer>
</article>
<section class="comments-section">
<h3>Comments ({{ comments?.length || 0 }})</h3>
<CommentForm @comment-added="handleNewComment" />
<CommentList :comments="comments" />
</section>
</div>
</template>
<script setup lang="ts">
interface Author {
id: number
name: string
avatar: string
}
interface BlogPost {
id: number
title: string
content: string
excerpt: string
author: Author
publishedAt: string
readingTime: number
tags: string[]
likesCount: number
}
interface Comment {
id: number
content: string
author: string
createdAt: string
}
const route = useRoute()
const postSlug = route.params.slug as string
// Long-term caching for post content (rarely changes)
const { data: post } = await $fetch<BlogPost>(`/api/posts/${postSlug}`, {
key: `post-${postSlug}`,
server: true,
// Cache for 1 hour - posts don't change often
maxAge: 60 * 60,
// Stale-while-revalidate: serve stale content while fetching fresh data
swr: true,
headers: {
'Cache-Control': 'public, max-age=3600, stale-while-revalidate=86400'
}
})
// Short-term caching for dynamic content (comments)
const {
data: comments,
refresh: refreshComments
} = await $fetch<Comment[]>(`/api/posts/${postSlug}/comments`, {
key: `comments-${postSlug}`,
server: false,
// Shorter cache for dynamic content
maxAge: 5 * 60, // 5 minutes
default: () => []
})
// Client-side state for interactions
const isLiked = ref(false)
// Handle new comment with cache invalidation
const handleNewComment = async (newComment: Comment) => {
// Optimistically update the UI
comments.value = [...(comments.value || []), newComment]
// Invalidate and refresh comments cache
await clearNuxtData(`comments-${postSlug}`)
await refreshComments()
// Also invalidate related caches
await clearNuxtData('recent-comments')
await clearNuxtData('user-activity')
}
const toggleLike = async () => {
const wasLiked = isLiked.value
// Optimistic update
isLiked.value = !wasLiked
try {
await $fetch(`/api/posts/${postSlug}/like`, {
method: wasLiked ? 'DELETE' : 'POST'
})
// Invalidate post cache to reflect new like count
await clearNuxtData(`post-${postSlug}`)
} catch (error) {
// Revert optimistic update on error
isLiked.value = wasLiked
console.error('Failed to toggle like:', error)
}
}
const sharePost = async () => {
if (navigator.share && post.value) {
await navigator.share({
title: post.value.title,
text: post.value.excerpt,
url: window.location.href
})
}
}
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
})
}
</script>
Cache Strategy Breakdown:
- Post content: Long cache duration with stale-while-revalidate for performance
- Comments: Shorter cache with immediate invalidation on updates
- Optimistic updates: Immediate UI feedback with rollback on errors
Global Cache Invalidation Patterns
Here's how to implement sophisticated cache invalidation patterns for complex applications:
<template>
<div class="admin-dashboard">
<nav class="admin-nav">
<button
v-for="tab in tabs"
:key="tab.id"
@click="activeTab = tab.id"
:class="{ active: activeTab === tab.id }"
class="nav-tab"
>
{{ tab.label }}
<span v-if="tab.badge" class="badge">{{ tab.badge }}</span>
</button>
</nav>
<main class="admin-content">
<UsersPanel v-if="activeTab === 'users'" @user-updated="handleUserUpdate" />
<PostsPanel v-if="activeTab === 'posts'" @post-published="handlePostPublish" />
<SettingsPanel v-if="activeTab === 'settings'" @settings-changed="handleSettingsChange" />
</main>
</div>
</template>
<script setup lang="ts">
interface CacheInvalidationEvent {
type: 'user' | 'post' | 'settings'
action: 'create' | 'update' | 'delete' | 'publish'
entityId?: string | number
affectedCaches: string[]
}
const activeTab = ref<'users' | 'posts' | 'settings'>('users')
const tabs = computed(() => [
{ id: 'users', label: 'Users', badge: pendingUsers.value },
{ id: 'posts', label: 'Posts', badge: draftPosts.value },
{ id: 'settings', label: 'Settings' }
])
// Global cache invalidation strategy
class CacheInvalidationManager {
private static patterns: Record<string, string[]> = {
'user-update': [
'users-*',
'user-{id}',
'user-posts-{id}',
'dashboard-stats',
'recent-activity'
],
'post-publish': [
'posts-*',
'post-{id}',
'author-posts-*',
'category-posts-*',
'homepage-featured',
'sitemap'
],
'settings-change': [
'app-config',
'theme-settings',
'navigation-menu',
'footer-content'
]
}
static async invalidate(event: CacheInvalidationEvent): Promise<void> {
const cacheKey = `${event.type}-${event.action}`
const patterns = this.patterns[cacheKey] || []
console.log(`🗑️ Invalidating caches for: ${cacheKey}`)
for (const pattern of patterns) {
let resolvedPattern = pattern
// Replace placeholders with actual values
if (event.entityId) {
resolvedPattern = pattern.replace('{id}', String(event.entityId))
}
// Handle wildcard patterns
if (resolvedPattern.includes('*')) {
await this.invalidatePattern(resolvedPattern)
} else {
await clearNuxtData(resolvedPattern)
}
}
}
private static async invalidatePattern(pattern: string): Promise<void> {
const prefix = pattern.replace('*', '')
// Get all cached keys and invalidate matching ones
if (process.client) {
const nuxtData = nuxtApp.ssrContext?.nuxtData || {}
const matchingKeys = Object.keys(nuxtData).filter(key =>
key.startsWith(prefix)
)
for (const key of matchingKeys) {
await clearNuxtData(key)
}
}
}
}
// Event handlers with intelligent cache invalidation
const handleUserUpdate = async (userId: number) => {
await CacheInvalidationManager.invalidate({
type: 'user',
action: 'update',
entityId: userId,
affectedCaches: []
})
// Trigger UI updates
await refreshNuxtData('dashboard-stats')
}
const handlePostPublish = async (postId: number) => {
await CacheInvalidationManager.invalidate({
type: 'post',
action: 'publish',
entityId: postId,
affectedCaches: []
})
// Show success notification
showNotification('Post published successfully! 🎉')
}
const handleSettingsChange = async () => {
await CacheInvalidationManager.invalidate({
type: 'settings',
action: 'update',
affectedCaches: []
})
}
// Fetch dashboard stats with caching
const { data: pendingUsers } = await $fetch<number>('/api/admin/users/pending-count', {
key: 'pending-users-count',
maxAge: 30, // 30 seconds cache
default: () => 0
})
const { data: draftPosts } = await $fetch<number>('/api/admin/posts/draft-count', {
key: 'draft-posts-count',
maxAge: 30,
default: () => 0
})
</script>
This sophisticated caching system automatically handles complex invalidation scenarios, ensuring your data stays fresh while maximizing performance.
3. Cross-Component Data Synchronization
One of the most powerful features is automatic data synchronization across components. No more prop drilling or complex state management!
Real-time Shopping Cart Example
<!-- ProductCard.vue -->
<template>
<div class="product-card">
<img :src="product.image" :alt="product.name" />
<div class="product-info">
<h3>{{ product.name }}</h3>
<p class="price">${{ product.price }}</p>
<p class="description">{{ product.description }}</p>
<div class="product-actions">
<div class="quantity-controls">
<button
@click="decrementQuantity"
:disabled="quantity <= 0"
class="qty-btn"
>
-
</button>
<span class="quantity">{{ quantity }}</span>
<button @click="incrementQuantity" class="qty-btn">+</button>
</div>
<button
@click="addToCart"
:disabled="quantity === 0"
class="add-to-cart-btn"
>
Add to Cart
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
interface Product {
id: number
name: string
price: number
description: string
image: string
stock: number
}
interface Props {
product: Product
}
const props = defineProps<Props>()
const quantity = ref(1)
// Access shared cart data - automatically synced across all components!
const { data: cartData, refresh: refreshCart } = await $fetch('/api/cart', {
key: 'user-cart',
server: false,
default: () => ({ items: [], total: 0, count: 0 })
})
const addToCart = async () => {
if (quantity.value <= 0) return
try {
// Add to cart on server
await $fetch('/api/cart/add', {
method: 'POST',
body: {
productId: props.product.id,
quantity: quantity.value
}
})
// The magic: refresh cart data and ALL components using this data will update!
await refreshCart()
// Reset quantity
quantity.value = 1
// Show success feedback
showCartNotification(`Added ${props.product.name} to cart!`)
} catch (error) {
console.error('Failed to add to cart:', error)
showErrorNotification('Failed to add item to cart')
}
}
const incrementQuantity = () => {
if (quantity.value < props.product.stock) {
quantity.value++
}
}
const decrementQuantity = () => {
if (quantity.value > 0) {
quantity.value--
}
}
</script>
<!-- CartSidebar.vue -->
<template>
<div class="cart-sidebar" :class="{ open: isOpen }">
<header class="cart-header">
<h2>Shopping Cart</h2>
<button @click="closeCart" class="close-btn">✕</button>
</header>
<div v-if="cartItems.length === 0" class="empty-cart">
<p>Your cart is empty</p>
<button @click="closeCart" class="continue-shopping">
Continue Shopping
</button>
</div>
<div v-else class="cart-content">
<div class="cart-items">
<CartItem
v-for="item in cartItems"
:key="item.id"
:item="item"
@quantity-changed="handleQuantityChange"
@remove="handleRemoveItem"
/>
</div>
<footer class="cart-footer">
<div class="cart-summary">
<div class="summary-row">
<span>Subtotal:</span>
<span>${{ cartData?.subtotal?.toFixed(2) }}</span>
</div>
<div class="summary-row">
<span>Tax:</span>
<span>${{ cartData?.tax?.toFixed(2) }}</span>
</div>
<div class="summary-row total">
<span>Total:</span>
<span>${{ cartData?.total?.toFixed(2) }}</span>
</div>
</div>
<button @click="proceedToCheckout" class="checkout-btn">
Proceed to Checkout
</button>
</footer>
</div>
</div>
</template>
<script setup lang="ts">
interface CartItem {
id: number
productId: number
name: string
price: number
quantity: number
image: string
}
interface CartData {
items: CartItem[]
subtotal: number
tax: number
total: number
count: number
}
const isOpen = defineModel<boolean>()
// Same cart data as ProductCard - automatically synchronized!
const { data: cartData, refresh: refreshCart } = await $fetch<CartData>('/api/cart', {
key: 'user-cart', // Same key = same cached data
server: false,
default: () => ({ items: [], subtotal: 0, tax: 0, total: 0, count: 0 })
})
const cartItems = computed(() => cartData.value?.items || [])
const handleQuantityChange = async (itemId: number, newQuantity: number) => {
try {
await $fetch('/api/cart/update', {
method: 'PUT',
body: { itemId, quantity: newQuantity }
})
// Refresh cart data - updates everywhere automatically!
await refreshCart()
} catch (error) {
console.error('Failed to update quantity:', error)
}
}
const handleRemoveItem = async (itemId: number) => {
try {
await $fetch(`/api/cart/remove/${itemId}`, {
method: 'DELETE'
})
await refreshCart()
} catch (error) {
console.error('Failed to remove item:', error)
}
}
const proceedToCheckout = () => {
navigateTo('/checkout')
}
const closeCart = () => {
isOpen.value = false
}
</script>
<!-- CartIcon.vue (in header) -->
<template>
<button @click="openCart" class="cart-icon-btn">
<div class="cart-icon">
🛒
<span v-if="itemCount > 0" class="cart-badge">
{{ itemCount }}
</span>
</div>
</button>
</template>
<script setup lang="ts">
// Same cart data - automatically shows updated count from any cart interaction!
const { data: cartData } = await $fetch('/api/cart', {
key: 'user-cart', // Same key again!
server: false,
default: () => ({ items: [], total: 0, count: 0 })
})
const itemCount = computed(() => cartData.value?.count || 0)
const openCart = () => {
// Emit event to open cart sidebar
emit('open-cart')
}
</script>
What's magical here? All three components use the same cache key ('user-cart'
), so when one component updates the cart data, ALL components automatically reflect the changes. No manual synchronization needed!
4. Optimistic Updates with Rollback
Optimistic updates make your app feel lightning-fast by updating the UI immediately, then syncing with the server in the background.
Social Media Post Interaction
<template>
<article class="social-post">
<header class="post-header">
<img :src="post.author.avatar" :alt="post.author.name" class="avatar" />
<div class="author-info">
<h3>{{ post.author.name }}</h3>
<time>{{ formatTimeAgo(post.createdAt) }}</time>
</div>
</header>
<div class="post-content">
<p>{{ post.content }}</p>
<img v-if="post.image" :src="post.image" alt="Post image" />
</div>
<footer class="post-actions">
<button
@click="toggleLike"
:class="{ liked: isLiked, loading: likeLoading }"
class="action-btn like-btn"
:disabled="likeLoading"
>
{{ isLiked ? '❤️' : '🤍' }} {{ displayLikeCount }}
</button>
<button
@click="toggleBookmark"
:class="{ bookmarked: isBookmarked, loading: bookmarkLoading }"
class="action-btn bookmark-btn"
:disabled="bookmarkLoading"
>
{{ isBookmarked ? '🔖' : '📑' }} {{ isBookmarked ? 'Saved' : 'Save' }}
</button>
<button @click="sharePost" class="action-btn share-btn">
🔗 Share
</button>
</footer>
<!-- Comments section with optimistic updates -->
<section class="comments-section">
<div class="comment-form">
<textarea
v-model="newComment"
placeholder="Write a comment..."
class="comment-input"
@keydown.meta.enter="submitComment"
></textarea>
<button
@click="submitComment"
:disabled="!newComment.trim() || commentSubmitting"
class="submit-comment-btn"
>
{{ commentSubmitting ? 'Posting...' : 'Post Comment' }}
</button>
</div>
<div class="comments-list">
<Comment
v-for="comment in displayComments"
:key="comment.id"
:comment="comment"
:is-optimistic="comment.isOptimistic"
@delete="handleDeleteComment"
/>
</div>
</section>
</article>
</template>
<script setup lang="ts">
interface Author {
id: number
name: string
avatar: string
}
interface SocialPost {
id: number
content: string
image?: string
author: Author
createdAt: string
likesCount: number
commentsCount: number
}
interface Comment {
id: number | string // Can be temp ID for optimistic updates
content: string
author: Author
createdAt: string
isOptimistic?: boolean // Flag for optimistic comments
}
interface Props {
post: SocialPost
}
const props = defineProps<Props>()
const user = await getCurrentUser() // Assume this gets current user
// Reactive state for interactions
const isLiked = ref(false)
const isBookmarked = ref(false)
const likeLoading = ref(false)
const bookmarkLoading = ref(false)
const commentSubmitting = ref(false)
const newComment = ref('')
// Optimistic state management
const optimisticLikes = ref(0)
const optimisticComments = ref<Comment[]>([])
// Fetch initial interaction state
const { data: interactions } = await $fetch(`/api/posts/${props.post.id}/interactions`, {
key: `post-interactions-${props.post.id}`,
default: () => ({ liked: false, bookmarked: false })
})
// Fetch comments with caching
const { data: comments, refresh: refreshComments } = await $fetch<Comment[]>(
`/api/posts/${props.post.id}/comments`,
{
key: `post-comments-${props.post.id}`,
default: () => []
}
)
// Initialize state from server data
onMounted(() => {
isLiked.value = interactions.value?.liked || false
isBookmarked.value = interactions.value?.bookmarked || false
})
// Computed properties for display
const displayLikeCount = computed(() =>
props.post.likesCount + optimisticLikes.value
)
const displayComments = computed(() => [
...(comments.value || []),
...optimisticComments.value
])
// Optimistic like toggle with rollback
const toggleLike = async () => {
if (likeLoading.value) return
likeLoading.value = true
const wasLiked = isLiked.value
const originalCount = props.post.likesCount
// Optimistic update
isLiked.value = !wasLiked
optimisticLikes.value = wasLiked ? -1 : 1
try {
await $fetch(`/api/posts/${props.post.id}/like`, {
method: wasLiked ? 'DELETE' : 'POST'
})
// Success: commit optimistic changes
props.post.likesCount = originalCount + optimisticLikes.value
optimisticLikes.value = 0
// Update cached interactions
await refreshNuxtData(`post-interactions-${props.post.id}`)
} catch (error) {
// Rollback optimistic update
isLiked.value = wasLiked
optimisticLikes.value = 0
console.error('Failed to toggle like:', error)
showErrorNotification('Failed to update like status')
} finally {
likeLoading.value = false
}
}
// Optimistic bookmark toggle
const toggleBookmark = async () => {
if (bookmarkLoading.value) return
bookmarkLoading.value = true
const wasBookmarked = isBookmarked.value
// Optimistic update
isBookmarked.value = !wasBookmarked
try {
await $fetch(`/api/posts/${props.post.id}/bookmark`, {
method: wasBookmarked ? 'DELETE' : 'POST'
})
showSuccessNotification(
wasBookmarked ? 'Removed from bookmarks' : 'Added to bookmarks'
)
} catch (error) {
// Rollback
isBookmarked.value = wasBookmarked
console.error('Failed to toggle bookmark:', error)
showErrorNotification('Failed to update bookmark status')
} finally {
bookmarkLoading.value = false
}
}
// Optimistic comment submission
const submitComment = async () => {
if (!newComment.value.trim() || commentSubmitting.value) return
commentSubmitting.value = true
const tempId = `temp-${Date.now()}`
const commentContent = newComment.value.trim()
// Create optimistic comment
const optimisticComment: Comment = {
id: tempId,
content: commentContent,
author: user.value,
createdAt: new Date().toISOString(),
isOptimistic: true
}
// Add to optimistic comments immediately
optimisticComments.value.push(optimisticComment)
newComment.value = '' // Clear input immediately
try {
const savedComment = await $fetch<Comment>(`/api/posts/${props.post.id}/comments`, {
method: 'POST',
body: { content: commentContent }
})
// Remove optimistic comment and refresh real comments
optimisticComments.value = optimisticComments.value.filter(c => c.id !== tempId)
await refreshComments()
// Update post comments count
props.post.commentsCount++
showSuccessNotification('Comment posted successfully!')
} catch (error) {
// Remove failed optimistic comment
optimisticComments.value = optimisticComments.value.filter(c => c.id !== tempId)
newComment.value = commentContent // Restore comment text
console.error('Failed to post comment:', error)
showErrorNotification('Failed to post comment. Please try again.')
} finally {
commentSubmitting.value = false
}
}
const handleDeleteComment = async (commentId: number) => {
// Find comment in real comments
const commentIndex = comments.value?.findIndex(c => c.id === commentId)
if (commentIndex === -1) return
const deletedComment = comments.value[commentIndex]
// Optimistically remove from UI
comments.value.splice(commentIndex, 1)
props.post.commentsCount--
try {
await $fetch(`/api/comments/${commentId}`, {
method: 'DELETE'
})
showSuccessNotification('Comment deleted')
} catch (error) {
// Rollback: restore deleted comment
comments.value.splice(commentIndex, 0, deletedComment)
props.post.commentsCount++
console.error('Failed to delete comment:', error)
showErrorNotification('Failed to delete comment')
}
}
const sharePost = async () => {
const shareData = {
title: `Post by ${props.post.author.name}`,
text: props.post.content.substring(0, 100) + '...',
url: `${window.location.origin}/posts/${props.post.id}`
}
if (navigator.share) {
await navigator.share(shareData)
} else {
await navigator.clipboard.writeText(shareData.url)
showSuccessNotification('Link copied to clipboard!')
}
}
const formatTimeAgo = (dateString: string) => {
const now = new Date()
const date = new Date(dateString)
const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000)
if (diffInSeconds < 60) return 'Just now'
if (diffInSeconds < 3600) return `${Math.floor(diffInSeconds / 60)}m ago`
if (diffInSeconds < 86400) return `${Math.floor(diffInSeconds / 3600)}h ago`
return `${Math.floor(diffInSeconds / 86400)}d ago`
}
</script>
This example showcases the power of optimistic updates. Users see immediate feedback while the app handles server synchronization in the background, with automatic rollback on failures.
5. Server-Side State Hydration
The final piece of the puzzle is seamless server-side state hydration. Your app starts with data already loaded, no loading spinners on first render!
E-commerce Product Catalog with SSR
<!-- pages/products/index.vue -->
<template>
<div class="products-page">
<header class="page-header">
<h1>Our Products</h1>
<p>Discover amazing products tailored just for you</p>
</header>
<aside class="filters-sidebar">
<ProductFilters
v-model:category="selectedCategory"
v-model:price-range="priceRange"
v-model:brands="selectedBrands"
:available-categories="categories"
:available-brands="brands"
@filters-changed="handleFiltersChange"
/>
</aside>
<main class="products-main">
<div class="products-toolbar">
<div class="results-info">
<span>{{ totalProducts }} products found</span>
</div>
<div class="sort-controls">
<select v-model="sortBy" class="sort-select">
<option value="relevance">Most Relevant</option>
<option value="price-low">Price: Low to High</option>
<option value="price-high">Price: High to Low</option>
<option value="rating">Highest Rated</option>
<option value="newest">Newest First</option>
</select>
</div>
</div>
<div v-if="productsLoading" class="loading-state">
<ProductSkeleton v-for="n in 12" :key="n" />
</div>
<div v-else class="products-grid">
<ProductCard
v-for="product in products"
:key="product.id"
:product="product"
@add-to-cart="handleAddToCart"
@quick-view="handleQuickView"
/>
</div>
<div v-if="hasMoreProducts" class="load-more-section">
<button
@click="loadMoreProducts"
:disabled="loadingMore"
class="load-more-btn"
>
{{ loadingMore ? 'Loading...' : 'Load More Products' }}
</button>
</div>
</main>
</div>
</template>
<script setup lang="ts">
interface Product {
id: number
name: string
price: number
originalPrice?: number
rating: number
reviewCount: number
image: string
category: string
brand: string
inStock: boolean
}
interface ProductsResponse {
products: Product[]
total: number
page: number
hasMore: boolean
categories: string[]
brands: string[]
}
interface ProductFilters {
category: string[]
priceRange: [number, number]
brands: string[]
sortBy: string
page: number
}
// SEO and meta tags
useHead({
title: 'Premium Products - Your Store',
meta: [
{ name: 'description', content: 'Discover our curated collection of premium products with fast shipping and great prices.' }
]
})
// URL-based filter state
const route = useRoute()
const router = useRouter()
// Initialize filters from URL params
const selectedCategory = ref<string[]>(
route.query.category ? String(route.query.category).split(',') : []
)
const priceRange = ref<[number, number]>([
Number(route.query.minPrice) || 0,
Number(route.query.maxPrice) || 1000
])
const selectedBrands = ref<string[]>(
route.query.brands ? String(route.query.brands).split(',') : []
)
const sortBy = ref<string>(String(route.query.sort) || 'relevance')
const currentPage = ref<number>(Number(route.query.page) || 1)
// Computed filter object
const filters = computed<ProductFilters>(() => ({
category: selectedCategory.value,
priceRange: priceRange.value,
brands: selectedBrands.value,
sortBy: sortBy.value,
page: currentPage.value
}))
// SERVER-SIDE HYDRATION: This runs on server AND client
const {
data: productsData,
pending: productsLoading,
refresh: refreshProducts
} = await $fetch<ProductsResponse>('/api/products', {
key: 'products-catalog',
// This ensures the data is fetched on server-side
server: true,
// Default data structure to prevent hydration mismatches
default: () => ({
products: [],
total: 0,
page: 1,
hasMore: false,
categories: [],
brands: []
}),
// Reactive query parameters
query: computed(() => ({
category: selectedCategory.value.join(','),
minPrice: priceRange.value[0],
maxPrice: priceRange.value[1],
brands: selectedBrands.value.join(','),
sort: sortBy.value,
page: currentPage.value,
limit: 24
})),
// Transform response for easier use
transform: (response: ProductsResponse) => {
return {
...response,
products: response.products.map(product => ({
...product,
discountPercentage: product.originalPrice
? Math.round(((product.originalPrice - product.price) / product.originalPrice) * 100)
: 0
}))
}
}
})
// Extract computed properties from server data
const products = computed(() => productsData.value?.products || [])
const totalProducts = computed(() => productsData.value?.total || 0)
const categories = computed(() => productsData.value?.categories || [])
const brands = computed(() => productsData.value?.brands || [])
const hasMoreProducts = computed(() => productsData.value?.hasMore || false)
// Load more products state
const loadingMore = ref(false)
const allProducts = ref<Product[]>([])
// Initialize all products with server data
onMounted(() => {
allProducts.value = [...products.value]
})
// Handle filter changes with URL sync
const handleFiltersChange = useDebounceFn(async () => {
// Update URL without page reload
await router.push({
query: {
...route.query,
category: selectedCategory.value.length ? selectedCategory.value.join(',') : undefined,
minPrice: priceRange.value[0] !== 0 ? priceRange.value[0] : undefined,
maxPrice: priceRange.value[1] !== 1000 ? priceRange.value[1] : undefined,
brands: selectedBrands.value.length ? selectedBrands.value.join(',') : undefined,
sort: sortBy.value !== 'relevance' ? sortBy.value : undefined,
page: undefined // Reset page when filters change
}
})
// Reset pagination
currentPage.value = 1
allProducts.value = []
// Refresh products with new filters
await refreshProducts()
allProducts.value = [...products.value]
}, 300)
// Load more products (pagination)
const loadMoreProducts = async () => {
if (loadingMore.value || !hasMoreProducts.value) return
loadingMore.value = true
currentPage.value++
try {
const moreProducts = await $fetch<ProductsResponse>('/api/products', {
query: {
...filters.value,
page: currentPage.value
}
})
// Append new products to existing list
allProducts.value.push(...moreProducts.products)
// Update hasMore flag
productsData.value = {
...productsData.value!,
hasMore: moreProducts.hasMore,
page: currentPage.value
}
} catch (error) {
console.error('Failed to load more products:', error)
currentPage.value-- // Revert page increment
} finally {
loadingMore.value = false
}
}
// Cart functionality
const handleAddToCart = async (product: Product) => {
try {
await $fetch('/api/cart/add', {
method: 'POST',
body: { productId: product.id, quantity: 1 }
})
// Refresh cart data across the app
await refreshNuxtData('user-cart')
showSuccessNotification(`${product.name} added to cart!`)
} catch (error) {
showErrorNotification('Failed to add product to cart')
}
}
const handleQuickView = (product: Product) => {
// Open quick view modal
navigateTo(`/products/${product.id}?modal=true`)
}
// Watch for route changes (back/forward navigation)
watch(
() => route.query,
(newQuery) => {
// Update local state from URL
selectedCategory.value = newQuery.category ? String(newQuery.category).split(',') : []
selectedBrands.value = newQuery.brands ? String(newQuery.brands).split(',') : []
sortBy.value = String(newQuery.sort) || 'relevance'
priceRange.value = [
Number(newQuery.minPrice) || 0,
Number(newQuery.maxPrice) || 1000
]
},
{ immediate: false }
)
</script>
Dynamic Product Page with Related Products
<!-- pages/products/[id].vue -->
<template>
<div class="product-page">
<nav class="breadcrumb">
<NuxtLink to="/products">Products</NuxtLink>
<span>/</span>
<NuxtLink :to="`/products?category=${product?.category}`">
{{ product?.category }}
</NuxtLink>
<span>/</span>
<span>{{ product?.name }}</span>
</nav>
<main class="product-main">
<section class="product-gallery">
<ProductImageGallery :images="product?.images || []" />
</section>
<section class="product-info">
<header class="product-header">
<h1>{{ product?.name }}</h1>
<div class="rating">
<StarRating :rating="product?.rating || 0" />
<span>({{ product?.reviewCount }} reviews)</span>
</div>
</header>
<div class="price-section">
<span class="current-price">${{ product?.price }}</span>
<span v-if="product?.originalPrice" class="original-price">
${{ product.originalPrice }}
</span>
<span v-if="discountPercentage > 0" class="discount-badge">
{{ discountPercentage }}% OFF
</span>
</div>
<div class="product-options">
<ProductVariants
v-if="product?.variants"
v-model:selected="selectedVariant"
:variants="product.variants"
/>
<div class="quantity-section">
<label>Quantity:</label>
<QuantitySelector v-model="quantity" :max="product?.stock || 0" />
</div>
</div>
<div class="action-buttons">
<button
@click="addToCart"
:disabled="!product?.inStock || addingToCart"
class="add-to-cart-btn primary"
>
{{ addingToCart ? 'Adding...' : 'Add to Cart' }}
</button>
<button @click="buyNow" class="buy-now-btn secondary">
Buy Now
</button>
<button @click="toggleWishlist" class="wishlist-btn">
{{ isInWishlist ? '❤️' : '🤍' }}
</button>
</div>
<div class="product-details">
<ProductTabs
:description="product?.description"
:specifications="product?.specifications"
:reviews="reviews"
:shipping-info="shippingInfo"
/>
</div>
</section>
</main>
<section class="related-products">
<h2>You might also like</h2>
<div class="related-grid">
<ProductCard
v-for="relatedProduct in relatedProducts"
:key="relatedProduct.id"
:product="relatedProduct"
size="compact"
/>
</div>
</section>
</div>
</template>
<script setup lang="ts">
interface ProductVariant {
id: number
name: string
value: string
priceModifier: number
}
interface DetailedProduct {
id: number
name: string
price: number
originalPrice?: number
rating: number
reviewCount: number
description: string
specifications: Record<string, string>
images: string[]
category: string
brand: string
inStock: boolean
stock: number
variants?: ProductVariant[]
}
interface Review {
id: number
author: string
rating: number
comment: string
createdAt: string
}
const route = useRoute()
const productId = route.params.id as string
// SERVER-SIDE HYDRATION: Product data loaded on server
const { data: product } = await $fetch<DetailedProduct>(`/api/products/${productId}`, {
key: `product-${productId}`,
server: true, // Ensures SSR
default: () => null
})
// Handle 404 case
if (!product.value) {
throw createError({
statusCode: 404,
statusMessage: 'Product not found'
})
}
// SEO optimization with product data
useHead({
title: `${product.value.name} - Your Store`,
meta: [
{ name: 'description', content: product.value.description },
{ property: 'og:title', content: product.value.name },
{ property: 'og:description', content: product.value.description },
{ property: 'og:image', content: product.value.images[0] },
{ property: 'product:price:amount', content: String(product.value.price) },
{ property: 'product:price:currency', content: 'USD' }
]
})
// SERVER-SIDE HYDRATION: Related products and reviews
const [{ data: relatedProducts }, { data: reviews }, { data: shippingInfo }] = await Promise.all([
$fetch<Product[]>(`/api/products/${productId}/related`, {
key: `related-products-${productId}`,
server: true,
default: () => []
}),
$fetch<Review[]>(`/api/products/${productId}/reviews`, {
key: `product-reviews-${productId}`,
server: true,
default: () => []
}),
$fetch(`/api/shipping-info`, {
key: 'shipping-info',
server: true,
default: () => ({ estimatedDays: '3-5', cost: 'Free over $50' })
})
])
// Reactive state
const selectedVariant = ref<ProductVariant | null>(null)
const quantity = ref(1)
const addingToCart = ref(false)
const isInWishlist = ref(false)
// Computed properties
const discountPercentage = computed(() => {
if (!product.value?.originalPrice) return 0
return Math.round(((product.value.originalPrice - product.value.price) / product.value.originalPrice) * 100)
})
const finalPrice = computed(() => {
let price = product.value?.price || 0
if (selectedVariant.value) {
price += selectedVariant.value.priceModifier
}
return price
})
// Check wishlist status on mount
onMounted(async () => {
try {
const wishlistStatus = await $fetch(`/api/wishlist/status/${productId}`)
isInWishlist.value = wishlistStatus.inWishlist
} catch (error) {
// User not logged in or other error
isInWishlist.value = false
}
})
// Cart functionality
const addToCart = async () => {
if (!product.value || addingToCart.value) return
addingToCart.value = true
try {
await $fetch('/api/cart/add', {
method: 'POST',
body: {
productId: product.value.id,
quantity: quantity.value,
variantId: selectedVariant.value?.id
}
})
// Refresh cart data globally
await refreshNuxtData('user-cart')
showSuccessNotification(`${product.value.name} added to cart!`)
} catch (error) {
showErrorNotification('Failed to add product to cart')
} finally {
addingToCart.value = false
}
}
const buyNow = async () => {
await addToCart()
if (!addingToCart.value) {
navigateTo('/checkout')
}
}
const toggleWishlist = async () => {
const wasInWishlist = isInWishlist.value
// Optimistic update
isInWishlist.value = !wasInWishlist
try {
await $fetch(`/api/wishlist/${productId}`, {
method: wasInWishlist ? 'DELETE' : 'POST'
})
showSuccessNotification(
wasInWishlist ? 'Removed from wishlist' : 'Added to wishlist'
)
} catch (error) {
// Rollback optimistic update
isInWishlist.value = wasInWishlist
showErrorNotification('Failed to update wishlist')
}
}
</script>
The Server-Side Hydration Magic:
- No Loading Spinners: Data is fetched on the server, so users see content immediately
- SEO Optimized: Search engines see fully rendered content with proper meta tags
- Performance: Critical data loads with the initial HTML, non-critical data can load asynchronously
- Error Handling: 404 errors are handled server-side before the page renders
Advanced Patterns and Best Practices
Custom Data Layer Composables
Create reusable composables for common data patterns:
// composables/useApiResource.ts
interface UseApiResourceOptions<T> {
key: string
endpoint: string
server?: boolean
transform?: (data: any) => T
default?: () => T
dependencies?: ComputedRef<any>[]
}
export function useApiResource<T>(options: UseApiResourceOptions<T>) {
const {
key,
endpoint,
server = true,
transform,
default: defaultValue,
dependencies = []
} = options
// Create reactive endpoint URL
const apiEndpoint = computed(() => {
let url = endpoint
// Replace placeholders with dependency values
dependencies.forEach((dep, index) => {
url = url.replace(`{${index}}`, String(dep.value))
})
return url
})
// Use enhanced $fetch with all the bells and whistles
const {
data,
pending,
error,
refresh
} = $fetch<T>(apiEndpoint, {
key: computed(() => `${key}-${dependencies.map(d => d.value).join('-')}`),
server,
default: defaultValue,
transform,
// Automatically refresh when dependencies change
watch: dependencies
})
// Enhanced refresh with error handling
const safeRefresh = async () => {
try {
await refresh()
} catch (error) {
console.error(`Failed to refresh ${key}:`, error)
throw error
}
}
// Invalidate related caches
const invalidate = async (pattern?: string) => {
await clearNuxtData(pattern || key)
}
return {
data: readonly(data),
pending: readonly(pending),
error: readonly(error),
refresh: safeRefresh,
invalidate
}
}
// Usage in components:
const userId = computed(() => route.params.id)
const { data: user, pending, refresh } = useApiResource({
key: 'user-profile',
endpoint: '/api/users/{0}',
dependencies: [userId],
transform: (response) => response.user
})
Data Synchronization Across Tabs
Implement cross-tab synchronization for real-time updates:
// composables/useCrossTabSync.ts
export function useCrossTabSync(key: string) {
const channel = new BroadcastChannel(`nuxt-data-${key}`)
// Listen for updates from other tabs
channel.addEventListener('message', (event) => {
if (event.data.type === 'DATA_UPDATE') {
// Refresh data in current tab
refreshNuxtData(key)
}
})
// Broadcast updates to other tabs
const broadcast = (data: any) => {
channel.postMessage({
type: 'DATA_UPDATE',
key,
data,
timestamp: Date.now()
})
}
// Cleanup on unmount
onUnmounted(() => {
channel.close()
})
return { broadcast }
}
Conclusion: Mastering the New Data Layer
Nuxt's revolutionary data layer transforms how we think about data management in modern web applications. These five features work together to create a seamless, performant, and developer-friendly experience:
- Reactive Server State eliminates the complexity of manual state synchronization
- Advanced Caching provides intelligent performance optimization without the headaches
- Cross-Component Synchronization removes the need for complex state management libraries
- Optimistic Updates create lightning-fast user experiences with automatic rollback
- Server-Side Hydration ensures perfect SEO and instant page loads
The examples in this guide demonstrate real-world scenarios where these features shine. From e-commerce platforms to social media applications, the new data layer adapts to your needs while maintaining simplicity and performance.
Key Takeaways for Implementation:
- Always use meaningful cache keys for proper deduplication
- Implement optimistic updates for better user experience
- Leverage server-side hydration for critical above-the-fold content
- Use conditional fetching to avoid unnecessary requests
- Create reusable composables for common patterns
- Implement proper error handling and rollback strategies
The future of Nuxt development is here, and it's more powerful and intuitive than ever. Embrace these new data layer features, and watch your applications become faster, more reliable, and easier to maintain. Your users will thank you, and your future self will too!
Happy coding with Nuxt's incredible new data layer! 🚀
Top comments (0)