Skip to content

Nuxt.js Integration with Pinia

A comprehensive guide to integrating Pinia with Nuxt.js, covering SSR, hydration, middleware, plugins, and advanced patterns for full-stack applications.

Features

  • 🚀 Server-Side Rendering (SSR) support
  • 💧 Proper hydration handling
  • 🔄 State persistence across navigation
  • 🛡️ Middleware integration
  • 🔌 Plugin system integration
  • 📱 Universal rendering
  • 🎯 Type-safe store composition
  • 🔐 Authentication with SSR
  • 📊 SEO-friendly data fetching
  • ⚡ Performance optimizations

Installation and Setup

1. Install Dependencies

bash
npm install @pinia/nuxt pinia
# or
yarn add @pinia/nuxt pinia

2. Nuxt Configuration

typescript
// nuxt.config.ts
export default defineNuxtConfig({
  modules: [
    '@pinia/nuxt',
  ],
  pinia: {
    autoImports: [
      // automatically imports `defineStore`
      'defineStore',
      // automatically imports `defineStore` as `definePiniaStore`
      ['defineStore', 'definePiniaStore'],
    ],
  },
  ssr: true, // Enable SSR
  css: ['~/assets/css/main.css'],
  runtimeConfig: {
    // Private keys (only available on server-side)
    apiSecret: process.env.API_SECRET,
    // Public keys (exposed to client-side)
    public: {
      apiBase: process.env.API_BASE_URL || 'http://localhost:3001'
    }
  }
})

Store Definitions

User Store with SSR Support

typescript
// stores/user.ts
export const useUserStore = defineStore('user', () => {
  // State
  const user = ref<User | null>(null)
  const isAuthenticated = ref(false)
  const loading = ref(false)
  const error = ref<string | null>(null)
  
  // Getters
  const userProfile = computed(() => {
    if (!user.value) return null
    return {
      ...user.value,
      fullName: `${user.value.firstName} ${user.value.lastName}`,
      initials: `${user.value.firstName[0]}${user.value.lastName[0]}`
    }
  })
  
  const hasRole = computed(() => (role: string) => {
    return user.value?.roles?.includes(role) || false
  })
  
  // Actions
  async function fetchUser() {
    if (process.server && !user.value) {
      // Server-side: fetch from API or database
      loading.value = true
      try {
        const { $fetch } = useNuxtApp()
        const userData = await $fetch('/api/user/profile')
        user.value = userData
        isAuthenticated.value = true
      } catch (err) {
        error.value = err instanceof Error ? err.message : 'Failed to fetch user'
        isAuthenticated.value = false
      } finally {
        loading.value = false
      }
    }
  }
  
  async function login(credentials: LoginCredentials) {
    loading.value = true
    error.value = null
    
    try {
      const { $fetch } = useNuxtApp()
      const response = await $fetch('/api/auth/login', {
        method: 'POST',
        body: credentials
      })
      
      user.value = response.user
      isAuthenticated.value = true
      
      // Set authentication cookie
      const token = useCookie('auth-token', {
        default: () => null,
        httpOnly: true,
        secure: true,
        sameSite: 'strict',
        maxAge: 60 * 60 * 24 * 7 // 7 days
      })
      token.value = response.token
      
      // Redirect to dashboard
      await navigateTo('/dashboard')
    } catch (err) {
      error.value = err instanceof Error ? err.message : 'Login failed'
      throw err
    } finally {
      loading.value = false
    }
  }
  
  async function logout() {
    try {
      const { $fetch } = useNuxtApp()
      await $fetch('/api/auth/logout', { method: 'POST' })
    } catch (err) {
      console.error('Logout error:', err)
    } finally {
      user.value = null
      isAuthenticated.value = false
      
      // Clear authentication cookie
      const token = useCookie('auth-token')
      token.value = null
      
      // Redirect to login
      await navigateTo('/login')
    }
  }
  
  async function updateProfile(updates: Partial<User>) {
    if (!user.value) return
    
    loading.value = true
    try {
      const { $fetch } = useNuxtApp()
      const updatedUser = await $fetch('/api/user/profile', {
        method: 'PATCH',
        body: updates
      })
      
      user.value = { ...user.value, ...updatedUser }
    } catch (err) {
      error.value = err instanceof Error ? err.message : 'Update failed'
      throw err
    } finally {
      loading.value = false
    }
  }
  
  // Initialize user on client-side
  function initializeUser() {
    if (process.client) {
      const token = useCookie('auth-token')
      if (token.value && !user.value) {
        fetchUser()
      }
    }
  }
  
  return {
    // State
    user: readonly(user),
    isAuthenticated: readonly(isAuthenticated),
    loading: readonly(loading),
    error: readonly(error),
    
    // Getters
    userProfile,
    hasRole,
    
    // Actions
    fetchUser,
    login,
    logout,
    updateProfile,
    initializeUser
  }
})

