What are Core Web Vitals?
Core Web Vitals are a set of three metrics defined by Google to measure the quality of the user experience on a web page. Since 2021, these metrics have been a ranking factor in Google search results.
LCP
Largest Contentful Paint
Measures the time to display the largest visible element in the viewport. Target: under 2.5 seconds.
CLS
Cumulative Layout Shift
Measures the visual stability of the page — unexpected layout shifts. Target: score below 0.1.
INP
Interaction to Next Paint
Measures the delay between a user interaction (click, keypress) and the visual update. Target: under 200 milliseconds.
Why these metrics matter
Google has used Core Web Vitals as a ranking signal since June 2021. With equivalent content, a site with better Core Web Vitals will be favored in search results. Beyond SEO, these metrics directly measure the experience perceived by the user: wait time, visual stability, and interface responsiveness.
The structural advantage of headless
The headless architecture offers a structural advantage for Core Web Vitals over traditional WordPress. The backend/frontend separation lets you optimize each layer independently.
Measuring Core Web Vitals
Before optimizing, you need to measure. Several tools can evaluate Core Web Vitals:
Lab data (controlled environment):
- Lighthouse (built into Chrome DevTools): performance audit with detailed scores
- PageSpeed Insights: URL-accessible analysis combining lab and field data
- WebPageTest: detailed tests with filmstrip and waterfall
Field data (real users):
- Chrome User Experience Report (CrUX): real data collected from Chrome users
- web-vitals (JavaScript library): collect metrics in your own analytics tool
To integrate Core Web Vitals collection into a Next.js project:
// app/layout.tsx — Web Vitals collection
import { Analytics } from '@vercel/analytics/react';
import { SpeedInsights } from '@vercel/speed-insights/next';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
{children}
<Analytics />
<SpeedInsights />
</body>
</html>
);
}
Optimizing LCP (Largest Contentful Paint)
LCP measures the time to display the largest visible element: typically the hero image or page title. The target is an LCP under 2.5 seconds.
Image optimization with next/image
The next/image component automatically applies several optimizations:
import Image from 'next/image';
// Above-the-fold image: load with priority
<Image
src={post._embedded['wp:featuredmedia'][0].source_url}
alt={post._embedded['wp:featuredmedia'][0].alt_text}
width={1200}
height={630}
priority // Disables lazy loading and loads the image with priority
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 1200px"
/>
priority attribute: use only for the main above-the-fold image. This option disables lazy loading and adds a <link rel="preload"> to the <head>.
sizes attribute: tells the browser which image size to load based on the screen width. Without this attribute, the browser loads the maximum size.
Font optimization with next/font
Font loading can delay LCP if the main text waits for the font before displaying. next/font loads fonts optimally:
// app/layout.tsx
import { Inter } from 'next/font/google';
const inter = Inter({
subsets: ['latin'],
display: 'swap', // Display text immediately with a fallback font
});
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className={inter.className}>
<body>{children}</body>
</html>
);
}
next/font downloads the font at build time and serves it from the same domain as the site (self-hosting). There is no request to Google Fonts servers, which removes a network round trip.
Reducing server response time
Use ISR instead of SSR
SSR generates HTML on every request, which includes the time of the WordPress API call. ISR serves a pre-generated page from the CDN, with TTFB under 50ms.
Deploy on a global CDN
Platforms like Vercel, Netlify, or Cloudflare Pages deploy static pages on a global server network. The visitor receives the page from the geographically closest server.
Optimize WordPress API responses
Limit the fields returned by the API to reduce response size and processing time.
// Fetch only the necessary fields
const res = await fetch(
`${API_URL}/posts?_fields=id,slug,title,excerpt,date&per_page=10`
);
Optimizing CLS (Cumulative Layout Shift)
CLS measures unexpected layout shifts. A score above 0.1 indicates that elements on the page change position after initial load, which degrades user experience.
Common CLS causes and solutions
Images without explicit dimensions: the browser doesn't know the image size before it loads, which causes a shift when it appears.
// Problem: no dimensions → the browser reserves 0px then recalculates
<img src="/image.jpg" alt="Photo" />
// Solution: next/image enforces dimensions and reserves space
<Image src="/image.jpg" alt="Photo" width={800} height={450} />
Web fonts with flash of invisible text (FOIT): the default font has a different line-height than the web font, which causes a shift when the final font is displayed.
/* Solution: font-display swap + metrics override */
/* next/font handles this automatically */
Dynamically injected content: ads, embeds, and client-side loaded components can shift existing content.
// Solution: reserve space with a minimum height
<div style={{ minHeight: '250px' }}>
<DynamicComponent />
</div>
Optimizing INP (Interaction to Next Paint)
INP measures the delay between a user interaction (click, key, tap) and the resulting visual update. The target is an INP under 200 milliseconds.
Reducing the cost of hydration
In a Next.js architecture, SSG and ISR pages are first served as static HTML, then React "hydrates" the page to make it interactive. This hydration runs JavaScript and can block interactions.
// Use Server Components by default (no hydration)
// app/blog/[slug]/page.tsx — Server Component
export default async function ArticlePage({ params }: { params: { slug: string } }) {
const post = await getPostBySlug(params.slug);
return (
<article>
<h1 dangerouslySetInnerHTML={{ __html: post.title.rendered }} />
<div dangerouslySetInnerHTML={{ __html: post.content.rendered }} />
{/* Only this component is hydrated on the client */}
<LikeButton postId={post.id} />
</article>
);
}
App Router Server Components don't ship JavaScript to the browser. Only components marked 'use client' are hydrated. Limit the number of Client Components to reduce hydration cost.
Code-splitting and lazy loading
Next.js automatically splits JavaScript by route (code-splitting). For heavy components that aren't visible immediately, use dynamic loading:
import dynamic from 'next/dynamic';
// The component is only loaded when needed
const HeavyChart = dynamic(() => import('@/components/HeavyChart'), {
loading: () => <div style={{ height: '400px' }}>Loading chart...</div>,
ssr: false, // Don't render on the server if the component is purely interactive
});
Cache strategies
Caching is the most effective performance lever. Several cache levels apply in a headless Next.js architecture:
Configuring cache headers
// next.config.js — Cache headers for static assets
const nextConfig = {
async headers() {
return [
{
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',
},
],
},
];
},
};
Optimizing WordPress API responses
The size and response time of the WordPress API directly impact build time (SSG) and regeneration time (ISR).
Reducing response size:
// Select only the necessary fields with _fields
const res = await fetch(`${API_URL}/posts?_fields=id,slug,title,excerpt,featured_media,date`);
// Limit the number of results with per_page
const res = await fetch(`${API_URL}/posts?per_page=10&page=1`);
On the WordPress side: install an object cache plugin (Redis Object Cache or Memcached) to speed up database queries. The WP Super Cache plugin is not useful in headless (it caches the PHP HTML, not API responses).
JavaScript bundle analysis
A JavaScript bundle that's too large slows down loading and hydration. Analyze the bundle composition with @next/bundle-analyzer:
npm install @next/bundle-analyzer
// next.config.js
const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.ANALYZE === 'true',
});
module.exports = withBundleAnalyzer(nextConfig);
# Generate the analysis report
ANALYZE=true npm run build
Practical rules for bundle size
For a standard headless WordPress site, the JavaScript bundle per route should not exceed 100 KB (gzip-compressed). If a route exceeds this threshold, check that heavy libraries (editors, charts, maps) are loaded dynamically rather than bundled in the main chunk. Use tree-shaking by importing only the functions you need (specific imports rather than global imports).
Performance checklist
Core Web Vitals checklist for headless WordPress + Next.js
LCP (< 2.5s):
- Use
next/imagewith thepriorityattribute for the main image - Use
next/fontfor optimized font loading - Deploy with ISR on a global CDN
- Limit WordPress API fields with
_fields
CLS (< 0.1):
- Define dimensions (
width,height) on every image - Use
next/fontwithdisplay: swapto avoid FOIT - Reserve space for dynamically loaded components with
minHeight
INP (< 200ms):
- Use Server Components by default (no hydration)
- Limit Client Components to interactive elements
- Dynamically load heavy components with
dynamic() - Keep the JavaScript bundle per route under 100 KB (gzip)
Cache:
- Configure
Cache-Controlheaders for static assets - Use ISR with on-demand revalidation for content pages
- Install an object cache (Redis) on the WordPress server
Securing a headless WordPress
Article suivantTechnical SEO for headless architecture