4 min read
On this page

Performance

A fast application is not something you add at the end. It is a series of decisions about what to load, when to load it, and how to cache it. Vue and Nuxt give you good defaults — automatic code splitting, tree shaking, and efficient hydration — but the defaults only get you so far. Once your app has a dozen routes, a charting library, and a rich text editor, you need to be deliberate about performance.

Lazy Loading Components

Nuxt auto-imports components, but by default they are all bundled eagerly. Prefix any component with Lazy to code-split it and load it on demand:

<script setup lang="ts">
const showComments = ref(false)
</script>

<template>
  <article>
    <h1>{{ post.title }}</h1>
    <div v-html="post.body" />

    <button @click="showComments = true">Show Comments</button>

    <!-- LazyCommentsList is only loaded when showComments is true -->
    <LazyCommentsList v-if="showComments" :post-id="post.id" />
  </article>
</template>

Without the Lazy prefix, CommentsList and all its dependencies ship in the initial bundle even if the user never clicks the button. With Lazy, the component chunk is fetched only when it renders.

This matters most for heavy components: rich text editors (TipTap, ProseMirror), charting libraries (Chart.js, Apache ECharts), code editors (Monaco, CodeMirror), and anything with large dependencies.

<template>
  <div>
    <!-- These heavy components load only when needed -->
    <LazyRichTextEditor v-if="editing" v-model="content" />
    <LazyChartDashboard v-if="activeTab === 'analytics'" :data="metrics" />
    <LazyCodeEditor v-if="showCode" :value="sourceCode" language="typescript" />
  </div>
</template>

Code Splitting

Nuxt automatically code-splits by route. Each page gets its own JavaScript chunk, loaded when the user navigates to it. You do not need to configure this — it just works.

For finer control, use dynamic imports in your code:

// Instead of importing everything at the top level
// import { parse } from 'csv-parse'

// Import dynamically when needed
async function processCSV(file: File) {
  const { parse } = await import('csv-parse/browser/esm')
  const text = await file.text()
  return parse(text, { columns: true })
}

Analyzing Your Bundle

npx nuxi build --analyze

This opens a treemap visualization of your bundle. Look for:

  • Libraries that appear in multiple chunks (they should be in a shared chunk)
  • Unexpectedly large dependencies (do you really need all of lodash for one function?)
  • Server-only code leaking into the client bundle
// nuxt.config.ts — manual chunk optimization
export default defineNuxtConfig({
  vite: {
    build: {
      rollupOptions: {
        output: {
          manualChunks: {
            // Group vendor libraries into a single chunk
            'chart-vendor': ['chart.js', 'chartjs-adapter-date-fns'],
          },
        },
      },
    },
  },
})

Be careful with manualChunks — it is easy to make things worse by creating chunks that are too large or too numerous. Only use it when the default splitting produces clearly suboptimal results.

Image Optimization with Nuxt Image

Images are usually the largest assets on a page. @nuxt/image provides automatic resizing, format conversion, and lazy loading:

npx nuxi module add @nuxt/image
<script setup lang="ts">
// No import needed — NuxtImg is auto-imported
</script>

<template>
  <div>
    <!-- Automatically generates srcset, converts to WebP, lazy loads -->
    <NuxtImg
      src="/images/hero.jpg"
      alt="Product hero image"
      width="1200"
      height="630"
      sizes="sm:100vw md:80vw lg:1200px"
      loading="lazy"
      format="webp"
      quality="80"
    />
  </div>
</template>

For responsive images with art direction:

<template>
  <NuxtPicture
    src="/images/hero.jpg"
    alt="Hero"
    sizes="sm:100vw md:50vw lg:800px"
    :imgAttrs="{ class: 'rounded-lg' }"
  />
</template>

Configure image providers for external images (Cloudinary, Imgix, Vercel):

// nuxt.config.ts
export default defineNuxtConfig({
  modules: ['@nuxt/image'],
  image: {
    provider: 'cloudinary',
    cloudinary: {
      baseURL: 'https://res.cloudinary.com/your-account/image/upload/',
    },
    // Or use multiple providers
    providers: {
      cloudinary: {
        baseURL: 'https://res.cloudinary.com/your-account/image/upload/',
      },
    },
    screens: {
      sm: 640,
      md: 768,
      lg: 1024,
      xl: 1280,
    },
  },
})

Font Optimization

Web fonts cause layout shifts when they load after the page renders (FOUT — Flash of Unstyled Text). Use @nuxt/fonts to optimize loading:

npx nuxi module add @nuxt/fonts
// nuxt.config.ts
export default defineNuxtConfig({
  modules: ['@nuxt/fonts'],
})

The module automatically detects font declarations in your CSS and optimizes them — downloading, subsetting, and self-hosting Google Fonts, Adobe Fonts, and others. No manual @font-face declarations needed.

For manual control:

/* The module processes this and generates optimized font-face declarations */
body {
  font-family: 'Inter', sans-serif;
}

