DEV Community

Marco Quintella
Marco Quintella

Posted on

2

Mastering Nuxt's New Data Layer: A Complete Developer's Guide

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode
<!-- 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>
Enter fullscreen mode Exit fullscreen mode
<!-- 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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

The Server-Side Hydration Magic:

  1. No Loading Spinners: Data is fetched on the server, so users see content immediately
  2. SEO Optimized: Search engines see fully rendered content with proper meta tags
  3. Performance: Critical data loads with the initial HTML, non-critical data can load asynchronously
  4. 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
})
Enter fullscreen mode Exit fullscreen mode

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 }
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Reactive Server State eliminates the complexity of manual state synchronization
  2. Advanced Caching provides intelligent performance optimization without the headaches
  3. Cross-Component Synchronization removes the need for complex state management libraries
  4. Optimistic Updates create lightning-fast user experiences with automatic rollback
  5. 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! 🚀

Heroku

Deploy with ease. Manage efficiently. Scale faster.

Leave the infrastructure headaches to us, while you focus on pushing boundaries, realizing your vision, and making a lasting impression on your users.

Get Started

Top comments (0)

ACI image

ACI.dev: Fully Open-source AI Agent Tool-Use Infra (Composio Alternative)

100% open-source tool-use platform (backend, dev portal, integration library, SDK/MCP) that connects your AI agents to 600+ tools with multi-tenant auth, granular permissions, and access through direct function calling or a unified MCP server.

Check out our GitHub!