Posts Store with SSR Data Fetching

typescript
// stores/posts.ts
export const usePostsStore = defineStore('posts', () => {
  // State
  const posts = ref<Post[]>([])
  const currentPost = ref<Post | null>(null)
  const loading = ref(false)
  const error = ref<string | null>(null)
  const pagination = ref({
    page: 1,
    limit: 10,
    total: 0,
    totalPages: 0
  })
  
  // Getters
  const publishedPosts = computed(() => {
    return posts.value.filter(post => post.status === 'published')
  })
  
  const postsByCategory = computed(() => (category: string) => {
    return posts.value.filter(post => post.category === category)
  })
  
  // Actions
  async function fetchPosts(options: FetchPostsOptions = {}) {
    const {
      page = 1,
      limit = 10,
      category,
      search,
      refresh = false
    } = options
    
    // Avoid duplicate requests
    if (loading.value && !refresh) return
    
    // Use cached data on client-side if available
    if (process.client && posts.value.length > 0 && !refresh) {
      return posts.value
    }
    
    loading.value = true
    error.value = null
    
    try {
      const { $fetch } = useNuxtApp()
      const query = new URLSearchParams({
        page: page.toString(),
        limit: limit.toString(),
        ...(category && { category }),
        ...(search && { search })
      })
      
      const response = await $fetch(`/api/posts?${query}`)
      
      if (page === 1 || refresh) {
        posts.value = response.posts
      } else {
        // Append for pagination
        posts.value.push(...response.posts)
      }
      
      pagination.value = {
        page: response.page,
        limit: response.limit,
        total: response.total,
        totalPages: response.totalPages
      }
      
      return response.posts
    } catch (err) {
      error.value = err instanceof Error ? err.message : 'Failed to fetch posts'
      throw err
    } finally {
      loading.value = false
    }
  }
  
  async function fetchPostById(id: string) {
    // Check if post is already in cache
    const cachedPost = posts.value.find(post => post.id === id)
    if (cachedPost && !loading.value) {
      currentPost.value = cachedPost
      return cachedPost
    }
    
    loading.value = true
    error.value = null
    
    try {
      const { $fetch } = useNuxtApp()
      const post = await $fetch(`/api/posts/${id}`)
      
      currentPost.value = post
      
      // Update cache if post exists
      const existingIndex = posts.value.findIndex(p => p.id === id)
      if (existingIndex !== -1) {
        posts.value[existingIndex] = post
      } else {
        posts.value.unshift(post)
      }
      
      return post
    } catch (err) {
      error.value = err instanceof Error ? err.message : 'Failed to fetch post'
      throw err
    } finally {
      loading.value = false
    }
  }
  
  async function createPost(postData: CreatePostData) {
    loading.value = true
    error.value = null
    
    try {
      const { $fetch } = useNuxtApp()
      const newPost = await $fetch('/api/posts', {
        method: 'POST',
        body: postData
      })
      
      posts.value.unshift(newPost)
      pagination.value.total += 1
      
      return newPost
    } catch (err) {
      error.value = err instanceof Error ? err.message : 'Failed to create post'
      throw err
    } finally {
      loading.value = false
    }
  }
  
  async function updatePost(id: string, updates: Partial<Post>) {
    loading.value = true
    error.value = null
    
    try {
      const { $fetch } = useNuxtApp()
      const updatedPost = await $fetch(`/api/posts/${id}`, {
        method: 'PATCH',
        body: updates
      })
      
      // Update in cache
      const index = posts.value.findIndex(post => post.id === id)
      if (index !== -1) {
        posts.value[index] = updatedPost
      }
      
      if (currentPost.value?.id === id) {
        currentPost.value = updatedPost
      }
      
      return updatedPost
    } catch (err) {
      error.value = err instanceof Error ? err.message : 'Failed to update post'
      throw err
    } finally {
      loading.value = false
    }
  }
  
  async function deletePost(id: string) {
    loading.value = true
    error.value = null
    
    try {
      const { $fetch } = useNuxtApp()
      await $fetch(`/api/posts/${id}`, { method: 'DELETE' })
      
      // Remove from cache
      posts.value = posts.value.filter(post => post.id !== id)
      pagination.value.total -= 1
      
      if (currentPost.value?.id === id) {
        currentPost.value = null
      }
    } catch (err) {
      error.value = err instanceof Error ? err.message : 'Failed to delete post'
      throw err
    } finally {
      loading.value = false
    }
  }
  
  function clearCurrentPost() {
    currentPost.value = null
  }
  
  function resetPagination() {
    pagination.value = {
      page: 1,
      limit: 10,
      total: 0,
      totalPages: 0
    }
  }
  
  return {
    // State
    posts: readonly(posts),
    currentPost: readonly(currentPost),
    loading: readonly(loading),
    error: readonly(error),
    pagination: readonly(pagination),
    
    // Getters
    publishedPosts,
    postsByCategory,
    
    // Actions
    fetchPosts,
    fetchPostById,
    createPost,
    updatePost,
    deletePost,
    clearCurrentPost,
    resetPagination
  }
})

