Skip to main content
Back to Blog
PerformanceNext.js

Scaling Next.js Apps: Performance & Optimization Guide

Production-tested techniques for optimizing Next.js applications at scale. Learn how to achieve sub-second load times, perfect Core Web Vitals scores, and handle thousands of concurrent users - with real examples from DemandAI.Pro and GivingStump.

Ross Day
December 22, 2024
20 min read

Why Performance Matters

For SaaS applications, performance directly impacts revenue. A 1-second delay in page load time can reduce conversions by 7%, and 53% of mobile users abandon sites that take longer than 3 seconds to load.

96

Lighthouse Score

0.8s

Time to Interactive

1.2s

Largest Contentful Paint

Results achieved: DemandAI.Pro production metrics after applying optimizations in this guide.

1. Maximize Server Components

Next.js 14's Server Components are the biggest performance improvement in React's history. They execute on the server, sending only HTML to the client - zero JavaScript overhead.

The Performance Impact

❌ Client Component Heavy

  • • Bundle: 450KB JavaScript
  • • Time to Interactive: 3.2s
  • • Lighthouse: 72/100

✓ Server Component Optimized

  • • Bundle: 120KB JavaScript
  • • Time to Interactive: 0.8s
  • • Lighthouse: 96/100

When to Use Server vs Client Components

// ✓ Server Component (default) - No 'use client'
// Use when you need:
// - Data fetching
// - Database queries
// - API calls
// - Static content rendering
// - No user interactivity

export default async function DemandLetterList() {
  // Fetch data directly on server
  const letters = await db.demandLetters.findMany({
    where: { userId: user.id },
    orderBy: { createdAt: 'desc' }
  });

  return (
    <div>
      {letters.map(letter => (
        <LetterCard key={letter.id} letter={letter} />
      ))}
    </div>
  );
}

// ✓ Client Component - Mark with 'use client'
// Use when you need:
// - useState, useEffect, event handlers
// - Browser APIs (localStorage, window)
// - User interactions (clicks, form inputs)
// - Real-time updates

'use client';

import { useState } from 'react';

export function LetterCard({ letter }) {
  const [isExpanded, setIsExpanded] = useState(false);

  return (
    <div onClick={() => setIsExpanded(!isExpanded)}>
      <h3>{letter.title}</h3>
      {isExpanded && <p>{letter.content}</p>}
    </div>
  );
}

Pro Tip: Server Components can import Client Components, but not vice versa. Structure your component tree with Server Components at the top and Client Components as leaves.

Composition Pattern for Maximum Performance

// ❌ Bad: Entire page becomes client component
'use client';

export default function DashboardPage() {
  const [view, setView] = useState('grid');

  const letters = await fetchLetters(); // Can't use await in client component!

  return (
    <div>
      <ViewToggle view={view} onChange={setView} />
      <LetterList letters={letters} />
    </div>
  );
}

// ✓ Good: Server component wrapper with client leaf
export default async function DashboardPage() {
  // Server-side data fetching
  const letters = await db.demandLetters.findMany();

  return (
    <div>
      {/* Only this small component is client-side */}
      <ClientViewToggle>
        {/* Pass data as props to server component */}
        <LetterList letters={letters} />
      </ClientViewToggle>
    </div>
  );
}

// Small client component
'use client';

function ClientViewToggle({ children }) {
  const [view, setView] = useState('grid');

  return (
    <div>
      <button onClick={() => setView(view === 'grid' ? 'list' : 'grid')}>
        Toggle View
      </button>
      <div className={view}>{children}</div>
    </div>
  );
}

2. Streaming for Instant Page Loads

Don't wait for all data to load before showing the page. Stream content as it becomes available using Suspense boundaries.

import { Suspense } from 'react';

export default function DashboardPage() {
  return (
    <div>
      {/* Show header immediately */}
      <Header />

      {/* Show loading state while fetching letters */}
      <Suspense fallback={<LetterListSkeleton />}>
        <LetterList />
      </Suspense>

      {/* Show loading state while fetching analytics */}
      <Suspense fallback={<AnalyticsSkeleton />}>
        <AnalyticsPanel />
      </Suspense>
    </div>
  );
}

