Skip to content

Server-Side Rendering (SSR)

Pinia provides excellent support for Server-Side Rendering (SSR) with automatic state serialization and hydration. This guide covers everything you need to know about using Pinia in SSR applications.

Overview

Server-Side Rendering with Pinia involves:

  1. Server-side state initialization - Creating and populating stores on the server
  2. State serialization - Converting store state to JSON for client transfer
  3. Client-side hydration - Restoring server state on the client
  4. Universal code - Writing code that works on both server and client

Basic SSR Setup

1. Server-side Store Creation

ts
// server.ts
import { createPinia, setActivePinia } from 'pinia'
import { createSSRApp } from 'vue'
import App from './App.vue'

export async function createApp() {
  const app = createSSRApp(App)
  const pinia = createPinia()
  
  app.use(pinia)
  setActivePinia(pinia)
  
  return { app, pinia }
}

2. State Serialization

ts
// server.ts (continued)
import { renderToString } from '@vue/server-renderer'

export async function render(url: string) {
  const { app, pinia } = await createApp()
  
  // Populate stores with data
  const userStore = useUserStore()
  await userStore.fetchUser()
  
  const html = await renderToString(app)
  const state = JSON.stringify(pinia.state.value)
  
  return {
    html,
    state
  }
}

3. Client-side Hydration

ts
// client.ts
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'

const app = createApp(App)
const pinia = createPinia()

// Hydrate state from server
if (window.__PINIA_STATE__) {
  pinia.state.value = window.__PINIA_STATE__
}

app.use(pinia)
app.mount('#app')

4. HTML Template

html
<!DOCTYPE html>
<html>
<head>
  <title>SSR App</title>
</head>
<body>
  <div id="app"><!--app-html--></div>
  <script>
    window.__PINIA_STATE__ = <!--pinia-state-->
  </script>
  <script src="/client.js"></script>
</body>
</html>

Advanced SSR Patterns

Conditional Server/Client Logic

ts
// stores/user.ts
export const useUserStore = defineStore('user', () => {
  const user = ref(null)
  const loading = ref(false)
  
  const fetchUser = async (id: string) => {
    if (import.meta.env.SSR) {
      // Server-side: fetch from database
      user.value = await getUserFromDatabase(id)
    } else {
      // Client-side: fetch from API
      loading.value = true
      try {
        user.value = await fetch(`/api/users/${id}`).then(r => r.json())
      } finally {
        loading.value = false
      }
    }
  }
  
  return { user, loading, fetchUser }
})

Store Initialization Hook

ts
// stores/app.ts
export const useAppStore = defineStore('app', () => {
  const initialized = ref(false)
  const settings = ref({})
  
  const initialize = async () => {
    if (initialized.value) return
    
    if (import.meta.env.SSR) {
      // Server initialization
      settings.value = await getServerConfig()
    } else {
      // Client initialization
      settings.value = await fetch('/api/config').then(r => r.json())
    }
    
    initialized.value = true
  }
  
  return { settings, initialize, initialized }
})

Route-based Data Fetching

ts
// router/index.ts
import { createRouter } from 'vue-router'

const router = createRouter({
  routes: [
    {
      path: '/user/:id',
      component: UserProfile,
      meta: {
        // Define data fetching requirements
        async serverPrefetch(route) {
          const userStore = useUserStore()
          await userStore.fetchUser(route.params.id)
        }
      }
    }
  ]
})

// Server-side route handling
export async function handleRoute(url: string) {
  const { app, pinia, router } = await createApp()
  
  await router.push(url)
  await router.isReady()
  
  const matchedRoute = router.currentRoute.value
  
  // Execute server prefetch
  if (matchedRoute.meta.serverPrefetch) {
    await matchedRoute.meta.serverPrefetch(matchedRoute)
  }
  
  return { app, pinia }
}

Framework Integrations

Nuxt.js Integration

See the Nuxt.js Integration Guide for detailed Nuxt-specific instructions.

Vite SSR

ts
// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [vue()],
  build: {
    ssr: true
  },
  ssr: {
    noExternal: ['pinia']
  }
})

