Core Web Vitals and performance SEO rules for Next.js. Use when optimizing page load speed, addressing LCP/CLS/INP issues, working with third-party scripts like Google Tag Manager, fonts, or bundle size.
Core Web Vitals are a Google ranking factor. Follow these rules to maintain good performance on a Next.js e-commerce site.
| Metric | Target | What it measures |
|---|---|---|
| LCP (Largest Contentful Paint) | < 2.5s | How fast the main content loads |
| CLS (Cumulative Layout Shift) | < 0.1 | How much the page layout shifts during load |
| INP (Interaction to Next Paint) | < 200ms | How fast the page responds to user interaction |
"use client"getServerSideProps or getStaticProps to fetch data server-side// GOOD — Server-rendered (no "use client" in App Router; use getServerSideProps in Pages Router)
export default async function ProductPage({ params }: Props) {
const product = await getProduct(params.id); // Server-side fetch
return <ProductDetails product={product} />;
}
// GOOD — Only the interactive part is a client component
// components/AddToCartButton.tsx
"use client";
export function AddToCartButton({ productId }: Props) {
const [quantity, setQuantity] = useState(1);
// ...
}
Why it matters: Client-side rendering adds to the JavaScript bundle. On category pages with 50+ products, making each product card a client component can add hundreds of KB of JS, destroying LCP.
The LCP element on product pages is typically the main product image. On category pages, it's the first visible product image or the hero banner.
priority prop on the <Image> componentpriority disables lazy loading automatically// next.config.js — ISR for product pages
// In page.tsx:
export const revalidate = 300; // 5 minutes — fresh enough for buyers
width/height or fill with a sized container (see seo-images skill)next/font with display: swap and size-adjust to prevent layout shift// GOOD — font with swap and size-adjust
import { Inter } from 'next/font/google';
const inter = Inter({ subsets: ['latin'], display: 'swap' });
// BAD — loading fonts via <link> causes FOUT and CLS
<link href="https://fonts.googleapis.com/css2?family=Inter" rel="stylesheet" />
useTransition for non-urgent UI updates (filter changes, sort changes on category pages)"use client";
import { useTransition } from 'react';
function CategoryFilters({ onFilterChange }: Props) {
const [isPending, startTransition] = useTransition();
function handleFilterChange(filter: string) {
startTransition(() => {
onFilterChange(filter); // Non-urgent, won't block interaction
});
}
}
GTM is a significant performance cost if loaded incorrectly.
// GOOD — load GTM after page is interactive
import Script from 'next/script';
<Script
id="gtm"
strategy="afterInteractive"
dangerouslySetInnerHTML={{
__html: `(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
'https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);
})(window,document,'script','dataLayer', process.env.NEXT_PUBLIC_GTM_ID);`,
}}
/>
// BAD — blocks page rendering
<Script strategy="beforeInteractive" src={`https://www.googletagmanager.com/gtm.js?id=${process.env.NEXT_PUBLIC_GTM_ID}`} />
Rules:
strategy="afterInteractive" for GTMbeforeInteractive for analytics/tracking scriptsNEXT_PUBLIC_GTM_ID), never hardcode itstrategy="lazyOnload" for non-critical third-party scripts (chat widgets, surveys)next/font — it self-hosts fonts, eliminating external requestsdisplay: 'swap' — shows fallback text immediately, swaps to custom font when loadedimport { Inter } from 'next/font/google';
const inter = Inter({
subsets: ['latin', 'latin-ext'], // Include latin-ext for European languages
display: 'swap',
weight: ['400', '500', '700'], // Only weights actually used
});
import { format } from 'date-fns' not import dayjs from 'dayjs' if only formatting@next/bundle-analyzer to find oversized dependencies// GOOD — dynamic import for below-fold content
import dynamic from 'next/dynamic';
const ProductReviews = dynamic(() => import('./ProductReviews'), {
loading: () => <ReviewsSkeleton />,
});
const RelatedProducts = dynamic(() => import('./RelatedProducts'));
Use React Suspense to stream content progressively — the page shell renders immediately while slower data loads:
import { Suspense } from 'react';
export default function ProductPage({ params }: Props) {
return (
<>
<ProductHeader id={params.id} /> {/* Renders immediately */}
<Suspense fallback={<PriceSkeleton />}>
<ProductPrice id={params.id} /> {/* Streams when ready */}
</Suspense>
<Suspense fallback={<ReviewsSkeleton />}>
<ProductReviews id={params.id} /> {/* Streams when ready */}
</Suspense>
</>
);
}
<link> — use next/font instead