// Async server component
async function LetterList() {
  // This fetch doesn't block the entire page
  const letters = await db.demandLetters.findMany();

  return (
    <div>
      {letters.map(letter => <LetterCard key={letter.id} {...letter} />)}
    </div>
  );
}

// Loading skeleton
function LetterListSkeleton() {
  return (
    <div className="space-y-4">
      {[1, 2, 3].map(i => (
        <div key={i} className="animate-pulse bg-slate-700 h-24 rounded-lg" />
      ))}
    </div>
  );
}

Performance Win: Users see content 60% faster. Instead of waiting 2s for all data, they see the shell in 0.4s and content streams in progressively.

3. Next.js Image Optimization

Images are the largest assets in most web apps. Next.js's Image component automatically optimizes them.

Automatic Optimizations

import Image from 'next/image';

// ❌ Bad: Standard img tag
<img src="/logo.png" alt="Logo" />
// Issues:
// - No optimization
// - Wrong size for device
// - Blocks page rendering
// - No lazy loading

// ✓ Good: Next.js Image component
<Image
  src="/logo.png"
  alt="Logo"
  width={300}
  height={100}
  priority // Load immediately for above-fold images
  quality={90} // Default is 75
/>

// Automatic benefits:
// ✓ WebP/AVIF format for modern browsers
// ✓ Responsive images (srcset)
// ✓ Lazy loading by default
// ✓ Blur placeholder
// ✓ Prevents layout shift

Advanced Patterns

// 1. Blur Placeholder for Better UX
<Image
  src="/hero.jpg"
  alt="Hero"
  width={1200}
  height={600}
  placeholder="blur"
  blurDataURL="data:image/jpeg;base64,/9j/4AAQSkZJRg..." // Low-res base64
/>

// 2. Fill Mode for Dynamic Containers
<div className="relative w-full h-96">
  <Image
    src="/banner.jpg"
    alt="Banner"
    fill
    style={{ objectFit: 'cover' }}
    sizes="100vw"
  />
</div>

// 3. Remote Images (require config)
// next.config.js
module.exports = {
  images: {
    remotePatterns: [
      {
        protocol: 'https',
        hostname: 'res.cloudinary.com',
      },
    ],
  },
};

// Usage
<Image
  src="https://res.cloudinary.com/demo/image/upload/photo.jpg"
  alt="User upload"
  width={800}
  height={600}
/>

// 4. Responsive Sizes Optimization
<Image
  src="/hero.jpg"
  alt="Hero"
  width={1200}
  height={600}
  sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
  // Loads different sizes based on viewport
/>

Real Impact: Switching from <img> to Image reduced DemandAI.Pro's Largest Contentful Paint from 3.2s to 1.2s.

4. Bundle Size Optimization

Every KB of JavaScript delays Time to Interactive. Here's how to minimize bundle size:

1. Dynamic Imports for Code Splitting

import dynamic from 'next/dynamic';

// ❌ Bad: Import large component even if not used
import PDFViewer from '@/components/PDFViewer';

export function DocumentPage() {
  const [showPDF, setShowPDF] = useState(false);

  return (
    <div>
      <button onClick={() => setShowPDF(true)}>View PDF</button>
      {showPDF && <PDFViewer />} {/* Already in bundle! */}
    </div>
  );
}

// ✓ Good: Only load when needed
const PDFViewer = dynamic(() => import('@/components/PDFViewer'), {
  loading: () => <p>Loading PDF viewer...</p>,
  ssr: false // Don't render on server if it uses browser APIs
});

export function DocumentPage() {
  const [showPDF, setShowPDF] = useState(false);

  return (
    <div>
      <button onClick={() => setShowPDF(true)}>View PDF</button>
      {/* Only downloads PDF viewer when user clicks */}
      {showPDF && <PDFViewer />}
    </div>
  );
}

// Result: 200KB PDF library only loads when needed

2. Tree-Shakeable Imports