Express.js Server

ts
// server.js
import express from 'express'
import { render } from './src/entry-server.js'

const app = express()

app.get('*', async (req, res) => {
  try {
    const { html, state } = await render(req.originalUrl)
    
    const template = `
      <!DOCTYPE html>
      <html>
        <head><title>SSR App</title></head>
        <body>
          <div id="app">${html}</div>
          <script>window.__PINIA_STATE__ = ${JSON.stringify(state)}</script>
          <script src="/client.js"></script>
        </body>
      </html>
    `
    
    res.send(template)
  } catch (error) {
    res.status(500).send('Server Error')
  }
})

app.listen(3000)

Best Practices

1. Avoid Memory Leaks

ts
// Create fresh pinia instance for each request
export async function createApp() {
  const app = createSSRApp(App)
  const pinia = createPinia() // Fresh instance
  
  app.use(pinia)
  return { app, pinia }
}

2. Handle Async Operations

ts
// stores/data.ts
export const useDataStore = defineStore('data', () => {
  const data = ref([])
  const loading = ref(false)
  const error = ref(null)
  
  const fetchData = async () => {
    if (loading.value) return // Prevent duplicate requests
    
    loading.value = true
    error.value = null
    
    try {
      data.value = await api.getData()
    } catch (err) {
      error.value = err.message
    } finally {
      loading.value = false
    }
  }
  
  return { data, loading, error, fetchData }
})

3. Optimize Bundle Size

ts
// Use dynamic imports for client-only code
const useClientOnlyStore = () => {
  if (import.meta.env.SSR) {
    return null
  }
  
  return import('./client-only-store').then(m => m.useClientOnlyStore())
}

4. Error Handling

ts
// stores/error.ts
export const useErrorStore = defineStore('error', () => {
  const errors = ref([])
  
  const addError = (error: Error) => {
    errors.value.push({
      id: Date.now(),
      message: error.message,
      stack: import.meta.env.DEV ? error.stack : undefined
    })
  }
  
  const clearErrors = () => {
    errors.value = []
  }
  
  return { errors, addError, clearErrors }
})

// Global error handler
app.config.errorHandler = (error) => {
  const errorStore = useErrorStore()
  errorStore.addError(error)
}

Debugging SSR

1. State Mismatch Detection

ts
// client.ts
if (import.meta.env.DEV) {
  // Warn about hydration mismatches
  app.config.warnHandler = (msg, instance, trace) => {
    if (msg.includes('Hydration')) {
      console.warn('Hydration mismatch detected:', msg)
      console.log('Component trace:', trace)
    }
  }
}

2. State Inspection

ts
// Debug helper
if (import.meta.env.DEV) {
  window.__PINIA_DEBUG__ = {
    getState: () => pinia.state.value,
    getStores: () => pinia._s,
    logState: () => console.log(JSON.stringify(pinia.state.value, null, 2))
  }
}

3. Performance Monitoring

ts
// Performance tracking
const performanceStore = defineStore('performance', () => {
  const metrics = ref({})
  
  const trackSSRTime = (startTime: number) => {
    metrics.value.ssrTime = Date.now() - startTime
  }
  
  const trackHydrationTime = (startTime: number) => {
    metrics.value.hydrationTime = Date.now() - startTime
  }
  
  return { metrics, trackSSRTime, trackHydrationTime }
})

Common Issues and Solutions

Issue: State Mismatch

Problem: Client state doesn't match server state

Solution:

ts
// Ensure consistent data fetching
const fetchUserData = async (id: string) => {
  // Use same data source on server and client
  const response = await fetch(`${API_BASE}/users/${id}`)
  return response.json()
}

Issue: Memory Leaks

Problem: Stores persist between requests

Solution:

ts
// Always create fresh pinia instance
export function createApp() {
  const pinia = createPinia() // New instance per request
  // ...
}

Issue: Client-only Code

Problem: Code that only works on client breaks SSR

Solution:

ts
// Guard client-only code
if (typeof window !== 'undefined') {
  // Client-only code
  localStorage.setItem('key', 'value')
}

Released under the MIT License.