TechieBall logoTECHIEBALLDIGITAL SOLUTIONS
Engineering

10 Next.js Performance Techniques That Actually Move the Needle

By Yogesh on LinkedIn (opens in a new tab)Last updated 21 min read
10 Next.js Performance Techniques That Actually Move the Needle

Most "Next.js performance" advice stops at "use next/image." That's a fine start, but it rarely explains why your Lighthouse score is still low, why real users on a 4G connection have a different experience than you do on office Wi-Fi, or which fix actually matters for the page that's costing you conversions.

This guide is different. It's written for developers, CTOs, and founders who want to understand why each technique works, not just copy a config snippet. We'll cover ten techniques that produce measurable gains in real Next.js applications, plus the metrics you should track, the mistakes we see most often in client audits, and how to keep performance from quietly regressing after launch.

A quick note before we start: fast pages and accessible pages have more in common than most teams realize. A page that loads quickly on a slow connection, doesn't shift content while it renders, and responds instantly to input is also easier to use for someone on assistive technology, an older device, or a shaky mobile network. Keep that in mind as we go — several of these techniques help both.

Understanding the metrics that actually matter

Before optimizing anything, you need a way to measure whether it worked. Google's Core Web Vitals are the industry-standard way to do that, and they're also a ranking factor in search.

LCP — Largest Contentful Paint

LCP measures how long it takes for the biggest visible element (usually a hero image, heading, or banner) to render. It's your best proxy for "does this page feel fast to load." Aim for under 2.5 seconds.

INP — Interaction to Next Paint

INP replaced First Input Delay (FID) as a Core Web Vital in 2024. It measures how responsive your page is across all interactions during a visit — clicks, taps, key presses — not just the first one. A slow INP usually means too much JavaScript is running on the main thread. Aim for under 200ms.

CLS — Cumulative Layout Shift

CLS measures visual stability — how much content jumps around while the page loads. It's almost always caused by images or ads without reserved dimensions, or web fonts swapping in and reflowing text. Aim for under 0.1.

TTFB — Time to First Byte

TTFB isn't a Core Web Vital, but it's the metric that determines your ceiling for LCP. If your server takes 1.5 seconds to respond, your LCP can never be faster than that, no matter how well you optimize the frontend. TTFB is affected by your rendering strategy (static vs. dynamic), your hosting region, and how much work happens before the first byte is sent.

MetricWhat it measuresGood threshold
LCPLoad speed of the main content≤ 2.5s
INPResponsiveness to interaction≤ 200ms
CLSVisual stability≤ 0.1
TTFBServer response time≤ 0.8s

Server-side rendering vs. client-side rendering

This distinction matters because it decides where your TTFB and LCP budget goes. In pure client-side rendering (CSR), the browser downloads a near-empty HTML shell, then JavaScript takes over to fetch data and render content — the user stares at a blank or loading screen until that finishes. Server-side rendering (SSR) sends fully-formed HTML on the first response, so there's something meaningful to paint immediately, even before JavaScript loads.

Next.js gives you both, plus static generation, in the same app. The technique that matters most below — using Server Components by default — is really about defaulting to server rendering and only reaching for client-side rendering where you genuinely need interactivity.

1. Optimize images with next/image

What it is

next/image is Next.js's built-in image component. It automatically serves correctly-sized, modern-format (WebP/AVIF) images, lazy-loads offscreen images, and — when configured correctly — prevents layout shift.

Why it matters

Images are usually the single largest asset on a page and the most common LCP element. Unoptimized images are also the most common cause of CLS in the audits we run, because the browser doesn't know how much space to reserve for them until they've downloaded.

How to implement it

import Image from "next/image";

