I recently realized my personal portfolio—built with Nuxt 3, Vue 3, TypeScript, and Vite—was loading way slower than I expected. It looked great but felt sluggish. After digging into its metrics, I found the initial load time was around 4.5 seconds. That might not sound terrible, but for a portfolio, it’s enough to lose visitors before they even see your work.
So, I decided to take a weekend to optimize it—and ended up cutting the loading time down to just 1.4 seconds. Here’s how I did it.
The Problem
When I first ran a Lighthouse audit, the metrics weren’t pretty:
- First Contentful Paint: ~3.9s
- Time to Interactive: ~4.5s
- Largest Contentful Paint: ~4.2s
Most of the slowness came from:
- Rendering delays due to SSR configuration
- Heavy component imports and unused dependencies in the bundle
- Unoptimized images (massive JPGs and PNGs)
- Everything loading all at once, no matter if the section was currently visible
The goal: make it fast without losing developer convenience. That’s when I revisited my Nuxt setup.
SSR vs SSG — The Big Decision
At first, I built my portfolio using Server-Side Rendering (SSR). That meant every request was processed dynamically on the server before being sent to the client.
However, after thinking it through, SSR was overkill for a mostly static portfolio.
Simplified Explanation
- SSR (Server-Side Rendering): Each request gets a fresh, pre-rendered HTML page from the server. Great for dynamic data (like dashboards or user profiles).
- SSG (Static Site Generation): HTML pages are prebuilt at build time and served as static files. Excellent for static content (like personal portfolios or blogs).
Real Use Cases
- SSR: News sites, e-commerce product pages.
- SSG: Developer portfolios, landing pages, blogs.
Switching from SSR to SSG alone made my pages load almost instantly since most of the HTML was pre-rendered ahead of time.
Optimization Techniques That Made the Difference
1. Route Prerendering Configuration
Nuxt 3 allows you to prerender certain routes during the build process.
// nuxt.config.ts
export default defineNuxtConfig({
nitro: {
prerender: {
routes: [
'/',
'/about',
'/projects',
'/contact'
],
crawlLinks: true,
},
},
})
-
Static pages like
/about
and/contact
were prebuilt. -
Dynamic routes like
/projects/[id]
were skipped to be fetched lazily at runtime.
This instantly shaved off a big chunk of server-render time.
2. Lazy Loading Components with defineAsyncComponent()
Initially, every Vue component was imported upfront—even for parts not visible on initial load. That’s bad for performance.
By loading larger sections asynchronously, I made the first paint faster:
<script setup lang="ts">
import { defineAsyncComponent } from 'vue'
const ProjectGrid = defineAsyncComponent(() => import('@/components/ProjectGrid.vue'))
</script>
<template>
<HeroSection />
<Suspense>
<ProjectGrid />
</Suspense>
</template>
This approach ensures users don’t wait for heavy components if they’re below the fold.
3. Tree-Shaking Dependencies (Goodbye Full Lodash Import)
I noticed my bundle size ballooned because I imported Lodash like this:
import _ from 'lodash'
That pulled the entire library, even if I used just one or two functions. Instead, importing individually helped a lot:
import cloneDeep from 'lodash/cloneDeep'
import debounce from 'lodash/debounce'
After switching to individual imports, my JS bundle dropped by 35%.
4. Image Optimization with NuxtImg and WebP Format
My images were beautiful... and huge. Switching to the Nuxt Image module was an easy win.
<template>
<NuxtImg src="/images/profile.jpg" format="webp" width="400" height="400" alt="Profile picture" />
</template>
- The module automatically serves optimized formats like WebP and AVIF.
- It also lazy-loads below-the-fold images.
- This single change improved my Largest Contentful Paint by nearly 1 full second.
5. Client-Side Data Fetching with useLazyFetch()
For my dynamic project data (fetched from a JSON API), I switched from useFetch()
to useLazyFetch()
with client-only fetching. This prevented unnecessary server rendering on each request.
<script setup lang="ts">
const { data: projects } = useLazyFetch('/api/projects', { server: false })
</script>
This ensured that static pages were prebuilt, and dynamic data fetched only on the client side, keeping build times short and initial page loads quick.
6. Manual Code Splitting with Vite Rollup Options
Even after tree-shaking, some libraries remained large. Vite’s Rollup options helped me manually split bundles.
// vite.config.ts
export default defineConfig({
build: {
rollupOptions: {
output: {
manualChunks: {
vue: ['vue', 'vue-router'],
vendor: ['axios', 'lodash']
}
}
}
}
})
This helped make caching smarter: vendor code rarely changes and can be reused for longer periods.
7. Custom Composables for Performance
Reusable composables helped centralize optimization logic. For instance, I created a composable for debounced window resize:
// composables/useDebouncedResize.ts
import { ref, onMounted, onUnmounted } from 'vue'
import debounce from 'lodash/debounce'
export function useDebouncedResize(delay = 200) {
const width = ref(window.innerWidth)
const update = debounce(() => {
width.value = window.innerWidth
}, delay)
onMounted(() => window.addEventListener('resize', update))
onUnmounted(() => window.removeEventListener('resize', update))
return { width }
}
Using this composable instead of creating multiple resize
listeners across components reduced redundant listeners and lowered CPU usage.
Results & Metrics
After implementing all these optimizations, I re-ran the Lighthouse and WebPageTest audits.
| Metric | Before | After |
||||
| Load Time | 4.5s | 1.4s |
| First Contentful Paint | 3.9s | 1.2s |
| Time to Interactive | 4.5s | 1.4s |
| Bundle Size | ~830KB | 280KB |
That’s a solid 3x performance improvement. The site now feels instant, even on mobile 4G.
Key Takeaways
- Know when to use SSG over SSR. Static pages should stay static.
- Lazy-load everything non-critical to your First Paint.
- Avoid full dependency imports; tree-shake effectively.
- Optimize assets early. The Nuxt Image module is your best friend.
- Measure as you go. Fixing blindly can lead to diminishing returns.
Conclusion
Optimizing performance in Nuxt 3 isn’t just about faster load times—it’s about improving the developer experience and giving visitors a smoother feel. Every millisecond counts, especially for a personal portfolio that makes that crucial first impression.
If you’ve built a Nuxt 3 site and haven’t checked its performance yet—now’s the time. You might be surprised by how much difference a weekend of careful optimization can make.
Top comments (0)