~/priyank$
> Initializing portfolio...
> Loading components... ✓
> Fetching experience... ✓
> Compiling skills... ✓
> Ready.
~/priyank $
~/blog/tech
post.metadata
title: "Next.js Performance Patterns I Use in Every Project (To Keep Lighthouse from Crying)"
date: May 2026
readTime: 9 min read
tags: ["Next.js", "Performance", "Web Vitals"]
author: "Priyank Deep Singh"

Next.js Performance Patterns I Use in Every Project (To Keep Lighthouse from Crying)

Server Components, next/dynamic, priority images, script strategies, and streaming Suspense. The architectural rules I reach for on every project to keep the main thread breathing and Core Web Vitals in the green.

Priyank Deep Singh
Priyank Deep Singh
9 min read · May 2026
Next.js Performance Patterns I Use in Every Project (To Keep Lighthouse from Crying)

Next.js is heavily marketed as being "fast by default." And it is! If you deploy a blank white page with some standard text, it's basically the Usain Bolt of web frameworks.

But then reality hits. You need an interactive date picker. The marketing team wants three different tracking scripts. You decide to add a 3D spinning logo because, well, it looked cool at 2 AM. Suddenly your beautifully optimized app is dragging a 4MB JavaScript bundle across the finish line, and your Lighthouse score looks like a bad grade in middle-school math.

Over years of fighting in the JavaScript bundle trenches (especially while keeping things snappy during the UCP Product Hunt launch this past April), I've adopted a strict set of architectural rules. Here are the Next.js performance patterns I reach for in every single project to keep the main thread breathing and Core Web Vitals strictly in the green.

The Five Patterns
01Quarantine the 'use client' zombie virus02The procrastinator's guide to imports (next/dynamic)03Give your LCP image VIP access04The marketing script quarantine05The magician's distraction (Suspense streaming)

01Quarantine the 'use client' zombie virus

With the App Router, Server Components are the default. This is incredible because they send zero JavaScript to the browser. But here's the catch: the 'use client' directive is basically a zombie virus.

If you get lazy and slap 'use client' at the very top of a layout.tsx or a massive page component just because you needed useState for a tiny dropdown menu, you've infected the entire component tree below it. Everything gets bundled. Everything gets sent to the browser. You've just turned your sleek server-side app into a giant 2018-era single-page application.

The pattern: leaf-node interactivity

Push interactivity to the absolute furthest branches of your component tree. The trunk (layouts) and branches (sections) stay on the server. Only the leaves (buttons, inputs) get the client treatment.

BlogPost.tsxtsx
// ❌ BAD: The entire page, including the massive static
// article, is shipped to the client.
'use client'
import { useState } from 'react'
import HugeMarkdownParser from './HugeMarkdownParser'
export default function BlogPost({ content }) {
const [likes, setLikes] = useState(0)
return (
<article>
{/* Why is this shipping to the browser?! */}
<HugeMarkdownParser content={content} />
<button onClick={() => setLikes(l => l + 1)}>
Like ({likes})
</button>
</article>
)
}
page.tsxtsx
// ✅ GOOD: Keep the heavy lifting on the server,
// isolate the button.
// page.tsx (Server Component by default)
import HugeMarkdownParser from './HugeMarkdownParser'
import LikeButton from './LikeButton' // <-- the only 'use client' file
export default function BlogPost({ content }) {
return (
<article>
{/* Rendered on the server, ships as lightweight HTML */}
<HugeMarkdownParser content={content} />
{/* The only JavaScript sent to the browser */}
<LikeButton initialLikes={0} />
</article>
)
}
⚠️ Watch Out
The fix is almost always "extract the interactive bit into its own file." One LikeButton with 'use client' ships a few bytes. A whole page marked client ships the whole page.

02The procrastinator's guide to imports (next/dynamic)

I am a big fan of procrastination when it comes to web performance. If a user can't see a component right away, the browser shouldn't have to download it right away.

When building Colorbrew.co earlier this year, I had to load some pretty heavy design utilities and color-manipulation libraries. If I bundled all of that up front, the initial load would have been brutal for anyone not on gigabit internet.

The pattern: surgical lazy loading

Use next/dynamic for heavy client components that live below the fold, inside modals, or behind tabs. Treat your bundle like a bouncer at an exclusive club: if you aren't on the immediate viewport list, you wait outside.