Server API Routes

Authentication API

typescript
// server/api/auth/login.post.ts
import jwt from 'jsonwebtoken'
import bcrypt from 'bcryptjs'

export default defineEventHandler(async (event) => {
  const { email, password } = await readBody(event)
  
  if (!email || !password) {
    throw createError({
      statusCode: 400,
      statusMessage: 'Email and password are required'
    })
  }
  
  try {
    // Find user in database
    const user = await findUserByEmail(email)
    
    if (!user || !await bcrypt.compare(password, user.passwordHash)) {
      throw createError({
        statusCode: 401,
        statusMessage: 'Invalid credentials'
      })
    }
    
    // Generate JWT token
    const config = useRuntimeConfig()
    const token = jwt.sign(
      { userId: user.id, email: user.email },
      config.apiSecret,
      { expiresIn: '7d' }
    )
    
    // Set secure cookie
    setCookie(event, 'auth-token', token, {
      httpOnly: true,
      secure: true,
      sameSite: 'strict',
      maxAge: 60 * 60 * 24 * 7 // 7 days
    })
    
    return {
      user: {
        id: user.id,
        email: user.email,
        firstName: user.firstName,
        lastName: user.lastName,
        roles: user.roles
      },
      token
    }
  } catch (error) {
    throw createError({
      statusCode: 500,
      statusMessage: 'Authentication failed'
    })
  }
})

Posts API

typescript
// server/api/posts/index.get.ts
export default defineEventHandler(async (event) => {
  const query = getQuery(event)
  const {
    page = 1,
    limit = 10,
    category,
    search
  } = query
  
  try {
    const posts = await fetchPostsFromDatabase({
      page: Number(page),
      limit: Number(limit),
      category: category as string,
      search: search as string
    })
    
    return {
      posts: posts.data,
      page: posts.page,
      limit: posts.limit,
      total: posts.total,
      totalPages: posts.totalPages
    }
  } catch (error) {
    throw createError({
      statusCode: 500,
      statusMessage: 'Failed to fetch posts'
    })
  }
})

// server/api/posts/[id].get.ts
export default defineEventHandler(async (event) => {
  const id = getRouterParam(event, 'id')
  
  if (!id) {
    throw createError({
      statusCode: 400,
      statusMessage: 'Post ID is required'
    })
  }
  
  try {
    const post = await findPostById(id)
    
    if (!post) {
      throw createError({
        statusCode: 404,
        statusMessage: 'Post not found'
      })
    }
    
    return post
  } catch (error) {
    throw createError({
      statusCode: 500,
      statusMessage: 'Failed to fetch post'
    })
  }
})