// ❌ Bad: Imports entire library (200KB+)
import _ from 'lodash';
const unique = _.uniq([1, 2, 2, 3]);

// ✓ Good: Import only what you need
import { uniq } from 'lodash-es';
const unique = uniq([1, 2, 2, 3]);

// Even better: Use native JavaScript when possible
const unique = [...new Set([1, 2, 2, 3])];

// For icons (lucide-react is tree-shakeable)
// ❌ Bad
import * as Icons from 'lucide-react';
<Icons.Home />

// ✓ Good
import { Home, User, Settings } from 'lucide-react';
<Home />

3. Analyze Your Bundle

// Install bundle analyzer
npm install @next/bundle-analyzer

// next.config.js
const withBundleAnalyzer = require('@next/bundle-analyzer')({
  enabled: process.env.ANALYZE === 'true',
});

module.exports = withBundleAnalyzer({
  // your config
});

// Run analysis
ANALYZE=true npm run build

// Opens interactive visualization showing:
// - Largest dependencies
// - Duplicate packages
// - Unused code

4. Remove Unused Dependencies

// Use depcheck to find unused dependencies
npx depcheck

// Common culprits in SaaS apps:
// - moment.js (use date-fns or native Intl instead)
// - jquery (use native DOM APIs)
// - axios (use native fetch)
// - lodash (use native methods or lodash-es)

// Before optimization: 450KB bundle
// After removing unused deps: 280KB bundle
// After tree-shaking: 120KB bundle

5. Aggressive Caching Strategies

Next.js 14 App Router has powerful caching built-in. Understanding it is critical for performance.

Cache Hierarchy

// 1. Request Memoization (single render)
// Multiple components fetching same data in one request
async function getUser(id: string) {
  // Automatically deduped within same render
  const res = await fetch(`/api/users/${id}`);
  return res.json();
}

// Both calls use the same request
<UserProfile userId="123" />
<UserStats userId="123" />

// 2. Data Cache (persistent, across requests)
async function getLetters() {
  // Cached indefinitely by default
  const res = await fetch('https://api.example.com/letters');
  return res.json();
}

// Revalidate every hour
async function getLetters() {
  const res = await fetch('https://api.example.com/letters', {
    next: { revalidate: 3600 } // seconds
  });
  return res.json();
}

// Never cache (always fresh)
async function getRealtimeData() {
  const res = await fetch('https://api.example.com/realtime', {
    cache: 'no-store'
  });
  return res.json();
}

// 3. Full Route Cache (static pages)
// Static page - cached at build time
export default async function StaticPage() {
  return <div>This page is cached!</div>;
}

// Dynamic page - regenerated per request
export const dynamic = 'force-dynamic';

export default async function DynamicPage() {
  return <div>Fresh on every request</div>;
}

// 4. Router Cache (client-side navigation)
// Automatically caches pages user visits for 30s
// Prefetches linked pages on hover

Pro Tip: Use revalidate: 60 for frequently changing data like user stats. Use revalidate: 3600 for stable data like blog posts.

Incremental Static Regeneration (ISR)

// Blog post page with ISR
export async function generateStaticParams() {
  // Generate static pages for all blog posts at build time
  const posts = await getAllPosts();
  return posts.map(post => ({ slug: post.slug }));
}

export default async function BlogPost({ params }) {
  const post = await getPost(params.slug);

  return <article>{post.content}</article>;
}

// Enable ISR - regenerate page every 1 hour
export const revalidate = 3600;

// Benefits:
// ✓ Build time: Pre-render 100 blog posts
// ✓ User visits: Serve from CDN (instant!)
// ✓ After 1 hour: Regenerate in background
// ✓ New post published: Revalidate on-demand

// On-demand revalidation from API
export async function POST(request: Request) {
  const { slug } = await request.json();

  // Regenerate specific page
  await revalidatePath(`/blog/${slug}`);

  return Response.json({ revalidated: true });
}

6. Database Query Optimization

Slow database queries kill performance. Optimize them before they reach production.