export function Hero() {
  return (
    <Image
      src="/hero.jpg"
      alt="TechieBall team reviewing a Next.js application architecture"
      width={1200}
      height={630}
      priority
      sizes="(min-width: 1024px) 1024px, 100vw"
    />
  );
}
  • Always set width/height, or use fill inside a sized container — this is what stops layout shift.
  • Set a realistic sizes attribute so the browser doesn't download a desktop-sized image on a phone.
  • Use priority only on the actual LCP image (usually one per page). Marking every image priority defeats the purpose — it tells the browser to load everything eagerly, which competes for bandwidth.
  • Write a real, descriptive alt value. It costs nothing in performance and makes the image usable for screen reader users and better understood by search engines.

When it's useful

On every image-heavy page — which, in practice, is almost every marketing site, product page, or blog.

Common mistakes

  • Forgetting width/height and letting the browser guess (causes CLS).
  • Marking too many images priority, which slows down the actual LCP image by competing for bandwidth.
  • Serving a single large image size to all devices via a plain <img> tag instead of next/image.

2. Reduce JavaScript bundle size

What it is

The total amount of JavaScript the browser has to download, parse, and execute before your page is interactive.

Why it matters

Bundle size affects almost every metric: it delays TTFB indirectly (larger builds, slower deploys), it directly increases the time before INP is good (the main thread is busy parsing and executing), and on slow networks it can add seconds to load time regardless of how fast your server is.

How to implement it

Run Next.js's built-in bundle analyzer periodically, not just once at launch:

npm install @next/bundle-analyzer
// next.config.ts
import withBundleAnalyzer from "@next/bundle-analyzer";

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

export default withAnalyzer({
  // your existing config
});

Run ANALYZE=true npm run build and look for surprises — a moment-with-locales import, an icon library pulled in wholesale instead of tree-shaken, a chart library loaded on every page when only one page uses it.

When it's useful

Continuously — bundle size creeps up gradually as dependencies are added, not all at once. A monthly check catches regressions before they compound.

Common mistakes

  • Treating bundle analysis as a one-time launch task instead of a recurring habit.
  • Importing an entire library (import _ from "lodash") when you only need one function.
  • Not noticing that a "small" dependency pulls in several large transitive dependencies.

3. Use dynamic imports and code splitting

What it is

Next.js splits your JavaScript by route automatically, but next/dynamic lets you split within a page — loading a component only when it's actually needed.

Why it matters

Not every part of a page is needed immediately. A modal, a chart that only renders after a user clicks "view details," or a rich text editor used only on an admin page shouldn't be part of the initial bundle everyone downloads.

How to implement it

import dynamic from "next/dynamic";

const RevenueChart = dynamic(() => import("@/components/revenue-chart"), {
  ssr: false,
  loading: () => <ChartSkeleton />,
});

Use ssr: false specifically for components that depend on browser-only APIs (like a charting library that needs window), or that simply aren't needed for the initial render.

When it's useful

For heavy, conditionally-rendered UI: modals, dashboards, rich text editors, complex charts, or any component you know most visitors won't interact with on a given page.

Common mistakes

  • Dynamically importing something that's always visible above the fold — this just adds a loading flicker for no benefit.
  • Forgetting a loading fallback, which can itself cause layout shift when the real component pops in.

4. Take advantage of Server Components

What it is

In the App Router, every component is a Server Component by default. Server Components render on the server and send HTML (not JavaScript) to the browser — they never ship their own code to the client bundle.

Why it matters

This is the single biggest architectural lever for Next.js performance. A page built entirely from Server Components can have a client-side JavaScript bundle close to zero. Every "use client" boundary you add pulls that component — and everything it imports — into the bundle the browser has to download.

How to implement it

Push "use client" as far down the component tree as possible — ideally onto small, genuinely interactive leaf components, not entire pages.

// ✅ Server Component (default) — no "use client" needed
export function ProductPage({ product }: { product: Product }) {
  return (
    <div>
      <ProductGallery images={product.images} />
      <ProductDetails product={product} />
      <AddToCartButton productId={product.id} /> {/* only this needs "use client" */}
    </div>
  );
}
// AddToCartButton.tsx
"use client";

export function AddToCartButton({ productId }: { productId: string }) {
  // interactive logic here — this is the only part shipped to the browser
}