Middleware

Authentication Middleware

typescript
// middleware/auth.ts
export default defineNuxtRouteMiddleware((to) => {
  const userStore = useUserStore()
  
  // Initialize user on client-side
  if (process.client) {
    userStore.initializeUser()
  }
  
  // Check authentication
  if (!userStore.isAuthenticated) {
    return navigateTo('/login')
  }
})

// middleware/guest.ts
export default defineNuxtRouteMiddleware(() => {
  const userStore = useUserStore()
  
  // Redirect authenticated users away from guest pages
  if (userStore.isAuthenticated) {
    return navigateTo('/dashboard')
  }
})

// middleware/admin.ts
export default defineNuxtRouteMiddleware(() => {
  const userStore = useUserStore()
  
  if (!userStore.isAuthenticated) {
    return navigateTo('/login')
  }
  
  if (!userStore.hasRole('admin')) {
    throw createError({
      statusCode: 403,
      statusMessage: 'Access denied'
    })
  }
})

Plugins

Pinia Persistence Plugin

typescript
// plugins/pinia-persistence.client.ts
import { PiniaPluginContext } from 'pinia'

function createPersistedState() {
  return (context: PiniaPluginContext) => {
    const { store, options } = context
    
    // Only persist stores that opt-in
    if (!options.persist) return
    
    const storageKey = `pinia-${store.$id}`
    
    // Restore state from localStorage
    const savedState = localStorage.getItem(storageKey)
    if (savedState) {
      try {
        const parsedState = JSON.parse(savedState)
        store.$patch(parsedState)
      } catch (error) {
        console.error('Failed to restore state:', error)
        localStorage.removeItem(storageKey)
      }
    }
    
    // Save state changes to localStorage
    store.$subscribe((mutation, state) => {
      try {
        localStorage.setItem(storageKey, JSON.stringify(state))
      } catch (error) {
        console.error('Failed to persist state:', error)
      }
    })
  }
}

export default defineNuxtPlugin(() => {
  const { $pinia } = useNuxtApp()
  $pinia.use(createPersistedState())
})

API Plugin

typescript
// plugins/api.ts
export default defineNuxtPlugin(() => {
  const config = useRuntimeConfig()
  
  const api = $fetch.create({
    baseURL: config.public.apiBase,
    onRequest({ request, options }) {
      // Add authentication token
      const token = useCookie('auth-token')
      if (token.value) {
        options.headers = {
          ...options.headers,
          Authorization: `Bearer ${token.value}`
        }
      }
    },
    onResponseError({ response }) {
      // Handle authentication errors
      if (response.status === 401) {
        const userStore = useUserStore()
        userStore.logout()
      }
    }
  })
  
  return {
    provide: {
      api
    }
  }
})

Pages with SSR

Blog Index Page

vue
<!-- pages/blog/index.vue -->
<template>
  <div class="blog-page">
    <Head>
      <Title>Blog - Latest Posts</Title>
      <Meta name="description" content="Read our latest blog posts and articles" />
    </Head>
    
    <div class="container">
      <h1>Latest Posts</h1>
      
      <!-- Search and Filters -->
      <div class="filters">
        <input 
          v-model="searchQuery"
          placeholder="Search posts..."
          @input="handleSearch"
        />
        
        <select v-model="selectedCategory" @change="handleCategoryChange">
          <option value="">All Categories</option>
          <option value="tech">Technology</option>
          <option value="design">Design</option>
          <option value="business">Business</option>
        </select>
      </div>
      
      <!-- Posts Grid -->
      <div v-if="postsStore.loading" class="loading">
        Loading posts...
      </div>
      
      <div v-else-if="postsStore.error" class="error">
        {{ postsStore.error }}
      </div>
      
      <div v-else class="posts-grid">
        <article 
          v-for="post in postsStore.publishedPosts"
          :key="post.id"
          class="post-card"
        >
          <NuxtLink :to="`/blog/${post.slug}`">
            <img 
              v-if="post.featuredImage"
              :src="post.featuredImage"
              :alt="post.title"
              class="post-image"
            />
            
            <div class="post-content">
              <h2>{{ post.title }}</h2>
              <p>{{ post.excerpt }}</p>
              
              <div class="post-meta">
                <span>{{ formatDate(post.publishedAt) }}</span>
                <span>{{ post.category }}</span>
              </div>
            </div>
          </NuxtLink>
        </article>
      </div>
      
      <!-- Load More -->
      <div v-if="hasMorePosts" class="load-more">
        <button 
          @click="loadMorePosts"
          :disabled="postsStore.loading"
        >
          {{ postsStore.loading ? 'Loading...' : 'Load More' }}
        </button>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
