Web Development

How I Fixed a Slow Next.js App: Lessons from Production

January 15, 2024
8 min read
By Saad Minhas
Next.jsPerformanceOptimizationReact
How I Fixed a Slow Next.js App: Lessons from Production

Last year, I was working on a Next.js application at TheHexatown that was performing well in development but struggling in production. Users were complaining about slow load times, and our Lighthouse scores were embarrassingly low. This is the story of how I fixed it.


The Problem


The app was an ERP system built with Next.js, TypeScript, and MongoDB. In development, everything felt snappy. But once deployed, the first page load took 8-10 seconds. That's unacceptable for a business application.


I started by checking the obvious things—bundle size, image optimization, API response times. But the real culprits weren't what I expected.


The First Surprise: Image Loading


We were using regular tags throughout the app. I thought switching to Next.js Image component would be a quick win. It wasn't.


The problem? We had hundreds of product images, and I was loading them all at full resolution. Even with Next.js optimization, requesting 50+ images on page load killed performance.


What I did: I implemented lazy loading with proper sizes attributes and used priority only for above-the-fold images. But more importantly, I realized we were loading images that users might never see.


// Before: Loading everything
{products.map(product => (
  <img src={product.image} alt={product.name} />
))}

// After: Smart loading
{products.map(product => (
  <Image
    src={product.image}
    alt={product.name}
    width={400}
    height={400}
    loading="lazy"
    sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
  />
))}

This alone cut our initial load time by 40%. But there was more.


The Database Query Problem


Our MongoDB queries were inefficient. We were fetching entire documents when we only needed specific fields, and we weren't using indexes properly.


I spent a day analyzing our queries with MongoDB's explain plan. The results were eye-opening. One query was scanning 50,000 documents to return 10 results.


The fix: Proper indexing and projection. Instead of fetching full documents, I projected only the fields we needed:


// Before
const products = await Product.find({ category: 'electronics' });

// After
const products = await Product.find(
  { category: 'electronics' },
  { name: 1, price: 1, image: 1, _id: 1 }
).hint({ category: 1 });

We also added compound indexes for our most common query patterns. This reduced query time from 800ms to 50ms.


Code Splitting: The Hidden Cost


I thought Next.js handled code splitting automatically. It does, but not always optimally.


We had a massive dashboard component that imported everything—charts, tables, forms, modals. Even though users only saw one section at a time, we were loading it all upfront.


Solution: Dynamic imports for heavy components:


const DashboardCharts = dynamic(() => import('./DashboardCharts'), {
  loading: () => <ChartSkeleton />,
  ssr: false
});

const DataTable = dynamic(() => import('./DataTable'), {
  loading: () => <TableSkeleton />
});

This reduced our initial JavaScript bundle from 2.1MB to 800KB. The remaining code loads on-demand when users navigate to those sections.


Static vs Dynamic: A Costly Mistake


We were using getServerSideProps for pages that didn't need fresh data on every request. Things like product listings, blog posts, and documentation pages were being rendered on every request.


Switching to getStaticProps with ISR (Incremental Static Regeneration) was a game-changer:


export async function getStaticProps() {
  const products = await fetchProducts();
  
  return {
    props: { products },
    revalidate: 3600 // Revalidate every hour
  };
}

Pages that took 2-3 seconds to render now load instantly. We went from server-side rendering everything to static generation with smart revalidation.


The Real Bottleneck: Third-Party Scripts


Here's something I didn't expect—third-party analytics and tracking scripts were adding 2-3 seconds to our load time. We had Google Analytics, Facebook Pixel, and a few other scripts loading synchronously.


Fix: Load them asynchronously and defer non-critical scripts:


// In _document.tsx
<Script
  src="https://www.googletagmanager.com/gtag/js"
  strategy="afterInteractive"
/>

This moved analytics loading off the critical path without losing tracking functionality.


Results


After implementing these changes:

  • Initial load time: 8-10 seconds → 1.5-2 seconds
  • Time to Interactive: 12 seconds → 3 seconds
  • Lighthouse Performance: 45 → 92
  • Bundle size: 2.1MB → 800KB initial load

What I Learned


Performance optimization isn't about one big fix. It's about finding and fixing dozens of small inefficiencies. The biggest wins came from things I didn't expect—database queries, third-party scripts, and how we were loading images.


The key is to measure everything. I used Lighthouse, Chrome DevTools, and Next.js Analytics to identify bottlenecks. You can't fix what you don't measure.


If you're dealing with a slow Next.js app, start with the basics: images, database queries, and bundle size. But don't ignore the small things—they add up quickly.

Get In Touch

Connect

Full Stack Software Engineer passionate about building innovative web and mobile applications.

© 2025 Saad Minhas. All Rights Reserved.