Next.js — Performance¶
OWNER: floris, floor
ALSO_USED_BY: urszula, maxim, alexander
LAST_VERIFIED: 2026-03-26
GE_STACK_VERSION: next@15.5.x
Overview¶
Performance targets for all GE client projects. Core Web Vitals are hard requirements,
not suggestions. Every project must meet thresholds before go-live.
Server Components + streaming + proper caching = fast by default.
Core Web Vitals Targets (GE Mandatory)¶
| Metric | Target | Measurement | What It Means |
|---|---|---|---|
| LCP | ≤ 2.5s | 75th percentile | Largest visible content painted |
| INP | ≤ 200ms | 75th percentile | Interaction to Next Paint (replaced FID) |
| CLS | ≤ 0.1 | 75th percentile | Cumulative Layout Shift |
| TTFB | ≤ 800ms | 75th percentile | Time to First Byte |
| FCP | ≤ 1.8s | 75th percentile | First Contentful Paint |
CHECK: all pages meet LCP ≤ 2.5s, INP ≤ 200ms, CLS ≤ 0.1
IF: any metric fails at 75th percentile
THEN: page is NOT production-ready — fix before deployment
TOOL: Lighthouse CI in pipeline
TOOL: useReportWebVitals hook for RUM (Real User Monitoring)
RUN: npx next build && npx next start then npx lighthouse http://localhost:3000 --output=json
Image Optimization (next/image)¶
import Image from "next/image";
// Hero image — above the fold, priority loading
<Image
src="/hero.jpg"
alt="Project dashboard"
width={1200}
height={630}
priority // Preload, no lazy loading
sizes="100vw"
quality={85}
/>
// Content image — below the fold, lazy loaded (default)
<Image
src="/feature.jpg"
alt="Feature screenshot"
width={600}
height={400}
sizes="(max-width: 768px) 100vw, 50vw"
/>
CHECK: priority is set on above-the-fold images (hero, logo)
CHECK: width and height are always set (prevents CLS)
CHECK: sizes attribute is set for responsive images
CHECK: alt text is meaningful (accessibility + SEO)
CHECK: formats: ["image/avif", "image/webp"] in next.config
ANTI_PATTERN: using <img> tag directly
FIX: always use next/image — automatic optimization, lazy loading, format conversion
ANTI_PATTERN: fill prop without sizes attribute
FIX: add sizes — without it, browser assumes image is 100vw (downloads largest)
ANTI_PATTERN: importing large images from public/ without optimization
FIX: use next/image with remote patterns or local imports
GE_CONVENTION: serve images as AVIF with WebP fallback (configured in next.config)
GE_CONVENTION: hero images max 200KB after optimization
GE_CONVENTION: thumbnails max 50KB
Font Optimization¶
// src/app/layout.tsx
import { Inter } from "next/font/google";
const inter = Inter({
subsets: ["latin"],
display: "swap", // Prevents FOIT (Flash of Invisible Text)
variable: "--font-inter", // CSS variable for Tailwind
});
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="nl" className={inter.variable}>
<body>{children}</body>
</html>
);
}
// tailwind.config.ts
module.exports = {
theme: {
extend: {
fontFamily: {
sans: ["var(--font-inter)", "system-ui", "sans-serif"],
},
},
},
};
CHECK: fonts loaded via next/font (self-hosted, no external requests)
CHECK: display: "swap" set (prevents invisible text during load)
CHECK: only required subsets loaded (reduces font file size)
ANTI_PATTERN: loading fonts from Google Fonts CDN via <link>
FIX: use next/font/google — self-hosts fonts, eliminates external request
Dynamic Imports & Lazy Loading¶
import dynamic from "next/dynamic";
// Heavy client component — loaded only when needed
const Chart = dynamic(() => import("@/components/charts/revenue-chart"), {
loading: () => <Skeleton className="h-64 w-full" />,
ssr: false, // Only if component truly cannot render on server
});
// Code-split a section that's below the fold
const FAQ = dynamic(() => import("@/components/marketing/faq"));
IF: component is heavy (charts, editors, maps) and below the fold
THEN: use dynamic() import with loading skeleton
IF: component uses browser-only APIs (canvas, WebGL)
THEN: use dynamic() with ssr: false
ANTI_PATTERN: ssr: false on components that CAN server-render
FIX: only use ssr: false when component genuinely needs browser APIs
ANTI_PATTERN: lazy loading everything
FIX: only lazy load heavy components below the fold — above-the-fold must be instant
Partial Prerendering (PPR)¶
PPR combines static shell + dynamic holes in a single HTTP response.
// next.config.ts
experimental: {
ppr: "incremental",
}
// Enable per-route
export const experimental_ppr = true;
import { Suspense } from "react";
export const experimental_ppr = true;
export default function ProductPage() {
return (
<div>
{/* Static shell — prerendered at build time */}
<h1>Product Name</h1>
<p>Product description...</p>
{/* Dynamic hole — streamed at request time */}
<Suspense fallback={<Skeleton className="h-8 w-24" />}>
<Price /> {/* Fetches current price, personalized */}
</Suspense>
{/* Dynamic hole */}
<Suspense fallback={<Skeleton className="h-32" />}>
<Reviews /> {/* Fetches latest reviews */}
</Suspense>
</div>
);
}
IF: page has static structure + dynamic personalized content
THEN: use PPR — instant static shell, streamed dynamic sections
CHECK: dynamic components are wrapped in <Suspense> with skeleton fallbacks
CHECK: experimental_ppr = true is exported from the route
Bundle Size Optimization¶
Analysis¶
RUN: ANALYZE=true npx next build
// next.config.ts — enable bundle analyzer
import withBundleAnalyzer from "@next/bundle-analyzer";
const config = withBundleAnalyzer({
enabled: process.env.ANALYZE === "true",
})({
// ... rest of config
});
Reduction Strategies¶
- Audit dependencies: check sizes on bundlephobia.com before adding
- Replace heavy libraries: date-fns (not moment.js), clsx (not classnames)
- Tree-shake imports:
import { format } from "date-fns"notimport * as dateFns - Server Components: keep data processing on server — zero bundle impact
- Dynamic imports: heavy client libs loaded on demand
TOOL: npx @next/bundle-analyzer — visual bundle breakdown
TOOL: npx next info — shows build output sizes
GE_TARGETS:
- First Load JS (shared): ≤ 90KB gzipped
- Per-page JS: ≤ 50KB gzipped
- Total client JS: minimize — every KB adds to INP
CHECK: no page exceeds 50KB First Load JS in build output
IF: build shows red/yellow warnings on bundle size
THEN: investigate and reduce before deploying
Caching Headers for k3s Deployment¶
// next.config.ts — headers for assets
headers: async () => [
{
source: "/_next/static/:path*",
headers: [
{ key: "Cache-Control", value: "public, max-age=31536000, immutable" },
],
},
{
source: "/images/:path*",
headers: [
{ key: "Cache-Control", value: "public, max-age=86400, stale-while-revalidate=604800" },
],
},
],
CHECK: static assets (/_next/static/) have immutable cache headers
CHECK: images have reasonable cache with stale-while-revalidate
CHECK: HTML responses do NOT have long cache (dynamic content)
Monitoring in Production¶
// src/app/layout.tsx or a client component
"use client";
import { useReportWebVitals } from "next/web-vitals";
export function WebVitals() {
useReportWebVitals((metric) => {
// Send to GE's analytics endpoint (EU-hosted)
fetch("/api/analytics/vitals", {
method: "POST",
body: JSON.stringify({
name: metric.name,
value: metric.value,
rating: metric.rating,
navigationType: metric.navigationType,
}),
});
});
return null;
}
GE_CONVENTION: all RUM data stays EU-hosted — no Vercel Analytics, no Google Analytics
GE_CONVENTION: use self-hosted analytics endpoint
Anti-Patterns¶
ANTI_PATTERN: no performance budget — shipping whatever builds
FIX: set explicit targets, fail CI if exceeded
ANTI_PATTERN: loading all fonts/weights upfront
FIX: load only needed weights, use next/font with specific subsets
ANTI_PATTERN: unoptimized third-party scripts (analytics, chat widgets)
FIX: use next/script with strategy="lazyOnload" for non-critical scripts
ANTI_PATTERN: rendering large lists without virtualization
FIX: use react-window or @tanstack/react-virtual for 100+ item lists
Cross-References¶
READ_ALSO: wiki/docs/stack/nextjs/index.md
READ_ALSO: wiki/docs/stack/nextjs/server-components.md
READ_ALSO: wiki/docs/stack/nextjs/data-fetching.md
READ_ALSO: wiki/docs/stack/nextjs/pitfalls.md
READ_ALSO: wiki/docs/stack/nextjs/checklist.md