const postsStore = usePostsStore()

// Reactive state
const searchQuery = ref('')
const selectedCategory = ref('')
const searchTimeout = ref<NodeJS.Timeout | null>(null)

// Computed
const hasMorePosts = computed(() => {
  const { page, totalPages } = postsStore.pagination
  return page < totalPages
})

// Server-side data fetching
await postsStore.fetchPosts({
  page: 1,
  limit: 12
})

// Methods
function handleSearch() {
  if (searchTimeout.value) {
    clearTimeout(searchTimeout.value)
  }
  
  searchTimeout.value = setTimeout(() => {
    postsStore.fetchPosts({
      page: 1,
      search: searchQuery.value,
      category: selectedCategory.value,
      refresh: true
    })
  }, 300)
}

function handleCategoryChange() {
  postsStore.fetchPosts({
    page: 1,
    category: selectedCategory.value,
    search: searchQuery.value,
    refresh: true
  })
}

async function loadMorePosts() {
  const nextPage = postsStore.pagination.page + 1
  await postsStore.fetchPosts({
    page: nextPage,
    category: selectedCategory.value,
    search: searchQuery.value
  })
}

function formatDate(date: string) {
  return new Date(date).toLocaleDateString('en-US', {
    year: 'numeric',
    month: 'long',
    day: 'numeric'
  })
}

// SEO
useHead({
  title: 'Blog - Latest Posts',
  meta: [
    {
      name: 'description',
      content: 'Read our latest blog posts and articles about technology, design, and business.'
    },
    {
      property: 'og:title',
      content: 'Blog - Latest Posts'
    },
    {
      property: 'og:description',
      content: 'Read our latest blog posts and articles about technology, design, and business.'
    }
  ]
})
</script>

Blog Post Page

vue
<!-- pages/blog/[slug].vue -->
<template>
  <div class="post-page">
    <Head v-if="post">
      <Title>{{ post.title }}</Title>
      <Meta name="description" :content="post.excerpt" />
      <Meta property="og:title" :content="post.title" />
      <Meta property="og:description" :content="post.excerpt" />
      <Meta property="og:image" :content="post.featuredImage" />
    </Head>
    
    <article v-if="post" class="post">
      <header class="post-header">
        <img 
          v-if="post.featuredImage"
          :src="post.featuredImage"
          :alt="post.title"
          class="featured-image"
        />
        
        <div class="post-meta">
          <h1>{{ post.title }}</h1>
          <p class="excerpt">{{ post.excerpt }}</p>
          
          <div class="meta-info">
            <span>Published {{ formatDate(post.publishedAt) }}</span>
            <span>{{ post.readingTime }} min read</span>
            <span class="category">{{ post.category }}</span>
          </div>
        </div>
      </header>
      
      <div class="post-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="share-buttons">
          <button @click="sharePost('twitter')">
            Share on Twitter
          </button>
          <button @click="sharePost('facebook')">
            Share on Facebook
          </button>
          <button @click="copyLink">
            Copy Link
          </button>
        </div>
      </footer>
    </article>
    
    <div v-else-if="postsStore.loading" class="loading">
      Loading post...
    </div>
    
    <div v-else class="error">
      Post not found
    </div>
  </div>
</template>

<script setup lang="ts">
const route = useRoute()
const postsStore = usePostsStore()

// Get post slug from route
const slug = route.params.slug as string

