Optimizing a Next.js site isn't just about making it feel fast, it's about proving it with a perfect (or near-perfect) Lighthouse score.
Yesterday, I gave my portfolio site a fresh new look. I also made several key changes that really boosted the site's metrics.
Performance Results
Here's how the site performed after the changes I made:
Here's a breakdown of what I did, why it mattered, and how you can do the same for your own sites.
1. Ditching Scroll Events for Intersection Observer
Most of us use a scroll listener to change the navbar background when someone scrolls down. The problem? Scroll events fire constantly and can make the page feel "janky" because they hog the main thread.
I replaced my scroll listener with the Intersection Observer API. I placed a tiny hidden "sentinel" element at the very top of the page. Now, I just observe when that sentinel leaves the screen to toggle the navbar style.
It's asynchronous and way more efficient. It keeps the main thread free, which helps with your Total Blocking Time (TBT) and makes scrolling feel buttery smooth.
// components/Navbar.tsx
useEffect(() => {
const sentinel = document.getElementById("scroll-sentinel");
const observer = new IntersectionObserver(
([entry]) => setScrolled(!entry.isIntersecting),
{ threshold: 0 }
);
if (sentinel) observer.observe(sentinel);
return () => observer.disconnect();
}, []);
2. Fixing the LCP Animation Trap
Largest Contentful Paint (LCP) measures how long it takes for your biggest content (usually the hero title) to show up. I was using a "reveal" animation on my main heading that started at opacity: 0.
Even a 100ms animation delay counts against your score. I removed the initial hidden state and the animation from the main title so that it's visible the moment the page loads. It might look slightly less "fancy," but the performance win is worth it.
// Before (slowed down LCP)
<h1 className="reveal opacity-0">Hi, I'm Yogesh</h1>
// After (instant visibility)
<h1>Hi, I'm Yogesh</h1>
3. Cleaning Up "Monolithic" Constants
I used to have one giant constants.ts file that exported all the constants like projects, courses, skills, etc. This meant every single page was downloading all that data, even if it only needed a tiny bit of it.
I split that giant file into smaller modules like projects.ts, courses.ts, skills.ts, etc. Now, components only grab exactly what they need. Think of it like a suitcase: you don't need to pack your winter coat for a beach trip. Keep the initial bundle small!
// app/constants/projects.ts
export const projects = [...];
// In components/Projects.tsx
import { projects } from "@/app/constants/projects";
// app/constants/skills.ts
export const skills = [...];
// In components/skills.tsx
import { skills } from "@/app/constants/skills";
4. Smart Loading with the PRPL Pattern
The PRPL pattern (Push, Render, Pre-cache, Lazy-load) is basically a fancy way of saying "load things only when you need them and keep them around for later."
I used dynamic imports for everything below the fold (like the Contact or Experience sections).
import dynamic from "next/dynamic";
const Contact = dynamic(() => import("@/components/Contact"));
I also added aggressive caching headers in next.config.ts. This combination means the first load is lean, and repeat visits are nearly instant because the browser already has the files.
// next.config.ts (PRPL Caching)
async headers() {
return [
{
// Public images cached for 30 days with stale-while-revalidate
source: "/images/:path*",
headers: [
{
key: "Cache-Control",
value: "public, max-age=2592000, stale-while-revalidate=86400",
},
],
},
];
}
5. Targeting Modern Browsers (ES2022)
By default, some tools target older versions of JavaScript to support ancient browsers. But most of us don't really need to support IE11 anymore.
I updated my tsconfig.json to target ES2022, instead of previous ES2017. Since modern browsers support this natively, TypeScript doesn't have to write extra "workaround" code. This results in cleaner, smaller files that the browser can execute much faster.
// tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
...
}
}
6. Cutting Out "Legacy" JavaScript
Similar to the step above, I updated my browserslist in package.json to tell the build tool exactly which browsers I care about. This allows Next.js to skip adding polyfills for features that my target browsers already have. It's an easy way to trim the fat from your JavaScript bundles.
"browserslist": [
"defaults",
"maintained node versions"
]
defaults means: Last 2 versions of each major browser and Not dead browsers.
So it excludes: IE 11 and Very old browsers
7. Letting Next.js Handle Images
Standard <img> tags are a headache for performance. They don't resize or compress themselves automatically.
I used the Next.js <Image /> component everywhere. It handles WebP conversion, lazy loading, and prevents layout shifts by making you define dimensions.
Using <Image /> component also loads images of different sizes based on the viewport size, so smaller images are loaded for smaller screens, and larger images for larger screens, making the site load faster.
It's truly one of the lowest-effort, highest-reward changes you can make.
import Image from 'next/image';
<Image
src="/hero.webp"
alt="Hero"
width={800}
height={600}
priority // Use this for top-of-page images!
/>
8. Making the Site Usable for Everyone
A high performance score is great, but accessibility is just as important. I added proper ARIA labels to my mobile menu buttons and wrapped the content in a <main> tag.
<button
...
aria-label={mobileMenuOpen ? "Close menu" : "Open menu"}
>
...
</>
Lighthouse rewards you for this, but more importantly, it makes your site actually usable for people using screen readers. It's just good practice.
9. Production-Ready Config Tweaks
I made two small but effective changes in next.config.ts: I enabled gzip compression and told the compiler to strip out all console.log statements in production. Cleaner code, smaller files, faster site.
// next.config.ts
const nextConfig = {
compress: true,
compiler: {
removeConsole: process.env.NODE_ENV === "production",
},
};
10. Pre-warming Connections
Every time your site links to another domain (like a course platform), or you include links for your external projects, courses, or any other external resources, the browser has to do some prep work to connect.
I added preconnect and dns-prefetch tags for my critical external links. I made sure to keep it under 4 preconnects, though, because too many can actually backfire and reduces your lighthouse score.
This little head start makes external links feel much snappier.
// app/layout.tsx
<head>
<link rel="preconnect" href="https://courses.yogeshchavan.dev" />
<link rel="dns-prefetch" href="https://www.codementor.io" />
<link rel="dns-prefetch" href="https://www.freecodecamp.org" />
<link rel="dns-prefetch" href="https://dev.to" />
<link rel="dns-prefetch" href="https://medium.com" />
</head>
Hopefully, some of these tips help you squeeze a bit more speed out of your own Next.js projects! Happy coding!
A Quick Tip for Testing Performance
When testing your site's performance, I'd recommend using PageSpeed Insights (pagespeed.web.dev) instead of Chrome DevTools Lighthouse tool.
Reason: Browser extensions and injected scripts can interfere with the results, often giving you inaccurate scores. PageSpeed Insights runs tests on Google's servers, so you get clean, reliable metrics every time.
About Me
I'm a freelancer, mentor, full-stack developer working primarily with React, Next.js, and Node.js with a total of 12+ years of experience.
Alongside building real-world web applications, I'm also an Industry/Corporate Trainer training developers and teams in modern JavaScript, Next.js and MERN stack technologies, focusing on practical, production-ready skills.
Also, created various courses with 3000+ students enrolled in these courses.
My Portfolio: https://yogeshchavan.dev/


Top comments (0)