DesignTools.tsxtsx
import dynamic from 'next/dynamic'
// This 150KB color picker won't block the initial page load.
// ssr: false ensures it doesn't break if it touches
// window / document APIs.
const HeavyColorPicker = dynamic(
() => import('@/components/HeavyColorPicker'),
{
loading: () => (
<div className="h-48 w-full animate-pulse bg-slate-800 rounded-xl" />
),
ssr: false,
}
)
export default function DesignTools() {
return (
<section>
<h2>Advanced Tools</h2>
{/* Loads only when the user scrolls to this section */}
<HeavyColorPicker />
</section>
)
}

03Give your LCP image VIP access

Largest Contentful Paint (LCP) is usually your hero image. The default browser behavior is to parse the HTML, eventually stumble across your <img /> tag, and think, "Oh, I guess I should download this." By that point you've wasted precious hundreds of milliseconds.

When I was migrating my portfolio to the hellopriyank.dev domain, getting the initial load completely seamless was the top priority.

The pattern: the priority prop

Find the single largest image visible on the initial screen and slap the priority prop on it. This injects a <link rel="preload"> into the document head, forcing the browser to fetch it immediately.

⚠️ Watch Out
Do not put priority on images below the fold, or you will accidentally DDOS your own bandwidth prioritizing a footer logo. Exactly one hero image gets the VIP pass.
HeroSection.tsxtsx
import Image from 'next/image'
export default function HeroSection() {
return (
<header className="relative h-[80vh]">
<Image
src="/massive-retina-hero.jpg"
alt="A really cool background"
fill
sizes="100vw"
priority // <-- the VIP pass for LCP
className="object-cover"
/>
<h1 className="absolute z-10">We load fast.</h1>
</header>
)
}

04The marketing script quarantine

Your code is pristine. Your Lighthouse score is a perfect 100. Then you get a Slack message: "Hey, can we add Intercom, Google Analytics, Hotjar, and this 4MB tracking pixel from a company no one has ever heard of?"

If you paste those into a raw <script> tag, your Total Blocking Time (TBT) will skyrocket, and your site will freeze while the browser executes marketing telemetry.

The pattern: next/script strategies

Never use a raw script tag. Route everything through next/script and ruthlessly control when each one executes.

app/layout.tsxtsx
import Script from 'next/script'
export default function RootLayout({ children }) {
return (
<html>
<body>
{children}
{/* Analytics? Sure, load after the page is interactive. */}
<Script
src="https://www.googletagmanager.com/gtag/js?id=GA_MEASUREMENT_ID"
strategy="afterInteractive"
/>
{/* Heavy chat widget? Wait until the browser is
literally doing nothing else. */}
<Script
src="https://widget.intercom.io/widget/APP_ID"
strategy="lazyOnload"
/>
</body>
</html>
)
}

05The magician's distraction (Suspense streaming)

If a database query takes 3 seconds, a traditional SSR app will show the user a blank white screen for 3 seconds. The user thinks your site is broken and leaves.

The pattern: granular Suspense boundaries

Wrap your slow, dynamic components in <Suspense>. Next.js will instantly stream the fast, static parts of your page (like the navigation and sidebar) while leaving a placeholder skeleton for the slow parts. It's like a magician distracting you with a shiny loading state while the server is sweating to join three database tables.

Dashboard.tsxtsx
import { Suspense } from 'react'
import StaticSidebar from './StaticSidebar' // instant
import SlowDatabaseList from './SlowDatabaseList' // takes 2 seconds
export default function Dashboard() {
return (
<div className="flex">
<StaticSidebar />
<main className="flex-1 p-8">
<h1>Your Data</h1>
{/* User sees the skeleton instantly,
data streams in when ready */}
<Suspense fallback={
<div className="h-64 bg-gray-200 animate-pulse rounded" />
}>
<SlowDatabaseList />
</Suspense>
</main>
</div>
)
}

Wrapping Up

When you are architecting a complex frontend, it's rarely one massive mistake that ruins performance. It's death by a thousand cuts. A heavy import here, an eager script there, a misused layout wrapper. Adopting these patterns turns performance from a panicked afterthought before launch into a foundational guarantee.

01
Quarantine 'use client'
Keep the directive at the leaves. Never infect a layout or page.
02
Procrastinate with next/dynamic
Lazy-load anything below the fold, in a modal, or behind a tab.
03
Fast-track your LCP image
priority preloads the one hero image, and only that one.
04
Quarantine marketing scripts
Route every tag through next/script with afterInteractive or lazyOnload.
05
Distract with Suspense
Stream the fast shell instantly, skeleton the slow parts.

Keep your Server Components clean, your Client Components small, and your Lighthouse score happy.

share.sh
$ echo "Share this article"
Priyank Deep Singh
Priyank Deep Singh

Senior web engineer who loves building fast, accessible, and beautiful web experiences. Writing about React, Next.js, and everything in between.