Skip to content
ExponaBLOG
VUE.JS & NUXT · DAY 072 / 100

Common Nuxt mistakes: Vue & Nuxt lessons from production

Pitfalls in SSR apps.

Expona Admin7 min read

Introduction

Server-side rendering (SSR) unlocks powerful capabilities for Vue and Nuxt applications—better SEO, faster initial page loads, and improved perceived performance. But moving from pure client-side rendering to a hybrid SSR model introduces a whole new category of bugs that don't exist in traditional single-page applications. We've learned these lessons the hard way across our production systems.

In this article, we'll walk through common Nuxt mistakes: Vue & Nuxt lessons from production—real pitfalls we've encountered and fixed in e-commerce platforms, SaaS dashboards, and travel applications. Understanding these traps will help you avoid hours of debugging and unpredictable behavior in your SSR apps.

Using window during SSR

The most frequent mistake we see is accessing browser APIs like window, document, localStorage, or navigator without checking whether code runs on the server or client. During server-side rendering, these globals don't exist—they're undefined, and your app crashes.

Here's a classic example that breaks in production:

typescript
export default defineComponent({
  setup() {
    const isDarkMode = localStorage.getItem('theme') === 'dark'
    
    return { isDarkMode }
  }
})

When Nuxt renders this component on the server, localStorage is undefined, and the entire render fails. The browser receives an error, and hydration mismatches cascade through your application.

The fix is straightforward: guard browser API access with process.client or use Nuxt's lifecycle hooks that only run client-side.

typescript
export default defineComponent({
  setup() {
    let isDarkMode = false
    
    if (process.client) {
      isDarkMode = localStorage.getItem('theme') === 'dark'
    }
    
    return { isDarkMode }
  }
})

Alternatively, use onMounted to defer browser-dependent logic until after hydration:

typescript
export default defineComponent({
  setup() {
    const isDarkMode = ref(false)
    
    onMounted(() => {
      isDarkMode.value = localStorage.getItem('theme') === 'dark'
    })
    
    return { isDarkMode }
  }
})

For third-party libraries that rely on browser APIs, wrap them in client-only components using Nuxt's built-in <ClientOnly> component. This tells Nuxt to skip rendering that subtree on the server and only render it in the browser.

typescript
<template>
  <ClientOnly>
    <MapComponent />
  </ClientOnly>
</template>

Shared state across requests

In Nuxt, the server process persists across multiple incoming requests. If you define a reactive state object at the module level—outside of a component or composable factory function—that state gets shared between users. This is catastrophic for applications handling sensitive data or maintaining user-specific session information.

Here's the problematic pattern we've encountered in production:

typescript
// stores/user.ts - WRONG
const userState = ref({
  id: null,
  email: '',
  isAdmin: false
})

export const useUserStore = () => {
  return { userState }
}

Every incoming request to your server reuses the same userState reference. User A logs in, their admin flag is set to true, and User B—making a request moments later—sees User A's admin status. This isn't a theoretical issue; we've seen this leak sensitive information in production travel bookings and CRM systems.

The solution depends on your state management choice. With Pinia, create a new store instance per request using createPinia() in your Nuxt plugin:

typescript
// plugins/pinia.ts
import { createPinia } from 'pinia'

export default defineNuxtPlugin(() => {
  const pinia = createPinia()
  return {
    provide: {
      pinia
    }
  }
})

Nuxt handles this automatically if you follow the recommended pattern. But if you're using plain composables or custom stores, always return a new instance per call:

typescript
// stores/user.ts - CORRECT
export const useUserStore = () => {
  const userState = ref({
    id: null,
    email: '',
    isAdmin: false
  })
  
  return { userState }
}

Each invocation now creates a fresh reactive object. On the server, it's created per request. In the browser, it's created per page load. This isolation prevents data leakage between users.

Fetching twice on server and client

A common pattern—and often a source of duplicate API calls, wasted bandwidth, and performance degradation—is calling the same data-fetching function both on the server during render and again on the client during hydration. You see the network tab light up with redundant requests to the same endpoint.

Consider this component:

typescript
export default defineComponent({
  async setup() {
    const products = ref([])
    
    const fetchProducts = async () => {
      const response = await fetch('/api/products')
      products.value = await response.json()
    }
    
    // Runs on server AND client
    await fetchProducts()
    
    return { products }
  }
})

The server fetches products, renders HTML with that data, sends it to the browser. Then the browser hydrates the component and runs await fetchProducts() again—another full API call for data already in the DOM.

Nuxt provides useFetch and useAsyncData composables specifically to handle this. They cache data on the server and reuse it on the client during hydration:

typescript
export default defineComponent({
  setup() {
    const { data: products } = useFetch('/api/products')
    
    return { products }
  }
})

Under the hood, useFetch executes the fetch on the server, serializes the result into the HTML payload, and rehydrates it in the browser without re-fetching. One request, two rendering contexts.

If you need more control—perhaps you're calling a method-based API or need custom logic—use useAsyncData with explicit server/client control:

typescript
export default defineComponent({
  setup() {
    const { data: products } = useAsyncData('products', async () => {
      const response = await $fetch('/api/products')
      return response
    })
    
    return { products }
  }
})

The string key ('products') deduplicates requests. Call it multiple times in the same render cycle; it fetches once and shares the payload.

Secrets in public runtime config

Nuxt's runtimeConfig API splits configuration into public and private sections. Developers sometimes misunderstand this distinction and leak API keys, database credentials, or other secrets into the public config—which gets bundled and sent to every client browser.

Here's the mistake we've seen in production:

typescript
// nuxt.config.ts - WRONG
export default defineNuxtConfig({
  runtimeConfig: {
    apiKey: process.env.API_KEY,
    database: {
      password: process.env.DB_PASSWORD
    }
  }
})

Both apiKey and database.password end up in the browser bundle. Anyone can open the browser console and access useRuntimeConfig().apiKey. If that key is tied to a sensitive resource (a payment processor, a private data store), you've exposed your infrastructure to the world.

The correct pattern separates public and private configuration:

typescript
// nuxt.config.ts - CORRECT
export default defineNuxtConfig({
  runtimeConfig: {
    // Private: server-only
    apiSecret: process.env.API_SECRET,
    database: {
      password: process.env.DB_PASSWORD
    },
    // Public: sent to browser
    public: {
      apiUrl: process.env.PUBLIC_API_URL,
      analyticsId: process.env.ANALYTICS_ID
    }
  }
})

Private keys are only available server-side (in API routes, middleware, server plugins). Public keys are accessible in both server and browser contexts. In this example, an attacker can see your analytics ID (usually fine) but not your database password (critical).

To be extra safe, never log useRuntimeConfig() in client components. And always rotate any secret that's been accidentally exposed to production traffic.

Common mistakes recap

  • Accessing browser APIs during SSR – Wrap with process.client checks or defer to lifecycle hooks.
  • Module-level reactive state – Define state inside composable functions to create fresh instances per request.
  • Redundant API calls – Use useFetch or useAsyncData to share payloads between server and client.
  • Public runtime config leaking secrets – Always separate sensitive config from the public section.

Key takeaways

  • SSR and client-side rendering have different runtime environments; code must account for both or explicitly opt into one.
  • Server processes persist; state defined outside factory functions is shared across requests and users.
  • Nuxt's useFetch and useAsyncData prevent double-fetching by caching and deduplicating across server and client renders.
  • Configuration that reaches the browser is visible to users; treat runtimeConfig.public as public and runtimeConfig as secrets-only.
  • Test your application with server and client rendering enabled; client-only testing misses whole categories of bugs.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles