Modern Web · Nextjs

Next.js 15 Performance Tuning

Practical performance work for Next.js 15: bundle analysis, image and font optimisation, Suspense streaming, experimental partial prerendering, and avoiding server-component fetch waterfalls.

John Kihiu12 min read

Most Next.js performance problems I've debugged aren't framework problems — they're a fetch waterfall hiding behind a Server Component, or an image that never got the next/image treatment. Next.js gives you real tools for all of this; the work is knowing which one to reach for and how to see the problem before you guess at a fix.

Start with bundle analysis, not intuition

@next/bundle-analyzer wraps your Next config and produces a treemap of what's actually in your client JS bundles after tree-shaking. It routinely surfaces the same offenders: a full moment.js or lodash import where three functions were needed, a chart library pulled into a page that only renders it behind a modal, or a client component boundary drawn so high that server-only code got bundled for the client. Run it before optimizing anything — guessing which dependency is heavy is a bad use of time next to just looking.

TS · next.config.ts
import bundleAnalyzer from '@next/bundle-analyzer';

const withAnalyzer = bundleAnalyzer({
  enabled: process.env.ANALYZE === 'true',
});

export default withAnalyzer({
  // ...rest of your Next.js config
});

Image and font optimization are close to free

next/image handles resizing, format conversion to WebP/AVIF, and lazy-loading below the fold automatically — the main mistake is not setting explicit width/height (or using fill with a sized parent), which is what causes layout shift even through the optimized component. next/font self-hosts Google Fonts at build time and injects the correct font-display and preload hints, which avoids both a render-blocking request to Google's CDN and the FOUT/FOIT flash — this alone is usually worth more to Core Web Vitals than most JavaScript-level tuning, because font loading is one of the more common causes of Cumulative Layout Shift on content-heavy pages.

Set sizes on next/image

Without a sizes attribute matching your actual responsive layout, the browser may download a larger image variant than it renders. Match sizes to your CSS breakpoints, not just the largest possible display width.

Streaming with Suspense turns one slow query into a fast page

Wrapping a slow data-dependent section in <Suspense> with a loading.tsx or inline fallback lets Next.js stream the rest of the page to the browser immediately and fill in the slow part when it resolves, rather than blocking the entire response on the slowest query. This is the single highest-leverage change for a page with one slow widget dragging down an otherwise-fast page — instead of the whole route waiting on a 2-second analytics query, the shell, nav, and fast content paint immediately and the analytics panel streams in after.

TSX · app/dashboard/page.tsx
import { Suspense } from 'react';
import { SlowAnalyticsPanel } from './analytics-panel';
import { FastSummary } from './fast-summary';

export default function DashboardPage() {
  return (
    <>
      <FastSummary />
      <Suspense fallback={<PanelSkeleton />}>
        {/* Streams in once its own data fetch resolves */}
        <SlowAnalyticsPanel />
      </Suspense>
    </>
  );
}

Avoiding fetch waterfalls inside server components

The subtle trap is await-ing sequential, independent fetches inside a Server Component — each one blocks the next even when they don't depend on each other. If a page needs a user, their orders, and a product catalog, and none of those depend on each other's result, kick them off together with Promise.all rather than awaiting them one at a time. This is easy to introduce accidentally when a component tree calls its own data-fetching function per component instead of at the top and passing props down, since each nested component's await only starts once its parent has already finished rendering.

Nested async components serialize by default

If component A awaits its own data, then renders component B which awaits its own data, B's fetch doesn't start until A finishes. Lift independent fetches to a common ancestor and run them concurrently, or pass already-resolved data down as props.

Partial prerendering (experimental)

Partial Prerendering lets a single route serve a static shell instantly from the CDN while dynamic, personalized segments stream in behind Suspense boundaries — combining the speed of static generation with per-user dynamic content, without you having to choose one rendering mode for the whole route. It's still experimental in the 15.x line and needs an explicit flag to enable, so it's worth prototyping on a non-critical route before betting a whole app's rendering strategy on it, but it's the clearest signal of where App Router rendering is heading.

Wrapping up

The performance wins that actually move the needle are unglamorous in order: measure the bundle before touching code, let next/image and next/font do the media optimization they're built for, stream slow sections behind Suspense instead of blocking the whole page, and audit your Server Components for sequential awaits that should be concurrent. Partial prerendering is worth watching, but it's additive to that list, not a replacement for doing the basics first.

John Kihiu
Acumatica ERP Developer · Laravel Engineer

Independent software engineer in Nairobi specialising in Acumatica customisations, Laravel backends, and tax fiscalisation integrations across East and Southern Africa.