Skip to content

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

  1. Audit dependencies: check sizes on bundlephobia.com before adding
  2. Replace heavy libraries: date-fns (not moment.js), clsx (not classnames)
  3. Tree-shake imports: import { format } from "date-fns" not import * as dateFns
  4. Server Components: keep data processing on server — zero bundle impact
  5. 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