// Server-side data fetching
const post = await postsStore.fetchPostById(slug)

if (!post) {
  throw createError({
    statusCode: 404,
    statusMessage: 'Post not found'
  })
}

// Methods
function formatDate(date: string) {
  return new Date(date).toLocaleDateString('en-US', {
    year: 'numeric',
    month: 'long',
    day: 'numeric'
  })
}

function sharePost(platform: string) {
  const url = window.location.href
  const title = post.title
  
  let shareUrl = ''
  
  switch (platform) {
    case 'twitter':
      shareUrl = `https://twitter.com/intent/tweet?url=${encodeURIComponent(url)}&text=${encodeURIComponent(title)}`
      break
    case 'facebook':
      shareUrl = `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(url)}`
      break
  }
  
  if (shareUrl) {
    window.open(shareUrl, '_blank', 'width=600,height=400')
  }
}

async function copyLink() {
  try {
    await navigator.clipboard.writeText(window.location.href)
    // Show success message
  } catch (err) {
    console.error('Failed to copy link:', err)
  }
}

// SEO and meta tags
useHead({
  title: post.title,
  meta: [
    {
      name: 'description',
      content: post.excerpt
    },
    {
      property: 'og:title',
      content: post.title
    },
    {
      property: 'og:description',
      content: post.excerpt
    },
    {
      property: 'og:image',
      content: post.featuredImage
    },
    {
      property: 'og:type',
      content: 'article'
    },
    {
      name: 'twitter:card',
      content: 'summary_large_image'
    }
  ]
})
</script>

Testing

typescript
// tests/stores/user.nuxt.test.ts
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useUserStore } from '@/stores/user'

// Mock Nuxt composables
vi.mock('#app', () => ({
  useNuxtApp: () => ({
    $fetch: vi.fn()
  }),
  useCookie: vi.fn(() => ({ value: null })),
  navigateTo: vi.fn()
}))

describe('User Store (Nuxt)', () => {
  beforeEach(() => {
    setActivePinia(createPinia())
    vi.clearAllMocks()
  })

  it('should handle login correctly', async () => {
    const store = useUserStore()
    const mockFetch = vi.fn().mockResolvedValue({
      user: { id: '1', email: 'test@example.com' },
      token: 'mock-token'
    })
    
    vi.mocked(useNuxtApp).mockReturnValue({
      $fetch: mockFetch
    })
    
    await store.login({
      email: 'test@example.com',
      password: 'password'
    })
    
    expect(store.isAuthenticated).toBe(true)
    expect(store.user?.email).toBe('test@example.com')
    expect(mockFetch).toHaveBeenCalledWith('/api/auth/login', {
      method: 'POST',
      body: {
        email: 'test@example.com',
        password: 'password'
      }
    })
  })

  it('should handle SSR user fetching', async () => {
    // Mock server environment
    Object.defineProperty(global, 'process', {
      value: { server: true }
    })
    
    const store = useUserStore()
    const mockFetch = vi.fn().mockResolvedValue({
      id: '1',
      email: 'test@example.com'
    })
    
    vi.mocked(useNuxtApp).mockReturnValue({
      $fetch: mockFetch
    })
    
    await store.fetchUser()
    
    expect(store.user?.email).toBe('test@example.com')
    expect(mockFetch).toHaveBeenCalledWith('/api/user/profile')
  })
})

Key Features

1. SSR Support

  • Server-side data fetching
  • Proper hydration handling
  • SEO-friendly rendering
  • Meta tag management

2. Authentication

  • JWT token management
  • Secure cookie handling
  • Route protection middleware
  • Role-based access control

3. Data Management

  • Efficient caching strategies
  • Optimistic updates
  • Error handling
  • Pagination support

4. Performance

  • Code splitting
  • Lazy loading
  • State persistence
  • Request deduplication

5. Developer Experience

  • Type safety
  • Auto-imports
  • Hot module replacement
  • Comprehensive testing

This Nuxt.js integration demonstrates how to build full-stack applications with Pinia, providing server-side rendering, authentication, and optimal performance while maintaining a great developer experience.

Released under the MIT License.