Start With What the Browser Sees
Every performance technique in this post is really about one thing: controlling when resources load. The browser discovers resources by parsing HTML top to bottom, and by default it treats everything as equally urgent. Your job is to tell it what's critical now, what can wait, and what it should quietly fetch for later:
- Critical now — the hero image, the fonts, the CSS for above-the-fold content. These deserve
preloadand high priority. - Can wait — analytics, chat widgets, anything below the fold. These get
defer, lazy loading, or an idle-time strategy. - Needed soon — the next page the user will visit. This is what
prefetchis for.
Before changing anything, run your app through PageSpeed Insights or a local Lighthouse audit and note the three Core Web Vitals: LCP (target under 2.5s), INP (under 200ms), and CLS (under 0.1). Keep in mind that Lighthouse measures a simulated page load — validate improvements against real-user Core Web Vitals data too (the field data in PageSpeed Insights, when available). Each technique below maps to one of those numbers.
Defer: Load Scripts at the Right Time with next/script
The classic HTML answer to render-blocking JavaScript is the defer attribute — the script downloads in parallel but only executes after the document is parsed. In Next.js you rarely write that attribute yourself, because the framework already defers its own bundles and gives you the next/script component for everything else:
import Script from "next/script";
// Analytics — load during idle time, never block the page
<Script
src="https://www.googletagmanager.com/gtag/js?id=G-XXXX"
strategy="lazyOnload"
/>
// A library the page needs once it's interactive (default)
<Script src="https://example.com/widget.js" strategy="afterInteractive" />The three strategies, in the order you should reach for them:
lazyOnload— loads during browser idle time, after everything else. The right choice for analytics, ad pixels, chat widgets, and social embeds. This is the Next.js equivalent of the “delay JavaScript” feature that caching plugins sell for WordPress — and here it's free.afterInteractive(default) — the closest Next.js equivalent to a deferred script: it loads without blocking rendering and executes once the page becomes interactive. Fine for scripts the page genuinely needs, but not before paint.beforeInteractive— injected before any Next.js code runs. Reserve it for the rare script that must run first, like a consent manager or a bot detector. Every script here delays interactivity for the whole app.
The most common mistake I see in audits: a plain <script> tag pasted into _document or Head because that's what the third-party vendor's instructions said. That bypasses Next.js's scheduling entirely. If a script can use next/script — and almost all of them can — it should.
Preload: Tell the Browser What It Needs Right Now
preload is a strong hint to the browser: “this resource will be needed for the current page very soon, so start fetching it at high priority before you'd naturally discover it.” The classic use case is the LCP image. With server-rendered HTML the browser often discovers the hero image early on its own — but preloading still matters, because it fetches that image at the highest priority and disables lazy loading, which on image-heavy pages is often the difference between a passing and a failing LCP.
In Next.js, you usually don't write the <link rel="preload"> tag by hand. The framework exposes preloading through props:
import Image from "next/image";
// "priority" preloads the image and disables lazy loading —
// use it on exactly one thing: the LCP element
<Image
src="/images/hero.png"
alt="Hero"
width={1200}
height={600}
priority
/>- Images — add
priorityto the LCP image (and only that one). Every othernext/imagestays lazy-loaded by default, which is what you want. Under the hood,priorityemits a preload hint and setsfetchpriority="high"on the image. Worth knowing: Next.js 16 deprecatespriorityin favor of a more explicitpreloadprop, and you can usefetchPriority="high"when you want the priority boost without a preload tag. - Fonts — use
next/font. It self-hosts the font files, preloads them, and setsfont-displayautomatically, which removes both the flash of invisible text and the layout shift that hurts CLS. If you're still loading Google Fonts via a<link>tag, this is a one-file change with a visible payoff. - Anything else critical — a manual
<link rel="preload">inHeadstill works for cases the framework doesn't cover, like a video poster or a critical API response.
Preload is a budget, not a free upgrade. Every preloaded resource competes with everything else for bandwidth — if you preload five things, you've preloaded nothing.
Related hints worth knowing: preconnect opens the TCP/TLS connection to a third-party origin early (worth adding for your font CDN or API domain), and dns-prefetch is its cheaper fallback that only resolves DNS. Both go in Head and cost almost nothing.
Prefetch: Make the Next Page Feel Instant
Where preload is about this page, prefetch is about the next one — fetching at low priority, during idle time, so the resource is already cached when the user navigates.
This is the feature Next.js is quietly famous for: in production, links rendered with next/link are prefetched automatically as they enter the viewport (the exact behavior depends on the router — the App Router prefetches more selectively than the Pages Router). By the time the user clicks, the code for that route is usually already downloaded — which is why navigation in a well-built Next.js app feels nearly instant.
import Link from "next/link";
// Prefetched automatically when it enters the viewport
<Link href="/projects">Projects</Link>
// Opt out for rarely-visited pages on link-heavy screens
<Link href="/privacy-policy" prefetch={false}>Privacy Policy</Link>Two things to keep in mind:
- It only works in production. If you're testing navigation speed with
npm run dev, you're not seeing prefetching at all. Always judge it on a production build. - It isn't free. Next.js throttles and deduplicates prefetches, but a page with many links can still generate substantial prefetch traffic. On dashboards or long listing pages, add
prefetch={false}to links users rarely follow and let the high-traffic ones keep the default.
For data rather than routes, the same idea applies one level up: libraries like SWR and React Query can prefetch a query on hover or viewport entry, so the next screen renders with data already in the cache instead of a loading spinner.
Ship Less JavaScript in the First Place
Resource hints schedule the work; code splitting removes it. On most React apps I audit, the single biggest win isn't a hint — it's a component or library that shouldn't be in the initial bundle at all.
next/dynamic (Next.js's wrapper around React.lazy) splits a component into its own chunk that loads on demand:
import dynamic from "next/dynamic";
// The chart library (~200 KB) is split into a separate chunk
// and downloaded only when this component is needed
const SalesChart = dynamic(() => import("../components/SalesChart"));
// Client-only widgets can skip server rendering entirely
const ChatWidget = dynamic(() => import("../components/ChatWidget"), {
ssr: false,
});One caveat on ssr: false: reach for it only when the component depends on browser-only APIs or server rendering adds little value — like a chat widget. Disabling SSR on large chunks of a page removes them from the server-rendered HTML, which hurts both SEO and LCP.
The best candidates are easy to spot: modals and drawers that open on click, charting and map libraries, rich-text editors, anything below the fold, and anything rendered conditionally. None of that belongs in the bundle that gates first paint.
And if you're on the App Router, the biggest bundle-size lever sits one level higher: keep components as Server Components wherever possible and add "use client" only where interactivity is actually required. Every component that stays on the server is JavaScript the browser never has to download — often a bigger win than any preload or prefetch tweak.
To find what's actually heavy, run @next/bundle-analyzer once and look at the treemap. Almost every project has a surprise in there — a date library imported whole for one formatting call, an icon pack shipping a thousand unused icons, lodash imported without tree shaking. Fixing two or three of those routinely cuts more kilobytes than every resource hint combined.
And on the React side, remember that the cheapest render is the one that doesn't happen: keep component state local so typing in an input doesn't re-render the page, reach for useMemo when profiling shows a genuinely expensive computation being recomputed unnecessarily (not as a default — overusing it adds complexity for no gain), and virtualize long lists. These don't show up in Lighthouse directly, but they're what keeps INP under 200ms once real users start interacting.
A Realistic Order of Operations
If I'm handed a slow Next.js app, this is the sequence — ordered by payoff per hour of work:
- Run Lighthouse on a production build and save the baseline.
- Mark the LCP image for high-priority loading (using
priorityin Next.js 15 orpreloadin newer versions) and confirm every other image usesnext/imagewith properwidth/height(that's your CLS insurance). - Move every third-party
<script>tag tonext/script— analytics and widgets getlazyOnload. - Switch fonts to
next/font; addpreconnectfor any remaining third-party origins. - Run the bundle analyzer, then
next/dynamicthe heavy below-the-fold components and fix oversized imports. - Review prefetching: keep the defaults on main navigation, opt out on link-heavy lists, and re-test.
Frequently Asked Questions
What is the difference between preload and prefetch?
Preload fetches a resource the current page needs right now, at high priority — a hero image, a critical font. Prefetch fetches a resource the user will probably need soon, at low priority during idle time — like the JavaScript for the next page. Preload is for this page's critical path; prefetch is for the next navigation.
How do I defer third-party scripts in Next.js?
Use the next/script component instead of a plain script tag. The default strategy, afterInteractive, is the closest Next.js equivalent to a deferred script — it loads without blocking rendering and executes once the page becomes interactive. For analytics, chat widgets, and other non-critical scripts, use strategy="lazyOnload" so they load during idle time and never compete with your content.
Does Next.js prefetch pages automatically?
Yes — in production builds, Next.js automatically prefetches links rendered with next/link as they enter the viewport, so navigation feels nearly instant (the App Router prefetches more selectively than the Pages Router). You can disable it per-link with prefetch={false} for rarely-visited pages, which saves bandwidth on link-heavy screens. Note that prefetching is off in development mode, so always judge it on a production build.
The Framework Does the Heavy Lifting — If You Let It
The pattern across everything above: Next.js already contains the optimization machinery — automatic code splitting, link prefetching, image lazy loading, script scheduling, font preloading. Most slow Next.js apps aren't slow because the framework failed; they're slow because something bypassed it — a raw script tag, an unoptimized image, a 300 KB library in the shared bundle.
So the discipline is less about adding clever tricks and more about staying on the paved road: one preloaded hero, deferred everything-else, prefetched navigation, and a bundle you've actually looked at. Do that, and green Core Web Vitals stop being a project and start being the default.