Practical tips: use font-display: swap (the default in @nuxt/fonts) so text is visible immediately with a fallback font. Subset fonts to include only the character sets you actually use. A full Inter font file is 300KB; subsetting to Latin reduces it to 30KB.

Lighthouse Audits

Run Lighthouse in CI to catch performance regressions before they reach production:

// scripts/lighthouse.ts
import lighthouse from 'lighthouse'
import * as chromeLauncher from 'chrome-launcher'

async function run() {
  const chrome = await chromeLauncher.launch({ chromeFlags: ['--headless'] })

  const result = await lighthouse('http://localhost:3000', {
    port: chrome.port,
    onlyCategories: ['performance'],
    output: 'json',
  })

  const score = result?.lhr.categories.performance?.score ?? 0

  console.log(`Performance score: ${score * 100}`)

  if (score < 0.9) {
    console.error('Performance score below 90. Failing build.')
    process.exit(1)
  }

  await chrome.kill()
}

run()

Key metrics to watch: Largest Contentful Paint (LCP) under 2.5 seconds, First Input Delay (FID) under 100ms, Cumulative Layout Shift (CLS) under 0.1. These are Google's Core Web Vitals, and they affect search ranking.

Caching Strategies

Route-Level Caching

Use routeRules to cache responses at the CDN or server level:

// nuxt.config.ts
export default defineNuxtConfig({
  routeRules: {
    // Static assets — cache forever (filename-hashed)
    '/_nuxt/**': {
      headers: { 'Cache-Control': 'public, max-age=31536000, immutable' },
    },

    // Product pages — SWR with 1-hour cache
    '/products/**': { swr: 3600 },

    // API responses — cache for 5 minutes
    '/api/products': {
      cache: { maxAge: 300 },
    },

    // User-specific data — never cache
    '/api/me': {
      cache: false,
      headers: { 'Cache-Control': 'no-store' },
    },
  },
})

Client-Side Data Caching

useFetch and useAsyncData cache responses by key. Avoid unnecessary refetches:

<script setup lang="ts">
// The key 'products' means this data is cached across navigations
const { data: products } = await useFetch('/api/products', {
  key: 'products',
  // Only refetch every 5 minutes
  getCachedData(key, nuxtApp) {
    const cached = nuxtApp.payload.data[key] || nuxtApp.static.data[key]
    if (!cached) return undefined

    const fetchedAt = nuxtApp.payload._errors?.[key]?.fetchedAt
    if (fetchedAt && Date.now() - fetchedAt > 5 * 60 * 1000) {
      return undefined // Cache expired, refetch
    }

    return cached
  },
})
</script>

Prefetching and Preloading

Nuxt prefetches linked pages when their <NuxtLink> enters the viewport. This makes navigations feel instant:

<template>
  <!-- Nuxt automatically prefetches /about when this link is visible -->
  <NuxtLink to="/about">About Us</NuxtLink>

  <!-- Disable prefetching for links that are rarely clicked -->
  <NuxtLink to="/terms" :prefetch="false">Terms of Service</NuxtLink>
</template>

For data prefetching, use useAsyncData with lazy: true to start fetching without blocking navigation:

<script setup lang="ts">
// Starts fetching immediately but doesn't block rendering
const { data: recommendations, pending } = await useLazyFetch('/api/recommendations')
</script>

<template>
  <div>
    <MainContent />
    <aside>
      <div v-if="pending">Loading recommendations...</div>
      <RecommendationList v-else :items="recommendations" />
    </aside>
  </div>
</template>

Common Pitfalls

Importing entire libraries when you need one function. import _ from 'lodash' pulls in 70KB. import debounce from 'lodash-es/debounce' pulls in 1KB. Better yet, use VueUse's useDebounceFn which is tree-shakeable and already in your dependency tree.

Not setting explicit width and height on images. Without dimensions, the browser cannot reserve space for the image before it loads. This causes layout shifts (bad CLS score). Always set width and height attributes or use CSS aspect-ratio.

Over-prefetching. If every link on the page prefetches its target, a page with 50 links fires 50 prefetch requests. Use :prefetch="false" on low-priority links like footer links and legal pages.

Ignoring server response time. Client-side optimizations do not help if your server takes 2 seconds to respond. Monitor Time to First Byte (TTFB) and optimize slow database queries, API calls, and middleware before tweaking client bundles.

Caching user-specific data. Never cache responses that include personal information, authentication tokens, or user-specific content at the CDN level. A cached /api/me response served to the wrong user is a security incident.

Key Takeaways

  • Use the Lazy prefix on components with heavy dependencies to defer loading until they are needed.
  • Run nuxi build --analyze regularly to catch bundle size regressions.
  • Use @nuxt/image for automatic image optimization — it handles srcset, format conversion, and lazy loading.
  • Cache aggressively with routeRules but never cache user-specific data.
  • Measure performance with Lighthouse in CI and track Core Web Vitals (LCP, FID, CLS) over time.