Back to Engineering Guides

Next.js App Router Performance: From 3.2s to 0.9s First Contentful Paint

Technical Insight
Published October 2, 2025
Next.js App Router Performance: From 3.2s to 0.9s First Contentful Paint

A B2B SaaS dashboard we engineered was sitting at 3.2s First Contentful Paint and a Lighthouse Performance score of 54 on mobile. After migrating to Next.js 14 App Router and applying a focused set of optimizations, we hit 0.9s FCP and a score of 91. Here's the exact playbook.

Baseline Audit: What Was Slow

Before optimizing, we profiled with Chrome DevTools and Lighthouse to identify the specific bottlenecks:

  • Large JavaScript bundle: 1.4MB of JS sent on initial load, including libraries only used on certain pages
  • Client-side data fetching: Every component fetched its own data after hydration, creating a waterfall of API calls
  • Unoptimized images: PNG assets served at 3x the rendered size, no lazy loading
  • No caching strategy: Every page hit the database on every request
  • Render-blocking fonts: Custom fonts loaded synchronously

Step 1: Migrate Data Fetching to Server Components

The biggest single win came from moving data fetching out of the client. In the Pages Router, every page fetched data after the initial JS loaded. In App Router, Server Components fetch during the render on the server — the browser receives fully populated HTML:

// Before (Pages Router): API call happens in the browser
export default function Dashboard() {
  const { data, isLoading } = useQuery(['metrics'], fetchMetrics);
  if (isLoading) return <Skeleton />;
  return <MetricCards data={data} />;
}

// After (App Router): Data is fetched server-side
export default async function Dashboard() {
  const metrics = await fetchMetrics(); // runs on the server
  return <MetricCards data={metrics} />;
}

This eliminated the skeleton-flash (layout shift) and removed an entire round-trip from the critical path. FCP improved from 3.2s to 1.8s from this change alone.

Step 2: Implement Granular Caching with fetch()

Next.js extends the native fetch API with caching semantics. We categorized our data by freshness requirements:

// Static data — cached indefinitely, revalidated on deploy
const config = await fetch('/api/config', { cache: 'force-cache' });

// Semi-fresh — revalidate every 60 seconds (ISR)
const posts = await fetch('/api/posts', { next: { revalidate: 60 } });

// Always fresh — bypasses cache (e.g., user-specific data)
const orders = await fetch('/api/orders', { cache: 'no-store' });

For database queries that don't go through fetch, we used unstable_cache:

import { unstable_cache } from 'next/cache';

const getCachedProducts = unstable_cache(
  async (categoryId: string) => db.product.findMany({ where: { categoryId } }),
  ['products-by-category'],
  { revalidate: 300, tags: ['products'] }
);

Step 3: Streaming with Suspense

Not all data is equally fast to fetch. Streaming lets you send the shell of the page immediately and stream in slow sections as they resolve:

import { Suspense } from 'react';

export default function Page() {
  return (
    <div>
      <HeroSection />  {/* renders instantly */}
      <Suspense fallback={<MetricsSkeleton />}>
        <SlowMetricsComponent />  {/* streams in when ready */}
      </Suspense>
      <Suspense fallback={<ChartSkeleton />}>
        <AnalyticsChart />
      </Suspense>
    </div>
  );
}

The browser can render and paint the hero section before the slow database queries finish. TTFB stays low while slow queries resolve independently.

Step 4: Aggressive Bundle Splitting

We audited our client components with @next/bundle-analyzer and found several heavy libraries loaded unconditionally:

  • A charting library (320KB gzipped) was imported globally but only used on one page
  • A rich-text editor (180KB) was bundled into the main chunk
  • Date manipulation library (40KB) when native Intl would suffice

Fixes:

// Dynamic import with ssr:false for heavy client-only libraries
const AnalyticsChart = dynamic(
  () => import('@/components/AnalyticsChart'),
  { loading: () => <ChartSkeleton />, ssr: false }
);

// Replace heavy date library with native Intl
// Before: import { format } from 'date-fns';
// After:
const formatDate = (d: Date) =>
  new Intl.DateTimeFormat('en-US', { dateStyle: 'medium' }).format(d);

Bundle size dropped from 1.4MB to 680KB — a 51% reduction.

Step 5: Image Optimization

Replace every <img> tag with Next.js <Image>. Key settings for performance:

import Image from 'next/image';

<Image
  src="/hero.jpg"
  alt="Dashboard hero"
  width={1200}
  height={600}
  priority          // preload the LCP image
  placeholder="blur"
  blurDataURL={heroBlurDataUrl}
  sizes="(max-width: 768px) 100vw, 1200px"
/>

For below-fold images, omit priority so they lazy-load. This reduced image payload by 65% through automatic WebP conversion and responsive sizing.

Step 6: Font Optimization

Self-host fonts via next/font to eliminate the render-blocking Google Fonts request:

import { Inter } from 'next/font/google';

const inter = Inter({
  subsets: ['latin'],
  display: 'swap',
  preload: true,
  variable: '--font-inter',
});

export default function RootLayout({ children }) {
  return (
    <html className={inter.variable}>
      <body>{children}</body>
    </html>
  );
}

Next.js downloads the font at build time, self-hosts it, and inlines a font-face declaration — no external DNS lookup, no render block.

Results

After applying all six steps:

  • First Contentful Paint: 3.2s → 0.9s (−72%)
  • Largest Contentful Paint: 4.1s → 1.4s (−66%)
  • Total Blocking Time: 820ms → 140ms (−83%)
  • JavaScript bundle: 1.4MB → 680KB (−51%)
  • Lighthouse Performance (mobile): 54 → 91

The single biggest lever was the Server Components migration — moving data fetching to the server eliminated client waterfalls. Bundle splitting and caching compounded on top. If you're still in the Pages Router, the App Router migration effort pays for itself within weeks on any production application.

Distribute Knowledge

#Next.js#Performance#React#Web Development