Built byPhoenix

© 2026 Phoenix

← Blog
Next.jsPerformanceWeb VitalsReactOptimizationFrontend

Why Your Next.js App Is Slow (And How to Fix It)

Phoenix·January 20, 2026·20 min read

Why Your Next.js App Is Slow (And How to Fix It)

Next.js gives you a fast app by default. Server Components, automatic code splitting, image optimization, font loading — it's all built in. So if your Next.js app is slow, you're actively fighting the framework.

Here are the most common performance killers I've seen in production Next.js apps, with real fixes for each.


1. You're Shipping Too Much JavaScript

The Problem

Every 'use client' component and its entire dependency tree ships to the browser. One careless import can add hundreds of kilobytes to your bundle.

typescript
// This imports the ENTIRE lodash library (~70KB gzipped)import { debounce } from 'lodash'// This imports the ENTIRE date-fns libraryimport { format } from 'date-fns'// This imports a massive charting library into the initial bundleimport { Chart } from 'chart.js'

The Fix

Analyze your bundle first. You can't fix what you can't measure:

bash
# Add the bundle analyzerpnpm add @next/bundle-analyzer# next.config.tsimport withBundleAnalyzer from '@next/bundle-analyzer'const config = withBundleAnalyzer({  enabled: process.env.ANALYZE === 'true',})({  // your config})# Run itANALYZE=true pnpm build

Use specific imports:

typescript
// Before: 70KBimport { debounce } from 'lodash'// After: 1KBimport debounce from 'lodash/debounce'

Dynamic imports for heavy components:

typescript
import dynamic from 'next/dynamic'// Only loads when the component is renderedconst Chart = dynamic(() => import('./Chart'), {  loading: () => <ChartSkeleton />,  ssr: false, // Don't render on the server if it's client-only})

Keep components server-side by default. Only add 'use client' when you need interactivity (useState, useEffect, onClick, etc.). Server Components ship zero JavaScript.


2. Your Images Are Unoptimized

The Problem

Raw images are the single biggest payload on most websites. A single unoptimized hero image can be 2-5MB.

tsx
// DON'T: Raw img tag, full-size image, no lazy loading<img src="/hero.png" />// DON'T: Next Image without dimensions (causes layout shift)<Image src="/hero.png" alt="Hero" />

The Fix

tsx
import Image from 'next/image'// DO: Proper Next.js Image with dimensions<Image  src="/hero.png"  alt="Hero image"  width={1200}  height={630}  priority          // Only for above-the-fold images  placeholder="blur" // Show blur while loading/>// DO: Fill mode for responsive images<div className="relative aspect-video">  <Image    src="/hero.png"    alt="Hero"    fill    sizes="(max-width: 768px) 100vw, 50vw"    className="object-cover"  /></div>

Key rules:

  • Use priority only for the Largest Contentful Paint (LCP) image
  • Always provide sizes so the browser picks the right image size
  • Use placeholder="blur" for perceived performance
  • Let Next.js handle format conversion (WebP/AVIF)

3. Your Fonts Cause Layout Shift

The Problem

Custom fonts load asynchronously. If not handled properly, text renders in a fallback font, then "jumps" when the custom font loads. This is called FOUT (Flash of Unstyled Text) and kills your CLS (Cumulative Layout Shift) score.

The Fix

typescript
// app/layout.tsximport { Inter } from 'next/font/google'const inter = Inter({  subsets: ['latin'],  display: 'swap',       // Show fallback immediately, swap when loaded  variable: '--font-inter',})export default function Layout({ children }) {  return (    <html className={inter.variable}>      <body>{children}</body>    </html>  )}

next/font automatically:

  • Self-hosts the font (no external requests to Google Fonts)
  • Generates size-adjusted fallback fonts (minimizing layout shift)
  • Preloads the font file

Never load fonts with <link> tags. Always use next/font.


4. You're Not Streaming

The Problem

Without streaming, the entire page waits for the slowest data fetch before anything renders:

