Nuxt is fast by default. Server-side rendering, automatic code splitting, and smart prefetching are all built in. But I've still seen Nuxt sites with poor Core Web Vitals, usually because of a few common mistakes.
This guide covers the Nuxt-specific optimizations that actually move the needle on LCP, CLS, and INP.
When I audit a Nuxt site, I first check whether the slow page is still getting the benefits Nuxt is supposed to provide: useful server-rendered HTML, small payloads, stable image dimensions, and client JavaScript limited to the parts that actually need interactivity.
The Nuxt audit order I use
| Step | Check | Why it matters |
|---|---|---|
| 1 | Is the LCP content in the server-rendered HTML? | If the hero or main content only appears after hydration, LCP will suffer. |
| 2 | Are images using @nuxt/image with dimensions and preload where needed? | Nuxt cannot optimize images it does not control. |
| 3 | Is <ClientOnly> wrapping content or just browser-only controls? | Overuse removes SSR content and increases client work. |
| 4 | Is the Nuxt payload larger than the page needs? | Oversized payloads slow hydration and can hurt INP. |
| 5 | Are below-fold widgets lazy-loaded? | Charts, maps, editors, and carousels should not block initial rendering. |
That sequence is faster than trying every optimization at once. It tells you whether the issue is rendering, assets, hydration, or payload size.
Images: Use NuxtImg
The @nuxt/image module is Nuxt's answer to optimized images. It handles lazy loading, responsive sizing, and modern formats. If you're using plain <img> tags, you're leaving performance on the table.
Install it:
npm install @nuxt/image
Configure it:
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['@nuxt/image'],
image: {
quality: 80,
formats: ['webp', 'avif'],
}
});
Use it:
<template>
<!-- Bad -->
<img src="/hero.jpg" alt="Hero" />
<!-- Good -->
<NuxtImg
src="/hero.jpg"
alt="Hero"
width="1200"
height="600"
sizes="(max-width: 768px) 100vw, 1200px"
preload
/>
</template>
Key props:
preload- For LCP images. Adds a preload hint and disables lazy loading.loading="lazy"- Default behavior, good for below-fold images.sizes- Responsive sizing:sizes="(max-width: 768px) 100vw, 50vw"format- Force a specific format:format="webp"
For background images, use NuxtPicture:
<NuxtPicture
src="/hero.jpg"
:imgAttrs="{ class: 'hero-bg' }"
/>
For LCP images, avoid hiding the image inside a client-only carousel or JavaScript-rendered hero. Keep the first visible image server-rendered:
<template>
<section class="hero">
<NuxtImg
src="/hero.jpg"
alt="Dashboard showing improved performance"
width="1200"
height="720"
sizes="(max-width: 768px) 100vw, 1200px"
preload
/>
<ClientOnly>
<HeroControls />
</ClientOnly>
</section>
</template>
The visual controls can hydrate later. The LCP candidate should not wait.
Fonts: Use @nuxtjs/fontaine or inline critical fonts
Font loading is a common source of CLS and slow LCP. Nuxt has a few options:
Option 1: Fontaine (automatic font fallback matching)
npm install @nuxtjs/fontaine
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['@nuxtjs/fontaine'],
});
Fontaine automatically creates a fallback font with matching metrics, eliminating layout shift when the real font loads.
Option 2: Preload critical fonts
// nuxt.config.ts
export default defineNuxtConfig({
app: {
head: {
link: [
{
rel: 'preload',
href: '/fonts/inter.woff2',
as: 'font',
type: 'font/woff2',
crossorigin: 'anonymous'
}
]
}
}
});
Option 3: Google Fonts module
npm install @nuxtjs/google-fonts
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['@nuxtjs/google-fonts'],
googleFonts: {
families: {
Inter: [400, 600, 700],
},
display: 'swap',
preload: true,
}
});
Reduce JavaScript: Use ClientOnly wisely
Nuxt's <ClientOnly> wrapper is useful but often overused. Everything inside it ships JavaScript to the client and doesn't render on the server.
Bad - wrapping too much:
<ClientOnly>
<div class="product-page">
<ProductImage :src="product.image" />
<ProductDetails :product="product" />
<AddToCartButton :productId="product.id" />
</div>
</ClientOnly>
Good - only wrap what needs client-side JS:
<div class="product-page">
<ProductImage :src="product.image" />
<ProductDetails :product="product" />
<ClientOnly>
<AddToCartButton :productId="product.id" />
</ClientOnly>
</div>
Rule: Only use <ClientOnly> for components that genuinely need browser APIs (localStorage, window, etc.) or have heavy client-side interactivity.
If you need a placeholder, make it match the final component size so it does not cause CLS:
<ClientOnly>
<InteractiveMap />
<template #fallback>
<div class="map-fallback" aria-hidden="true" />
</template>
</ClientOnly>
.map-fallback {
min-height: 360px;
border-radius: 8px;
background: #e5e7eb;
}
The fallback is not just visual polish. It reserves layout space until the client component is ready.
Lazy load components
For heavy components below the fold, use defineAsyncComponent:
<script setup>
const HeavyChart = defineAsyncComponent(() =>
import('~/components/HeavyChart.vue')
);
</script>
<template>
<HeavyChart v-if="showChart" :data="chartData" />
</template>
Or use Nuxt's auto-import with the Lazy prefix:
<template>
<!-- Automatically lazy-loaded -->
<LazyHeavyChart :data="chartData" />
</template>
Any component prefixed with Lazy is automatically code-split and lazy-loaded.
Good candidates for lazy components:
| Component type | Why lazy-load it |
|---|---|
| Charts and dashboards | Usually not needed for the first paint. |
| Rich text editors | Heavy dependencies and browser-only behavior. |
| Maps | Large third-party scripts and layout cost. |
| Product recommendation widgets | Often below the main content. |
| Video embeds | Slow third-party resources. |
Do not lazy-load the hero, main heading, primary product image, or the first block of article content. Those are part of the initial user experience.
Optimize data fetching
Nuxt offers multiple data fetching methods. Choose the right one:
useFetch - For most cases:
<script setup>
const { data: products } = await useFetch('/api/products');
</script>
Runs on server during SSR, caches the result, and hydrates on client.
useAsyncData - When you need more control:
<script setup>
const { data: product } = await useAsyncData(
`product-${id}`,
() => $fetch(`/api/products/${id}`)
);
</script>
$fetch - For client-only or event handlers:
<script setup>
async function loadMore() {
const more = await $fetch('/api/products', {
params: { page: page.value + 1 }
});
products.value.push(...more);
}
</script>
Avoid: fetching on mount with onMounted:
<script setup>
// Bad - runs after SSR, causes hydration delay
onMounted(async () => {
const data = await fetch('/api/products').then(r => r.json());
});
</script>
Fetching in onMounted is fine for non-critical user-triggered data, but it is a poor default for page content. It creates an empty server-rendered page, then waits for JavaScript before data appears.
For public content, prefer server-aware fetching:
<script setup>
const route = useRoute();
const { data: article } = await useAsyncData(
`article-${route.params.slug}`,
() => queryCollection('blog').path(`/blog/${route.params.slug}`).first()
);
</script>
That keeps the page useful without waiting for client-side JavaScript.
Payload optimization
Large payloads slow down hydration. Keep your data minimal.
Bad - fetching everything:
<script setup>
// Returns all product fields including large descriptions, metadata, etc.
const { data } = await useFetch('/api/products');
</script>
Good - fetch only what you need:
<script setup>
const { data } = await useFetch('/api/products', {
pick: ['id', 'name', 'price', 'thumbnail']
});
</script>
The pick option strips unused fields from the payload, reducing transfer size and hydration time.
Also watch for payloads that include full markdown bodies, large nested product objects, or duplicated API responses. If a listing page only shows title, price, slug, and thumbnail, do not hydrate the entire product catalog into the client.
Use the Nuxt payload files or DevTools to check what is actually being sent. A fast API does not help if the browser has to parse a large JSON payload before the page becomes responsive.
Hydration mismatch and performance
Hydration mismatches are correctness bugs, but they can also become performance bugs because Vue has to recover from a DOM that does not match the server output.
Common causes:
| Cause | Safer pattern |
|---|---|
Date.now() in the template | Compute on the server and pass a stable value, or render after mount. |
Math.random() during SSR | Generate IDs deterministically or use Vue/Nuxt helpers. |
| Browser-only APIs in setup | Move to onMounted or wrap only the browser-only component in <ClientOnly>. |
| Locale/timezone differences | Format dates consistently on the server or defer non-critical formatting. |
| User-specific state in a public layout | Keep public HTML stable, hydrate personalization separately. |
If a component must render differently on the client, isolate that difference instead of making the whole page client-only.
Third-party scripts in Nuxt
Analytics, ads, chat, maps, and embeds can hurt all three Core Web Vitals. Load them intentionally:
<script setup>
useHead({
script: [
{
src: 'https://analytics.example/script.js',
async: true,
defer: true,
},
],
});
</script>
For non-critical widgets, load on interaction:
<script setup>
const chatLoaded = ref(false);
</script>
<template>
<button @click="chatLoaded = true">Open chat</button>
<ClientOnly>
<LazyChatWidget v-if="chatLoaded" />
</ClientOnly>
</template>
For ads, reserve the slot height before the ad library fills it. That protects CLS and avoids surprising users with content movement.
Quick wins checklist
LCP
- Use
<NuxtImg>withpreloadfor hero images - Preload critical fonts
- Minimize blocking JavaScript
CLS
- Always set
widthandheighton images - Use Fontaine for automatic font fallback matching
- Reserve space for dynamic content
INP
- Minimize
<ClientOnly>usage - Use
Lazyprefix for below-fold components - Keep payloads small with
pick - Avoid page content fetched only in
onMounted - Keep browser-only widgets isolated
- Reserve fallback height for client-only components
- Check payload size after adding new API fields
Debugging in Nuxt
Enable the Nuxt DevTools for performance insights:
// nuxt.config.ts
export default defineNuxtConfig({
devtools: { enabled: true }
});
The DevTools show component render times, payload sizes, and more.
Also inspect the generated output:
npm run build
Then check:
| Artifact | What to look for |
|---|---|
| Prerender/build output | Unexpected dynamic routes or missing public pages. |
| Payload JSON | Large repeated objects or fields not needed by the page. |
| Browser network waterfall | Late LCP image discovery or large third-party scripts. |
| DevTools Performance trace | Long hydration tasks and slow interactions. |
If the production build is static or Cloudflare/Vercel-backed, test the built output, not only npm run dev. Development mode includes extra work and does not represent the real user path.
Frequently Asked Questions
Why is my Nuxt site slow when Nuxt is supposed to be fast?
Common issues: using plain <img> instead of <NuxtImg>, overusing <ClientOnly>, fetching data with onMounted instead of useFetch, and not lazy loading heavy components below the fold.
Should I use useFetch or useAsyncData?
Use useFetch for most cases - it's simpler and handles caching automatically. Use useAsyncData when you need more control over the cache key or want to combine multiple data sources.
How do I lazy load components in Nuxt?
Prefix any component with Lazy (e.g., <LazyHeavyChart />) and Nuxt automatically code-splits and lazy loads it. For more control, use defineAsyncComponent.
What's the best way to handle fonts in Nuxt?
Use @nuxtjs/fontaine for automatic font fallback matching (eliminates CLS), or @nuxtjs/google-fonts with preload enabled. Avoid loading fonts via CSS @import.
Does ClientOnly hurt performance?
<ClientOnly> content ships JavaScript and doesn't render on the server, so overusing it hurts both load time and SEO. Only wrap components that genuinely need browser APIs or heavy client-side interactivity.
What's next
Want to see exactly where your Nuxt app is losing performance points? Run it through PageSpeedFix - we'll identify the specific issues and show you the Nuxt-specific code to fix them.
Related guides:
- Next.js vs Nuxt Performance - Comparing the two frameworks with real benchmarks
- Vite + React Performance - Vite optimization patterns that apply to Nuxt