Nuxt 3 Performance Optimization: A Production Playbook for Core Web Vitals

A production playbook for optimizing Nuxt 3 applications. SSR strategies, image optimization, code splitting, and Core Web Vitals tuning that holds up in CI.

Starting Point: A Slow Production App

The app this playbook comes from was slow in the way most B2B apps get slow — gradually, then noticeably. Largest Contentful Paint on key routes was 4.2s on throttled mobile. By Google's standards, anything over 2.5s for LCP is failing, and every user felt it.

The full program behind this post — what was tried, what was rejected, and how the results are enforced — is written up in the SaaS performance case study. This post is the generalized playbook.

The Optimization Playbook

1. Analyze with Lighthouse & Bundle Analysis

Before optimizing, we measured everything:

bash
npm run build --analyze
# Generated visual bundle report

# Lighthouse CI integration
npm install -D @lhci/cli@0.9.x

A typical analysis of an unoptimized app reveals something like this (illustrative example — run your own):

  • Main bundle: a few hundred KB gzipped of code the first paint doesn't need
  • Vendor bundle: duplicate versions of the same utility library, bundled separately
  • Images: the largest single line item — no WebP, no lazy loading, full-resolution on mobile

2. Code Splitting Strategy

Nuxt 3 has better defaults than Nuxt 2, but we needed aggressive splitting:

typescript
// nuxt.config.ts
export default defineNuxtConfig({
  build: {
    rollupOptions: {
      output: {
        manualChunks: {
          // Separate heavy dependencies
          'date-fns': ['date-fns'],
          'lodash': ['lodash-es'],
          'charts': ['chart.js', 'vue-chartjs'],
          // Keep vendor chunks under 200KB each
        }
      }
    }
  },
  nitro: {
    prerender: {
      crawlLinks: true,
      routes: ['/sitemap.xml', '/rss.xml'],
      // Pre-render critical routes at build time
      ignore: ['/admin', '/preview']
    }
  }
})

What to expect (illustrative example — your numbers will differ):

  • The main bundle drops to whatever the first route genuinely needs
  • Each lazy route loads a small chunk on demand
  • Routes render without waiting for full app initialization

3. Image Optimization

This single change had the biggest impact:

vue
<script setup lang="ts">
import { useImage } from '#app'

const imageUrl = 'hero.jpg'
</script>

<template>
  <!-- ❌ Old way: no optimization -->
  <img src="/images/hero.jpg" alt="Hero" />
  
  <!-- ✅ New way: Nuxt Image component -->
  <NuxtImg
    src="/images/hero.jpg"
    alt="Hero"
    sizes="xs:100vw sm:100vw md:600px lg:800px"
    format="webp,jpg"
    quality="80"
    loading="lazy"
  />
</template>

Install the Nuxt Image module:

bash
npm install @nuxt/image

Configure in nuxt.config.ts:

typescript
export default defineNuxtConfig({
  modules: ['@nuxt/image'],
  image: {
    provider: 'ipx', // or 'cloudinary', 'imgix', etc.
    ipx: {
      maxAge: 60 * 60 * 24 * 365, // 1 year cache
    }
  }
})

Why this matters most: on image-heavy routes, WebP conversion plus lazy loading is routinely the single largest LCP win — images are usually the largest contentful paint element. Measure your image bytes before and after; the reduction is typically dramatic.

4. Server-Side Rendering (SSR) Optimization

Nuxt 3's SSR is better, but configuration matters:

typescript
// nuxt.config.ts
export default defineNuxtConfig({
  nitro: {
    // Compress responses
    gzip: { enabled: true },
    brotli: { enabled: true },
    
    // Cache aggressively
    headers: {
      'Cache-Control': 'public, max-age=3600, s-maxage=86400'
    },
    
    // Pre-render static routes
    prerender: {
      crawlLinks: true,
      routes: ['/blog', '/projects', '/'],
      ignore: ['/admin', '/api/internal']
    },
    
    // Enable Redis caching for API responses
    storage: {
      redis: {
        host: process.env.REDIS_HOST,
        port: 6379
      }
    }
  }
})

The result: time-to-first-byte drops sharply once cacheable responses stop hitting the origin (illustrative — TTFB depends heavily on your hosting).

5. Font Loading Optimization

Fonts are invisible but heavy. We optimized aggressively:

typescript
// app.vue
import { useState } from '#app'

const fontLoadingComplete = useState('fontLoadingComplete', () => false)

useHead({
  link: [
    // Preconnect to font providers
    {
      rel: 'preconnect',
      href: 'https://fonts.googleapis.com'
    },
    {
      rel: 'preconnect',
      href: 'https://fonts.gstatic.com',
      crossorigin: ''
    },
    // Load only necessary weights
    {
      rel: 'stylesheet',
      href: 'https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap'
    }
  ]
})

But the real win came from font-display: swap:

css
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');

This tells the browser: "Show text immediately with fallback, swap in custom font when ready." No invisible text delays.

6. Hydration Optimization

This is a Nuxt 3 secret weapon. Partial hydration means not every component needs JavaScript:

vue
<!-- This component renders as static HTML on server -->
<!-- JavaScript only loads when needed -->
<ClientOnly fallback="<div class='skeleton'></div>">
  <ExpensiveInteractiveComponent />
</ClientOnly>

Use it for:

  • Hero sections (no JS needed)
  • Blog content (pure markup)
  • Static sidebars

Static sections that skip hydration are JavaScript the browser never has to parse.

7. Caching Strategy

Implemented a tiered caching approach:

typescript
// server/api/posts.get.ts
export default cachedEventHandler(
  async (event) => {
    return await db.posts.findAll()
  },
  {
    maxAge: 60 * 10, // 10 minute cache
    sMaxAge: 60 * 60 * 24, // 1 day for CDN
    staleMaxAge: 60 * 60 * 24 * 7, // 1 week stale
    name: 'api_posts',
    getKey: () => 'api_posts',
    vary: ['accept-encoding'] // Cache based on compression
  }
)

With a tiered cache, most repeat API reads never reach the database.

The Final Results

On the production program this playbook comes from, the headline result was:

MetricBeforeAfterImprovement
Largest Contentful Paint4.2s2.5s−40%

The other vitals moved in the same direction; the LCP number is the one measured carefully enough to publish. The case study covers how it was measured and enforced.

Performance Budget Going Forward

We set strict budgets to prevent regressions:

bash
# package.json
"build-analyze": "nuxi build && npm run bundle-check",

# .github/workflows/performance.yml
- name: Bundle Size Check
  run: npm run build-analyze
  # Fails if bundles exceed thresholds

Thresholds:

  • Main bundle: < 200KB gzipped
  • Per route: < 80KB gzipped
  • Images: < 1MB total per page

Key Takeaways

  1. Measure first — every optimization should be data-driven
  2. Images are the lowest-hanging fruit — image handling routinely delivers the largest share of the wins
  3. HTTP caching > code optimization — smart caching beats micro-optimizations
  4. Nuxt 3's primitives are powerful — when used correctly, you don't need complex workarounds
  5. Monitor in production — real-world metrics differ from labs. Use tools like Sentry, Vercel Analytics

If you're on Nuxt 2, the move to Nuxt 3 alone usually buys a meaningful improvement before you optimize anything — better defaults, better tree-shaking, better hydration. The playbook above is how you claim the rest.