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:
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:
// 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:
<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:
npm install @nuxt/image
Configure in nuxt.config.ts:
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:
// 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:
// 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:
@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:
<!-- 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:
// 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:
| Metric | Before | After | Improvement |
|---|---|---|---|
| Largest Contentful Paint | 4.2s | 2.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:
# 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
- Measure first — every optimization should be data-driven
- Images are the lowest-hanging fruit — image handling routinely delivers the largest share of the wins
- HTTP caching > code optimization — smart caching beats micro-optimizations
- Nuxt 3's primitives are powerful — when used correctly, you don't need complex workarounds
- 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.