Lighthouse scores are a useful proxy but a poor goal. A 100/100 score on a staging environment with no real users doesn't tell you whether your app is fast for someone on a mid-range phone in Mumbai on a 4G connection. Let's focus on what actually affects real users.
Server Components first
The single highest-leverage change in modern Next.js is using Server Components correctly. Every component that doesn't require interactivity should be a Server Component. This isn't just about bundle size — it eliminates the client/server waterfall for data fetching and removes JavaScript that users have to parse and execute.
// Server Component — no 'use client', no JS sent to the browser
// Data fetches happen in parallel on the server
async function ProductPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const [product, relatedProducts, reviews] = await Promise.all([
getProduct(id),
getRelatedProducts(id),
getReviews(id),
]);
return (
<div>
<ProductDetails product={product} />
{/* Suspense boundary — shows skeleton while reviews load */}
<Suspense fallback={<ReviewSkeleton />}>
<Reviews reviews={reviews} />
</Suspense>
<RelatedProducts products={relatedProducts} />
</div>
);
}Cache aggressively, invalidate precisely
Next.js's data cache is powerful but unintuitive. The key mental model: fetch requests are cached by default. You can opt out per-fetch or per-route. The mistake most teams make is disabling caching everywhere because they don't understand the defaults — and then wondering why their app is slow.
// Revalidate product data every 60 seconds
const product = await fetch(`/api/products/${id}`, {
next: { revalidate: 60, tags: ["products", `product-${id}`] },
});
// Always fresh — user-specific data shouldn't be cached
const cart = await fetch(`/api/cart/${userId}`, {
cache: "no-store",
});
// On-demand revalidation when product is updated
import { revalidateTag } from "next/cache";
export async function updateProduct(id: string, data: ProductUpdate) {
await db.product.update({ where: { id }, data });
revalidateTag(`product-${id}`); // Clears only this product's cache
revalidateTag("products"); // Clears product list caches
}The image issues that actually matter
Next.js's Image component handles WebP conversion, responsive sizing, and lazy loading correctly by default. The places where it goes wrong: forgetting the `priority` prop on above-the-fold images (this is the single most common LCP problem we see), and setting `sizes` incorrectly which causes the browser to download oversized images.
// Priority on the hero image — the most common LCP fix
<Image
src="/hero.jpg"
alt="Product hero"
width={1200}
height={630}
priority // Never lazy-load the LCP element
sizes="(max-width: 768px) 100vw, 1200px"
quality={85} // 85 is usually indistinguishable from 100 at half the size
/>
// For images below the fold — lazy load is correct (default)
<Image
src="/product-thumbnail.jpg"
alt={product.name}
width={400}
height={300}
// No priority — lazy loading is correct here
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 400px"
/>Bundle analysis before you optimize
Before micro-optimizing anything, run bundle analysis. The output almost always shows one or two packages causing 60-70% of your bundle size. Fix those before doing anything else.
# Install and run the bundle analyzer
npm install @next/bundle-analyzer --save-dev
# In next.config.ts
import bundleAnalyzer from "@next/bundle-analyzer";
const withBundleAnalyzer = bundleAnalyzer({ enabled: process.env.ANALYZE === "true" });
export default withBundleAnalyzer(nextConfig);
# Run the analysis
ANALYZE=true next buildCommon bundle bloat culprits: moment.js (replace with date-fns), lodash (import specific functions: `import debounce from 'lodash/debounce'`), and any charting library imported without tree-shaking. Recharts is generally leaner than Chart.js for typical dashboards.
What actually moves Core Web Vitals
- LCP is almost always an image or large text block — find it in Chrome DevTools, then check: server-rendered? Priority set? WebP format?
- CLS is caused by images without explicit dimensions, late-loading ads, and font swaps — reserve space with aspect-ratio or explicit width/height
- INP (Interaction to Next Paint, the new FID) is caused by long JavaScript tasks on the main thread — check the Performance tab for tasks over 50ms
- FCP improves with server rendering and preloading fonts — use `<link rel='preload'>` for your primary font
- TTFB improves with edge rendering and proper caching — Vercel's edge network makes this mostly automatic
Auravon AI
Engineering Studio