Client Components vs. Server Components — the trade-off

CapabilityServer ComponentsClient Components
Ships JS to browserNoYes
Can use hooks (useState, useEffect)NoYes
Can access browser APIsNoYes
Can fetch data directly (async/await)YesNo (needs a client-side fetch or a passed prop)
Best forContent, layout, data fetchingForms, interactivity, animations

When it's useful

By default, everywhere. Reach for a Client Component only when you specifically need state, effects, event handlers, or a browser-only API.

Common mistakes

  • Adding "use client" at the top of a page component "just in case," which drags the entire tree into the client bundle.
  • Wrapping a whole layout in a client provider (like a theme context) when only a small toggle button actually needs it.

5. Optimize fonts

What it is

next/font downloads and self-hosts your fonts at build time, instead of fetching them from an external font CDN (like Google Fonts) at request time.

Why it matters

External font requests add a DNS lookup, a connection, and a download — all blocking text from rendering until they resolve. Self-hosted fonts eliminate that round trip entirely, and next/font automatically applies font-display: swap behavior with proper fallback metrics, which prevents both invisible text and the layout shift caused by a fallback font being replaced by the real one.

How to implement it

// app/layout.tsx
import { Inter } from "next/font/google";

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

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en" className={inter.variable}>
      <body>{children}</body>
    </html>
  );
}

When it's useful

On every project using web fonts — there's essentially no downside.

Common mistakes

  • Loading multiple font weights or families "just in case" they're needed later, bloating the font payload.
  • Still linking to Google Fonts via a <link> tag in the <head> out of habit, alongside next/font — this reintroduces the exact round trip you were trying to avoid.

6. Improve your caching strategy

What it is

Deciding, per route or per fetch, how long content can be served from cache before it needs to be regenerated — and being deliberate about invalidating it precisely when data changes.

Why it matters

Caching is what lets a page be served instantly from the edge instead of being rebuilt on every request. The most common performance mistake we see isn't under-caching — it's reaching for force-dynamic or no-store out of caution, which silently opts an entire route out of caching and pushes TTFB back up.

How to implement it

// Tag-based revalidation — precise, not blanket
export async function getProduct(id: string) {
  const res = await fetch(`https://api.example.com/products/${id}`, {
    next: { tags: [`product-${id}`] },
  });
  return res.json();
}

// Elsewhere, after an update:
import { revalidateTag } from "next/cache";
revalidateTag(`product-${id}`);

This keeps the page static and fast almost all the time, and only regenerates it the moment the underlying data actually changes — instead of on a fixed timer or on every request.

When it's useful

Any route where the data doesn't change on every request — which is most of them, even ones that look "dynamic" at first glance (product pages, blog posts, dashboards with hourly data).

Common mistakes

  • Defaulting to force-dynamic on routes that don't actually need per-request freshness.
  • Using time-based revalidation (revalidate: 60) everywhere instead of tag-based invalidation, which means content is sometimes stale and sometimes rebuilt unnecessarily.

7. Use streaming and Suspense appropriately

What it is

Instead of waiting for every piece of data on a page to resolve before sending anything to the browser, streaming lets Next.js send the fast parts of the page immediately and "stream in" the slow parts as they become ready.

Why it matters

Without streaming, one slow database query or third-party API call blocks the entire page. With <Suspense>, the rest of the page — navigation, hero content, anything not dependent on that slow data — reaches the user immediately, which directly improves both perceived load time and TTFB for the parts that matter most.

How to implement it

import { Suspense } from "react";

export default function DashboardPage() {
  return (
    <div>
      <DashboardHeader /> {/* renders immediately */}
      <Suspense fallback={<RevenueChartSkeleton />}>
        <RevenueChart /> {/* streams in once its data resolves */}
      </Suspense>
    </div>
  );
}

When it's useful

Any page with at least one slow, non-critical data dependency — a report, a third-party integration, an analytics widget — sitting alongside content that could render immediately.

