After a decade of building production Nuxt applications — from tiny MVPs to systems serving millions of requests per day — I keep seeing the same performance killers ship to prod. Most of them share a root cause: copying patterns from Vue SPA development and assuming they carry over to a full-stack SSR framework. They don't.
Let's fix all ten, permanently.
Mistake 1 — Skipping useFetch in Favor of axios / fetch
Impact: 🔴 High — Completely nullifies SSR. Users get a blank flash before data loads.
This is the mistake I see most frequently from developers migrating from Nuxt 2 or Vue 3 SPA backgrounds. They reach for axios or the browser's native fetch inside onMounted, which means the data fetch doesn't happen during SSR at all — only after the component hydrates on the client. Your users get a flash of empty content, then a network request. You've completely nullified the SSR advantage you're paying for.
useFetch and useAsyncData are SSR-aware. They run on the server, embed the result in the HTML payload, and rehydrate without a second request. They also deduplicate requests if the same key is fetched multiple times across components.
// ❌ Data fetched only after hydration — SSR gets empty HTML
const products = ref([])
onMounted(async () => {
products.value = await axios.get('/api/products')
})
// ✅ Data is fetched on the server, embedded in HTML, no double-fetch
const { data: products } = await useFetch('/api/products', {
key: 'products-list',
transform: (r) => r.data, // strip wrapper
default: () => [], // type-safe initial state
})
Fix: Reserve onMounted data fetching for truly client-only data (e.g. user-specific session state or browser APIs like navigator.geolocation). Everything else belongs in useFetch or useAsyncData.
Mistake 2 — Over-fetching on the Client Side
Impact: 🔴 High — Bloats the
__NUXT__payload embedded in every HTML response.
Even when teams correctly switch to useFetch, I still see huge JSON payloads being sent to the client — full database rows with 40 fields when the UI needs six. Every extra byte costs you on mobile networks. More critically, Nuxt serializes the entire server response into the __NUXT__ payload, which is inlined into the HTML. A bloated payload means a bloated document.
The fix has two parts: transform data at the API layer, and use pick in useFetch to discard fields you never render.
// ❌ Full user objects serialized into __NUXT__ payload
const { data: users } = await useFetch('/api/users')
// ✅ Only the fields we actually render are included in the payload
const { data: users } = await useFetch('/api/users', {
pick: ['id', 'name', 'avatar', 'role'],
transform: (list) =>
list.map(({ id, name, avatar, role }) => ({ id, name, avatar, role }))
})
// Also: lean server routes with explicit field selection
export default defineEventHandler(async () => {
return db.users.findMany({
select: { id: true, name: true, avatar: true, role: true }
})
})
Mistake 3 — Not Using Lazy Loading for Heavy Route Components
Impact: 🔴 High — Sends megabytes of JS to visitors who never trigger that component.
Nuxt auto-imports and bundles every page component. But "auto-imported" doesn't mean "lazily loaded." When you have a rich dashboard or admin panel, shipping that JavaScript to visitors of your landing page is waste. In Nuxt 3, defineAsyncComponent and the Lazy component prefix are your friends.
For heavy child components inside a page — think rich text editors, charts, or file uploaders — the Lazy prefix instructs Nuxt to code-split that component into a separate chunk and only load it when it enters the viewport or is conditionally rendered.
// ❌ Chart library + heavy component shipped to all visitors
import AnalyticsDashboard from '~/components/AnalyticsDashboard.vue'
// ✅ Nuxt auto-splits LazyAnalyticsDashboard into its own chunk.
// No import needed — the "Lazy" prefix does it automatically.
// In template:
<LazyAnalyticsDashboard v-if="showAnalytics" />
// For programmatic lazy-loading with suspense:
const Chart = defineAsyncComponent({
loader: () => import('~/components/HeavyChart.vue'),
loadingComponent: ChartSkeleton,
delay: 200,
})
Mistake 4 — Registering All Components Globally
Impact: 🟡 Medium — Every globally registered component bloats your main JS entry chunk.
A pattern that carried over from Nuxt 2's plugin era: registering every component globally via a plugin so you never have to import. It's convenient in development and catastrophic for your bundle. Every globally registered component gets included in your main entry chunk, even if 90% of your pages never render it.
Nuxt 3's auto-imports already solve the convenience argument. Components in the ~/components directory are auto-imported on-demand with tree-shaking intact. There's almost no legitimate reason to register globally anymore.
// ❌ Every component in the main bundle — even ones never rendered
export default defineNuxtPlugin((app) => {
app.component('AppModal', AppModal)
app.component('DataGrid', DataGrid) // 180kB chart lib inside
app.component('RichEditor', RichEditor) // 240kB editor
// ...20 more
})
// ✅ Delete the plugin. Nuxt auto-imports from ~/components on demand.
export default defineNuxtConfig({
components: { global: false } // explicit opt-out of global registration
})
Note: There is a valid use case for global registration: components rendered inside
v-htmlor third-party portals that bypass Vue's component resolution. For everything else, let auto-import handle it.
Mistake 5 — Blocking the Event Loop in Route Middleware
Impact: 🔴 High — Sequential awaits serialize every user hitting your SSR server.
Nuxt's route middleware runs before every navigation — including SSR. I've reviewed codebases where auth middleware was doing synchronous DB lookups, blocking regex parsing of user-agent strings, or — most egregiously — await-ing multiple sequential API calls without parallelization. Since this runs per-request on your Nitro server, it serializes every user who hits your app.
Parallelize independent async operations with Promise.all, cache aggressively, and keep middleware surgically focused on access control — not data loading.
// ❌ Sequential awaits — each request waits ~300ms on middleware alone
export default defineNuxtRouteMiddleware(async (to) => {
const session = await fetchSession() // 120ms
const perms = await fetchPermissions() // 110ms
const flags = await fetchFeatureFlags() // 90ms → total: ~320ms
})
// ✅ Parallel resolution — total wait ≈ slowest single call (~120ms)
export default defineNuxtRouteMiddleware(async (to) => {
const [session, perms, flags] = await Promise.all([
fetchSession(),
fetchPermissions(),
fetchFeatureFlags(),
])
if (!session) return navigateTo('/login')
})
Fix: Use Nitro's useStorage or an in-memory LRU cache for session and feature flag lookups. These rarely change per request — caching for even 30 seconds can reduce middleware latency by 90%.
Mistake 6 — Misusing useState as a Global Store
Impact: 🟡 Medium — Large reactive objects get serialized into every SSR payload.
useState in Nuxt 3 is SSR-safe shared state — it's not Pinia, and it's not designed to hold large, frequently-mutated datasets. When teams use it as a drop-in global store, they end up with reactive objects holding entire API result sets, which get serialized into every SSR payload regardless of whether the current page needs them.
Use useState for lightweight, primitive SSR state (current locale, theme, auth status). For anything relational or list-like, reach for Pinia.
// ❌ Entire product catalog in useState — serialized into every HTML response
const catalog = useState('catalog', () => []) // 500 products
const cart = useState('cart', () => []) // grows unbounded
// ✅ useState for primitives only
const cartCount = useState('cartCount', () => 0)
// ✅ Full cart managed in Pinia, server-initialized lazily
export const useCartStore = defineStore('cart', () => {
const items = ref([])
const addItem = (item) => items.value.push(item)
return { items, addItem }
})
Mistake 7 — Forgetting Stable Keys for Fetch Deduplication
Impact: 🔴 High — The same endpoint gets fetched N times when N components omit the key.
When two sibling components on the same page both call useFetch('/api/config'), Nuxt deduplicates the network request — but only if the keys match. Without explicit keys, each call gets a hash-based key derived from the URL, which often doesn't collide. Result: the same API endpoint is fetched multiple times per render, multiplying server load linearly with component depth.
Always provide an explicit, stable key option. For data shared across multiple components, extract it into a composable so there's a single source of truth.
// ❌ ComponentA and ComponentB both do this — two fetches, no dedup
const { data } = await useFetch('/api/config') // no key
// ✅ Single composable — useFetch deduplicates via the stable key
export const useAppConfig = () =>
useFetch('/api/config', {
key: 'app-config', // same key = same cache entry, always
server: true,
lazy: false,
default: () => ({})
})
// ComponentA.vue — no extra request, returns the cached ref
const { data: config } = useAppConfig()
// ComponentB.vue — same thing, zero extra network call
const { data: config } = useAppConfig()
Mistake 8 — Not Configuring the Nitro Server for Your Deployment Target
Impact: 🔴 High — Default config leaves edge caching, ISR, and compression on the table.
Nitro is a chameleon — it adapts to Node.js, edge workers, serverless functions, and static output. But it ships with sensible generic defaults, not optimal ones for your specific target. I routinely see teams deploying to Vercel or Cloudflare Workers without setting the correct preset, missing out on edge caching, streaming responses, and platform-native compression.
Equally overlooked: Nitro's built-in route caching with routeRules. Defining cache strategies per route pattern costs you five lines of config and eliminates entire classes of repeated computation on your origin.
// ❌ No routeRules — every request hits your origin server cold
export default defineNuxtConfig({})
// ✅ Route-level caching, ISR, prerendering, and redirect rules
export default defineNuxtConfig({
nitro: {
preset: 'vercel-edge', // or 'cloudflare-pages', 'node-cluster'
routeRules: {
'/': { prerender: true },
'/blog/**': { isr: 3600 }, // ISR: rebuild at most every hour
'/api/**': { cache: { maxAge: 60 } },
'/dashboard/**': { ssr: false }, // SPA for auth-gated routes
},
compressPublicAssets: { brotli: true },
},
})
Fix: ISR (isr: N) is one of the most powerful Nuxt 3 features. It gives you static-site speed with dynamic content freshness — no rebuild pipeline needed. Combine it with on-demand revalidation via $fetch('/api/__nuxt_isr__/revalidate?...') for instant cache purges on content updates.
Mistake 9 — Shipping Unoptimized Images
Impact: 🔴 High — Images are the #1 contributor to page weight in 95% of apps I've audited.
Teams spend hours shaving kilobytes off their JavaScript bundles while serving a 4 MB hero image as a PNG. Nuxt's built-in <NuxtImg> component (via @nuxt/image) handles resizing, format conversion, lazy loading, and CDN routing automatically. There is no excuse for not using it.
The component generates a srcset for responsive images, converts to WebP/AVIF on the fly, and adds native lazy loading. Pair it with a provider like Cloudinary or IPX and your images are served from edge nodes closest to your users.
<!-- ❌ Raw img — no compression, no WebP, no lazy loading -->
<img src="/hero.png" alt="Hero" />
<!-- ✅ NuxtImg generates WebP srcset, lazy-loads, and serves from CDN -->
<NuxtImg
src="/hero.png"
alt="Hero banner"
width="1200"
height="600"
format="webp"
loading="lazy"
quality="80"
sizes="sm:100vw md:80vw lg:1200px"
/>
// Configure a provider for edge delivery
export default defineNuxtConfig({
image: {
provider: 'cloudinary',
cloudinary: {
baseURL: 'https://res.cloudinary.com/your-id/image/upload/'
}
}
})
Mistake 10 — Shipping Without Analyzing Your Bundle
Impact: 🟡 Medium — You cannot optimize what you cannot see.
The majority of teams I work with have never once run a bundle analysis on their Nuxt application. They wonder why their JS entry chunk is 900 kB and blame "the framework" — when the real culprit is moment.js being imported by a date-picker, or lodash being bundled in full instead of cherry-picked.
Nuxt ships with Rollup under the hood (via Vite), and rollup-plugin-visualizer gives you an interactive treemap of your entire bundle in under a minute. Make this part of your CI pipeline, not a one-time panic fix.
# Install once
pnpm add -D rollup-plugin-visualizer
import { visualizer } from 'rollup-plugin-visualizer'
export default defineNuxtConfig({
vite: {
plugins: [
visualizer({
filename: 'bundle-stats.html',
open: true, // auto-opens in browser after build
gzipSize: true,
brotliSize: true,
template: 'treemap', // 'sunburst' | 'network' | 'treemap'
})
],
},
build: { analyze: true } // also enables built-in analysis
})
# Run a production build — bundle-stats.html opens automatically
nuxt build
Common culprits after analysis:
| Offender | Replacement | Size Savings |
|---|---|---|
moment | date-fns | ~65 kB gzipped |
lodash (full) | lodash-es with imports | ~50 kB gzipped |
| Full icon library | Tree-shaken individual icons | ~80–200 kB |
validator.js | zod | ~15 kB gzipped |
The Bottom Line
Performance isn't a feature you bolt on at the end — it's a discipline woven into every architectural decision from day one. Nuxt 3 gives you extraordinary leverage if you work with the framework's rendering model rather than around it.
Here's the quick-reference checklist:
- ✅ Always use
useFetch/useAsyncDatafor server-compatible data fetching - ✅ Transform and
pickAPI responses — never bloat the__NUXT__payload - ✅
Lazy-prefix heavy components,defineAsyncComponentfor third-party libs - ✅ Auto-import only — zero global component registrations
- ✅
Promise.allin middleware — parallelize, never serialize - ✅
useStatefor primitives, Pinia for relational / list state - ✅ Stable explicit
keyon everyuseFetchcall - ✅ Set
nitro.presetandrouteRulesbefore going to production - ✅
<NuxtImg>for every image — no raw<img>tags in a Nuxt app, ever - ✅ Bundle analysis on every significant dependency addition
Written by Shaheer Jawad — Staff Engineer Nuxt
Share this article