typescript
// This page won't render until BOTH fetches completeexport default async function Page() {  const user = await getUser()         // 200ms  const analytics = await getAnalytics() // 2000ms  // User waits 2200ms before seeing anything  return (    <div>      <UserProfile user={user} />      <AnalyticsDashboard data={analytics} />    </div>  )}

The Fix

Use Suspense to stream parts of the page independently:

typescript
import { Suspense } from 'react'export default async function Page() {  const user = await getUser() // 200ms — fast, load eagerly  return (    <div>      <UserProfile user={user} />      {/* Analytics streams in when ready */}      <Suspense fallback={<AnalyticsSkeleton />}>        <AnalyticsDashboard />      </Suspense>    </div>  )}// This component fetches its own dataasync function AnalyticsDashboard() {  const data = await getAnalytics() // 2000ms — slow, but streamed  return <Dashboard data={data} />}

Now the user sees the profile in 200ms. Analytics streams in 2 seconds later with a skeleton placeholder in between.


5. You're Over-Fetching Data

The Problem

Fetching data on every request when it doesn't change often:

typescript
// This fetches from the API on EVERY requestexport const dynamic = 'force-dynamic'export default async function BlogPage() {  const posts = await fetch('https://api.example.com/posts')  return <BlogList posts={posts} />}

The Fix

Use appropriate caching strategies:

typescript
// Static — fetched at build time, cached foreverexport default async function BlogPage() {  const posts = await fetch('https://api.example.com/posts')  return <BlogList posts={posts} />}// ISR — revalidate every hourexport const revalidate = 3600export default async function BlogPage() {  const posts = await fetch('https://api.example.com/posts')  return <BlogList posts={posts} />}// Per-fetch cachingconst posts = await fetch('https://api.example.com/posts', {  next: { revalidate: 3600 },})

6. Your Third-Party Scripts Block Rendering

The Problem

Analytics, chat widgets, and tracking pixels can block the main thread:

tsx
// DON'T: Scripts in <head> block rendering<script src="https://analytics.example.com/script.js" />

The Fix

tsx
import Script from 'next/script'// Load after the page is interactive<Script  src="https://analytics.example.com/script.js"  strategy="afterInteractive"/>// Load when the browser is idle<Script  src="https://chat-widget.example.com/widget.js"  strategy="lazyOnload"/>

7. You're Not Using Parallel Data Fetching

The Problem

Sequential fetches when the data is independent:

typescript
// Sequential — total time: 200ms + 300ms + 150ms = 650msconst user = await getUser()const posts = await getPosts()const comments = await getComments()

The Fix

typescript
// Parallel — total time: max(200ms, 300ms, 150ms) = 300msconst [user, posts, comments] = await Promise.all([  getUser(),  getPosts(),  getComments(),])

This alone can cut your page load time in half.


The Performance Checklist

Before shipping, verify:

  • Bundle analyzer shows no unexpected large dependencies
  • No 'use client' on components that don't need interactivity
  • Images use next/image with proper sizes and priority
  • Fonts use next/font (no external <link> tags)
  • Slow data fetches wrapped in <Suspense>
  • Independent fetches use Promise.all
  • Third-party scripts use next/script with proper strategy
  • Static pages use ISR or SSG (not force-dynamic)
  • No barrel file re-exports pulling in unused code

Wrapping Up

Next.js performance isn't about clever tricks. It's about not fighting the framework:

  1. Ship less JavaScript — Server Components by default, dynamic imports for heavy stuff
  2. Optimize images — Use next/image, always
  3. Fix fonts — Use next/font, always
  4. Stream content — Suspense boundaries around slow data
  5. Cache aggressively — ISR over force-dynamic when possible
  6. Parallelize — Promise.all for independent fetches
  7. Defer scripts — next/script with afterInteractive or lazyOnload

Measure first. Fix the biggest bottleneck. Repeat.


Further Reading

← All postsShare on X
// This imports the ENTIRE lodash library (~70KB gzipped)import { debounce } from 'lodash'// This imports the ENTIRE date-fns libraryimport { format } from 'date-fns'// This imports a massive charting library into the initial bundleimport { Chart } from 'chart.js'
# Add the bundle analyzerpnpm add @next/bundle-analyzer# next.config.tsimport withBundleAnalyzer from '@next/bundle-analyzer'const config = withBundleAnalyzer({  enabled: process.env.ANALYZE === 'true',})({  // your config})# Run itANALYZE=true pnpm build
// Before: 70KBimport { debounce } from 'lodash'// After: 1KBimport debounce from 'lodash/debounce'
import dynamic from 'next/dynamic'// Only loads when the component is renderedconst Chart = dynamic(() => import('./Chart'), {  loading: () => <ChartSkeleton />,  ssr: false, // Don't render on the server if it's client-only})
// DON'T: Raw img tag, full-size image, no lazy loading<img src="/hero.png" />// DON'T: Next Image without dimensions (causes layout shift)<Image src="/hero.png" alt="Hero" />
import Image from 'next/image'// DO: Proper Next.js Image with dimensions<Image  src="/hero.png"  alt="Hero image"  width={1200}  height={630}  priority          // Only for above-the-fold images  placeholder="blur" // Show blur while loading/>// DO: Fill mode for responsive images<div className="relative aspect-video">  <Image    src="/hero.png"    alt="Hero"    fill    sizes="(max-width: 768px) 100vw, 50vw"    className="object-cover"  /></div>
// app/layout.tsximport { Inter } from 'next/font/google'const inter = Inter({  subsets: ['latin'],  display: 'swap',       // Show fallback immediately, swap when loaded  variable: '--font-inter',})export default function Layout({ children }) {  return (    <html className={inter.variable}>      <body>{children}</body>    </html>  )}
// This page won't render until BOTH fetches completeexport default async function Page() {  const user = await getUser()         // 200ms  const analytics = await getAnalytics() // 2000ms  // User waits 2200ms before seeing anything  return (    <div>      <UserProfile user={user} />      <AnalyticsDashboard data={analytics} />    </div>  )}
import { Suspense } from 'react'export default async function Page() {  const user = await getUser() // 200ms — fast, load eagerly  return (    <div>      <UserProfile user={user} />      {/* Analytics streams in when ready */}      <Suspense fallback={<AnalyticsSkeleton />}>        <AnalyticsDashboard />      </Suspense>    </div>  )}// This component fetches its own dataasync function AnalyticsDashboard() {  const data = await getAnalytics() // 2000ms — slow, but streamed  return <Dashboard data={data} />}
// This fetches from the API on EVERY requestexport const dynamic = 'force-dynamic'export default async function BlogPage() {  const posts = await fetch('https://api.example.com/posts')  return <BlogList posts={posts} />}
// Static — fetched at build time, cached foreverexport default async function BlogPage() {  const posts = await fetch('https://api.example.com/posts')  return <BlogList posts={posts} />}// ISR — revalidate every hourexport const revalidate = 3600export default async function BlogPage() {  const posts = await fetch('https://api.example.com/posts')  return <BlogList posts={posts} />}// Per-fetch cachingconst posts = await fetch('https://api.example.com/posts', {  next: { revalidate: 3600 },})
// DON'T: Scripts in <head> block rendering<script src="https://analytics.example.com/script.js" />
import Script from 'next/script'// Load after the page is interactive<Script  src="https://analytics.example.com/script.js"  strategy="afterInteractive"/>// Load when the browser is idle<Script  src="https://chat-widget.example.com/widget.js"  strategy="lazyOnload"/>
// Sequential — total time: 200ms + 300ms + 150ms = 650msconst user = await getUser()const posts = await getPosts()const comments = await getComments()
// Parallel — total time: max(200ms, 300ms, 150ms) = 300msconst [user, posts, comments] = await Promise.all([  getUser(),  getPosts(),  getComments(),])