Common mistakes

  • Wrapping the entire page in one <Suspense> boundary, which brings back the same "wait for everything" problem streaming was meant to solve.
  • Using a fallback with different dimensions than the real content, which reintroduces layout shift when the real content pops in.

8. Reduce unnecessary client-side rendering

What it is

Auditing which parts of your app genuinely need to run in the browser versus which were made client-rendered by default, habit, or copy-pasted boilerplate.

Why it matters

This is really techniques 4 and 7 applied deliberately across an entire app. Marketing pages, blog posts, and case studies — the pages most likely to be a visitor's first impression — rarely need meaningful client-side interactivity. Rendering them almost entirely on the server keeps their JavaScript bundle small and their INP good by default.

How to implement it

Ask, for every "use client" boundary in the codebase: does this specific piece need state, an effect, or a browser API — or was it added because a parent component needed it, and it was easier to mark the whole thing client-side?

When it's useful

Across the whole application, but especially on content-first pages: homepage, blog, case studies, pricing, about.

Common mistakes

  • Treating "use client" as the default and only removing it when something breaks, rather than the other way around.
  • Client-rendering content that could be fetched and rendered on the server simply because that's how the component was originally prototyped.

9. Improve API and database performance

What it is

Optimizing the queries and API calls that feed your pages — indexing, query shape, and avoiding unnecessary round trips — not just the frontend rendering that displays the result.

Why it matters

No amount of frontend optimization fixes a page whose TTFB is 2 seconds because of an unindexed database query. This is the piece of "Next.js performance" that's easiest to overlook because it isn't Next.js-specific, but it's often the actual bottleneck.

How to implement it

  • Add indexes for any column used in a WHERE, JOIN, or ORDER BY clause on a query that runs per-request.
  • Avoid N+1 queries — fetch related data in one query (or one batched request) instead of looping and querying per item.
  • Co-locate your database and your Next.js deployment region where possible; a query that's fast locally can be slow if it crosses continents on every request.
  • Cache expensive, slow-changing queries at the data layer, not just at the Next.js fetch layer.

When it's useful

On any route where TTFB is high despite the frontend being well-optimized — that's the signal to look at the data layer next.

Common mistakes

  • Optimizing images and bundle size while a genuinely slow query is the real bottleneck.
  • Fetching more data than the page actually renders "to be safe."

10. Use CDN/edge caching and proper deployment configuration

What it is

Making sure static assets, and cacheable pages, are actually served from a CDN edge location close to the visitor — not re-fetched from a single origin server on every request.

Why it matters

Physical distance matters. A server response that takes 100ms from a data center in the same region can take 400–600ms for a visitor on the other side of the world hitting a single origin. Edge caching removes that distance for content that doesn't need to be generated per-request.

How to implement it

  • Deploy on a platform with automatic edge caching for static and ISR (Incremental Static Regeneration) pages.
  • Set correct Cache-Control headers on API routes that serve semi-static data.
  • Confirm your images, fonts, and other static assets are actually being served with long cache lifetimes — check response headers in your browser's network tab, don't assume.

When it's useful

For any application with a geographically distributed audience — which, for most public-facing sites, is all of them.

Common mistakes

  • Assuming a page is being served from the edge without checking response headers.
  • Setting overly short cache lifetimes "to be safe," which defeats most of the benefit.

Third-party scripts: the silent performance killer

Analytics tags, chat widgets, ad scripts, A/B testing tools — third-party scripts are consistently one of the largest contributors to poor INP, because you don't control what they do once they load, and they often run expensive code on the main thread at the worst possible time.

Use next/script with an appropriate strategy:

import Script from "next/script";

<Script src="https://widget.example.com/chat.js" strategy="lazyOnload" />
  • beforeInteractive — only for scripts that must run before the page is usable (rare).
  • afterInteractive — the default; fine for most analytics.
  • lazyOnload — for anything non-critical, like a chat widget, that can wait until the browser is idle.