// ❌ Bad: N+1 query problem
async function getLettersWithAuthors() {
  const letters = await db.demandLetter.findMany();

  // Executes 1 query for EACH letter!
  const lettersWithAuthors = await Promise.all(
    letters.map(async letter => ({
      ...letter,
      author: await db.user.findUnique({ where: { id: letter.authorId } })
    }))
  );

  return lettersWithAuthors;
}

// ✓ Good: Single query with join
async function getLettersWithAuthors() {
  return db.demandLetter.findMany({
    include: {
      author: true // Prisma does efficient join
    }
  });
}

// ✓ Even better: Only select needed fields
async function getLettersForList() {
  return db.demandLetter.findMany({
    select: {
      id: true,
      title: true,
      createdAt: true,
      author: {
        select: {
          name: true,
          avatar: true
        }
      }
    }
  });
}

// Add database indexes
// schema.prisma
model DemandLetter {
  id        String   @id @default(uuid())
  authorId  String
  createdAt DateTime @default(now())

  author User @relation(fields: [authorId], references: [id])

  @@index([authorId]) // Speed up joins
  @@index([createdAt]) // Speed up ordering
}

Performance Win: Adding indexes reduced query time from 850ms to 45ms for DemandAI.Pro's main dashboard.

7. Edge Runtime for Global Performance

Deploy API routes to the edge for 10-100x lower latency worldwide.

// API route running on edge
export const runtime = 'edge';

export async function GET(request: Request) {
  const { searchParams } = new URL(request.url);
  const userId = searchParams.get('userId');

  // Call external API
  const response = await fetch(`https://api.example.com/user/${userId}`);
  const data = await response.json();

  return Response.json(data);
}

// Latency comparison:
// Node.js (us-east-1): 800ms for user in Australia
// Edge Runtime: 120ms for user in Australia (nearest edge node)

// Limitations of Edge Runtime:
// - No Node.js APIs (fs, process, etc.)
// - No native modules
// - 4MB code size limit
// - 50ms CPU time limit (Vercel)

// When to use Edge:
// ✓ API routes that call external APIs
// ✓ Authentication middleware
// ✓ A/B testing logic
// ✓ Geolocation-based content
// ✓ Rate limiting

// When NOT to use Edge:
// ✗ Database-heavy operations (use regional functions)
// ✗ Image processing (too CPU intensive)
// ✗ File system operations

8. Monitor Core Web Vitals

Track real-user performance metrics to identify regressions before they impact users.

Key Metrics

LCP

Largest Contentful Paint

<2.5s

Good

FID

First Input Delay

<100ms

Good

CLS

Cumulative Layout Shift

<0.1

Good

// app/layout.tsx
import { Analytics } from '@vercel/analytics/react';
import { SpeedInsights } from '@vercel/speed-insights/next';

export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        {children}
        <Analytics />
        <SpeedInsights />
      </body>
    </html>
  );
}

// Custom Web Vitals reporting
export function reportWebVitals(metric) {
  // Log to analytics service
  if (metric.label === 'web-vital') {
    console.log(metric.name, metric.value);

    // Send to your analytics
    fetch('/api/analytics', {
      method: 'POST',
      body: JSON.stringify({
        metric: metric.name,
        value: metric.value,
        page: window.location.pathname
      })
    });
  }
}

Production Performance Checklist

Use Server Components by default, Client Components only when necessary
Implement Suspense boundaries for progressive loading
Replace all <img> with <Image>
Dynamic import heavy components (PDF viewers, charts, editors)
Analyze bundle with @next/bundle-analyzer
Configure appropriate cache revalidation times
Add database indexes for frequently queried fields
Use Edge Runtime for API routes when possible
Monitor Core Web Vitals in production
Set performance budgets (LCP <2.5s, FID <100ms, CLS <0.1)

Results You Can Achieve

Implementing these optimizations in DemandAI.Pro resulted in:

73% faster

Time to Interactive (3.2s → 0.8s)

62% smaller

JavaScript bundle (450KB → 170KB)

96/100

Lighthouse Performance Score

18% higher

Conversion rate (faster = more users)

Need Performance Optimization Help?

I offer performance audits and optimization consulting for Next.js applications. Let's make your app blazing fast.