DEV Community

Cover image for How I Boosted My Nuxt 3 Portfolio Speed by 3x (4.5s 1.4s)
Ojas
Ojas

Posted on

How I Boosted My Nuxt 3 Portfolio Speed by 3x (4.5s 1.4s)

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,
    },
  },
})
Enter fullscreen mode Exit fullscreen mode
  • 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>
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode
  • 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>
Enter fullscreen mode Exit fullscreen mode

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']
        }
      }
    }
  }
})
Enter fullscreen mode Exit fullscreen mode

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 }
}
Enter fullscreen mode Exit fullscreen mode

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.

Cover image by Chris Peeters

Top comments (0)