Audit your third-party scripts the same way you audit your own bundle. It's common to find two analytics tools doing the same job, or a chat widget loaded on every page when it's only used on one.

Monitoring performance in production

Lighthouse and DevTools are lab tests — they measure your machine, on your network, once. Real User Monitoring (RUM) measures what actual visitors experience, across every device and connection quality that hits your site.

  • Use a RUM tool (Vercel Analytics, or your own web-vitals reporting) to collect real LCP, INP, and CLS from production traffic.
  • Track performance by page template, not just as a single site-wide number — your homepage and your checkout flow have very different budgets and very different consequences for being slow.
  • Set a budget and alert on regressions, the same way you'd alert on errors. Performance decays gradually as features are added; without monitoring, nobody notices until it's a real problem.

A practical Next.js performance checklist

Common Next.js performance mistakes

MistakeWhy it hurtsFix
force-dynamic used by defaultOpts the whole route out of caching, raising TTFBUse it only when the route genuinely needs per-request data
Marking every image priorityCompetes for bandwidth with the actual LCP imageReserve priority for the one image that matters most
"use client" near the rootPulls the whole tree into the client bundlePush client boundaries to small, interactive leaf components
No dimensions on imagesCauses layout shift (CLS)Always set width/height or use fill in a sized container
One giant <Suspense> boundaryReintroduces "wait for everything"Wrap only the genuinely slow, non-critical section
Testing only in LighthouseDoesn't reflect real users on real networksAdd Real User Monitoring for production traffic
Ignoring the database layerFrontend gains are capped by a slow TTFBIndex queries and fix N+1 patterns at the source

Frequently asked questions

What is a good Next.js performance score?

Aim for all three Core Web Vitals in the "good" range: LCP under 2.5 seconds, INP under 200 milliseconds, and CLS under 0.1. A high Lighthouse score is a useful lab signal, but real-user data from production is what actually reflects visitor experience.

Does Next.js improve performance automatically?

Next.js gives you the tools — automatic code splitting, image optimization, Server Components, edge caching — but none of it is automatic in the sense of "do nothing and get a fast site." How you use those tools (where you put "use client", how you cache, how you size images) determines the outcome.

What's the single highest-impact change for an existing Next.js app?

For most existing apps, it's auditing "use client" boundaries and pushing them down the tree. It's usually the fastest change to make and often produces the biggest drop in shipped JavaScript, which improves both load time and INP.

Is Server-Side Rendering always faster than Client-Side Rendering?

Not automatically — it depends on what you do with it. SSR sends meaningful HTML on the first response, which helps LCP and TTFB. But if the server-side work itself is slow (a slow database query, for example), SSR can be slower than a well-cached client-rendered page. The two aren't a strict hierarchy; they're tools for different situations.

How often should we check Next.js performance?

Continuously, not just at launch. Bundle size and query performance both tend to creep up gradually as features are added. A monthly bundle-analyzer check plus always-on Real User Monitoring catches regressions long before they become a visible problem.

Can third-party scripts really slow down a fast Next.js app?

Yes — often more than anything in your own codebase. A single poorly-loaded chat widget or analytics tag can dominate INP regardless of how well-optimized your application code is, because you don't control the JavaScript it runs. Auditing third-party scripts with the same rigor as your own bundle is worth the time.

Conclusion

None of these ten techniques are exotic, and none of them require a rewrite. What separates a genuinely fast Next.js application from one that just looks fast in a single Lighthouse run is applying them consistently — across every route, not just the homepage — and measuring the result with real users instead of assuming it worked.

This is the kind of work we do regularly on client projects: auditing an existing Next.js application, finding where the actual bottleneck is (not just where it's easiest to optimize), and fixing it without a full rebuild. If your Next.js app's performance doesn't match what the framework is capable of, it's usually a handful of specific, fixable issues — not a fundamental problem with the framework or your codebase.

Next.jsPerformanceWeb DevelopmentCore Web Vitals

Have a project like this in mind?

Tell us about it — we usually reply within 24 hours.

Start a Conversation