After running our Vue.js dashboard as a client-side SPA for over a year, we started hitting some real limitations. The biggest pain point? SEO. Our marketing pages were invisible to search engines, and the initial load time was getting worse as our bundle grew.

I spent the last two months migrating our main application to server-side rendering using Nuxt.js. Here’s what I learned.

Table of Contents

Why We Needed SSR

Our SPA worked great for authenticated users, but we had three major problems:

  1. SEO was terrible - Google could barely index our public pages
  2. First paint was slow - Users saw a blank screen for 2-3 seconds
  3. Social sharing didn’t work - No meta tags meant ugly previews on Twitter/Facebook

I initially tried implementing SSR manually with Vue’s official SSR guide. After two weeks of fighting with webpack configs and hydration bugs, I gave up and looked at Nuxt.js.

Setting Up Nuxt

The initial setup was surprisingly smooth:

npm install nuxt

Here’s my basic nuxt.config.js:

export default {
  mode: 'universal', // Enable SSR
  
  head: {
    title: 'My App',
    meta: [
      { charset: 'utf-8' },
      { name: 'viewport', content: 'width=device-width, initial-scale=1' }
    ]
  },
  
  modules: [
    '@nuxtjs/axios'
  ],
  
  build: {
    extend(config, { isDev, isClient }) {
      // Custom webpack config
      if (isDev && isClient) {
        config.devtool = 'source-map'
      }
    }
  }
}

The biggest mental shift was understanding Nuxt’s file-based routing. Instead of defining routes in a router file, you just create files in the pages/ directory:

pages/
  index.vue          -> /
  about.vue          -> /about
  users/
    index.vue        -> /users
    _id.vue          -> /users/:id

This felt weird at first (I’m used to explicit route definitions), but it grew on me quickly.

Data Fetching with asyncData

The hardest part of the migration was refactoring our data fetching. In our SPA, we fetched data in mounted() hooks. With SSR, that doesn’t work because mounted() only runs on the client.

Nuxt provides asyncData() which runs on both server and client:

export default {
  async asyncData({ $axios, params }) {
    const user = await $axios.$get(`/api/users/${params.id}`)
    return { user }
  },
  
  data() {
    return {
      user: null // Will be populated by asyncData
    }
  }
}

The tricky part: asyncData() doesn’t have access to this because it runs before the component is created. This broke a lot of our existing code that relied on this.$store or this.$route.

I had to refactor dozens of components. Here’s a pattern I settled on:

export default {
  async asyncData({ store, $axios, params }) {
    // Fetch data on server
    if (!store.state.users[params.id]) {
      const user = await $axios.$get(`/api/users/${params.id}`)
      store.commit('setUser', { id: params.id, user })
    }
    return {}
  },
  
  computed: {
    user() {
      return this.$store.state.users[this.$route.params.id]
    }
  }
}

Vuex Store Gotchas

Our existing Vuex store had a major problem: it was a singleton. In SSR, the same store instance would be shared across all requests, causing data leaks between users.

Nuxt solves this by requiring you to export a function that creates a new store:

// store/index.js
export const state = () => ({
  users: {}
})

export const mutations = {
  setUser(state, { id, user }) {
    state.users[id] = user
  }
}

export const actions = {
  async nuxtServerInit({ commit }, { $axios }) {
    // This runs once on server startup
    const config = await $axios.$get('/api/config')
    commit('setConfig', config)
  }
}

The nuxtServerInit action is brilliant - it lets you populate the store before any pages render. I use it to fetch app configuration and user authentication state.

Authentication Challenges

Our authentication flow was completely client-side before. With SSR, I had to handle auth on both server and client.

I ended up using cookies instead of localStorage:

// middleware/auth.js
export default function ({ store, redirect, req }) {
  // Server-side
  if (process.server && req.headers.cookie) {
    const token = extractToken(req.headers.cookie)
    if (token) {
      store.commit('setAuth', token)
    }
  }
  
  // Client-side
  if (process.client) {
    const token = getCookie('auth_token')
    if (token) {
      store.commit('setAuth', token)
    }
  }
  
  // Redirect if not authenticated
  if (!store.state.auth.token) {
    return redirect('/login')
  }
}

Then apply it to protected pages:

export default {
  middleware: 'auth',
  // ... rest of component
}

Performance Results

After deploying to production, the results were impressive:

  • First Contentful Paint: 3.2s → 0.8s (75% improvement)
  • Time to Interactive: 4.5s → 2.1s (53% improvement)
  • SEO: Google started indexing our pages within days
  • Social sharing: Meta tags now work perfectly

The server load increased slightly (we’re now rendering HTML on every request), but it’s manageable. We’re running 4 Node.js instances behind nginx.

Deployment Setup

We deploy Nuxt as a Node.js app, not static files. Here’s our PM2 config:

// ecosystem.config.js
module.exports = {
  apps: [{
    name: 'nuxt-app',
    script: './node_modules/nuxt/bin/nuxt.js',
    args: 'start',
    instances: 4,
    exec_mode: 'cluster',
    env: {
      NODE_ENV: 'production',
      PORT: 3000
    }
  }]
}

Build process:

npm run build
pm2 start ecosystem.config.js

Nginx sits in front and handles SSL:

upstream nuxt {
  server 127.0.0.1:3000;
}

server {
  listen 80;
  server_name example.com;
  
  location / {
    proxy_pass http://nuxt;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection 'upgrade';
    proxy_set_header Host $host;
    proxy_cache_bypass $http_upgrade;
  }
}

Things I’d Do Differently

  1. Start with Nuxt from day one - Migrating an existing SPA was painful
  2. Use TypeScript - We’re still on plain JavaScript, but Nuxt 2.5+ has great TS support
  3. Implement proper caching - We’re rendering the same pages repeatedly; should cache at nginx level
  4. Split public and private apps - Our dashboard doesn’t need SSR; should have kept it as SPA

Conclusion

Nuxt.js made SSR much more approachable than I expected. The framework handles most of the complexity, and the conventions (while opinionated) make sense once you understand them.

The migration took about 6 weeks of full-time work, but the performance and SEO improvements were worth it. If you’re building a Vue app that needs SEO or fast initial loads, I’d recommend starting with Nuxt instead of trying to add SSR later.

Just be prepared to refactor your data fetching logic - that’s where most of the work is.