1. Contextual Introduction
In recent years, Google has made Core Web Vitals (CWV) the primary quality criterion for user experience and, consequently, for SEO ranking. The three metrics – Largest Contentful Paint (LCP), First Input Delay (FID) and Cumulative Layout Shift (CLS) – measure, respectively, perceived load speed, responsiveness, and visual stability of a page. A site that does not meet the recommended thresholds (LCP < 2.5 s, FID < 100 ms, CLS < 0.1) suffers ranking penalties and, worse, loses conversions.
According to Google Search Console 2023, more than 55 % of websites with traffic above 10 k monthly visits have at least one Core Web Vital out of range. In the React and Next.js ecosystem, the flexibility the framework provides for mixing static and dynamic rendering often leads to architectural decisions that directly impact these metrics.
In 2021, Next.js 12 introduced Middleware, Edge Functions, and improvements to static generation, providing specific tools to attack CWV bottlenecks. However, the huge number of possible configurations creates confusion: should I use getStaticProps or getServerSideProps? How do I optimize images without sacrificing quality? Which JavaScript loading strategy minimizes FID?
Answering these questions today is critical because the Core Web Vitals Score is now a permanent ranking factor, and mobile users expect sub‑second load times. In this article we will break down a Next.js architecture focused on CWV, show step‑by‑step production‑ready implementations, and analyze real‑world use cases where optimization made a measurable difference in business metrics.
2. Foundations and Architecture
2.1. What Core Web Vitals Measure
| Metric | What it evaluates | Recommended threshold | Direct impact on Next.js |
|---|---|---|---|
| LCP | Time the largest visible element (image, video, or text block) takes to load | ≤ 2.5 s | HTML rendering, image optimization, use of ISR |
| FID | Latency between the first user interaction and the main‑thread response | ≤ 100 ms | JavaScript bundle size, use of dynamic() and React.lazy |
| CLS | Sum of all unexpected layout shifts during loading | < 0.1 | Avoid fonts without font-display, reserve space for images and ads |
2.2. Architectural Principles for CWV in Next.js
- Static rendering whenever possible –
getStaticProps+ ISR generate HTML at build time, reducing server response time and therefore LCP. - Deferred loading and prioritization – Use the
next/imagecomponent withpriorityonly for the hero andloading="lazy"for everything else, avoiding unexpected shifts (CLS). - JavaScript fragmentation –
dynamic()lets you split the bundle and load modules on demand, lowering FID. - Edge caching and middleware – Responses from the edge network (Vercel Edge, Cloudflare Workers) cut network latency, improving LCP and FID simultaneously.
- Observability and real‑time metrics – Integrate the Web Vitals API (
web-vitals) to capture real production data and close the continuous‑improvement loop.
2.3. Conceptual Diagram (described in text)
- Client → CDN Edge (static HTML cache + assets) → Next.js Server (Middleware → API Routes) → Database / CMS.
- The rendering flow follows two paths:
- ISR: pre‑generated static HTML + background revalidation.
- SSR: on‑demand HTML for highly dynamic pages.
next/imageintercepts image requests, serves optimized versions (WebP, AVIF) and manages placeholders.- Dynamic Imports: each page imports only the critical components; the rest are loaded on demand.
2.4. Critical Trade‑offs
| Decision | Advantage | Disadvantage |
|---|---|---|
| ISR vs SSR | ISR: lower response time, less server load. | ISR: data can be slightly stale (up to revalidate). |
| next/image with placeholders | Improves CLS and LCP by reserving space and loading images progressively. | Image‑processing overhead at build time; requires loader configuration. |
| Edge Functions | Minimal latency, global scalability. | Execution‑time and bundle‑size limits on the edge. |
| Dynamic UI component import | Reduces FID by loading only what’s needed. | Possible flash of unstyled content (FOUC) if not handled correctly. |
3. Practical Implementation
Below we present five typical evolutions that go from a naïve solution to a CWV‑ready architecture. Each block includes imports, types, error handling, and comments (now in English).
3.1. Image Optimization
3.1.1. Basic version (plain <img>)
// pages/index.tsx
import React from 'react';
export default function HomePage() {
return (
<section>
<h1>Welcome to our catalog</h1>
{/* Image without optimization or space reservation */}
<img src="/images/hero.jpg" alt="Featured product" />
<p>Discover our offers.</p>
</section>
);
}
Issues: No lazy loading, the image downloads at full size and can cause CLS if the browser takes time to compute its dimensions.
3.1.2. Intermediate version (next/image with layout="responsive")
// pages/index.tsx
import React from 'react';
import Image from 'next/image';
import path from 'path';
export default function HomePage() {
return (
<section>
<h1>Welcome to our catalog</h1>
{/* next/image reserves space and serves an optimized version */}
<Image
src="/images/hero.jpg"
alt="Featured product"
width={1600}
height={900}
layout="responsive"
priority // early load because this is the hero
/>
<p>Discover our offers.</p>
</section>
);
}
Improvements: next/image automatically generates WebP/AVIF, reserves space, and allows priority. A placeholder is still missing to avoid white‑flash content.
3.1.3. Optimized version (blur placeholder + lazy loading)
// pages/index.tsx
import React from 'react';
import Image from 'next/image';
import { GetStaticProps } from 'next';
import fs from 'fs';
import path from 'path';
/**
* Retrieves the blur (base64) version path for the placeholder.
* In production it is recommended to use an external loader (Imgix, Cloudinary).
*/
export const getStaticProps: GetStaticProps = async () => {
const placeholderPath = path.join(process.cwd(), 'public', 'images', 'hero-blur.jpg');
const placeholderBuffer = await fs.promises.readFile(placeholderPath);
const placeholderBase64 = placeholderBuffer.toString('base64');
return {
props: {
placeholder: `data:image/jpeg;base64,${placeholderBase64}`,
},
};
};
type Props = {
placeholder: string;
};
export default function HomePage({ placeholder }: Props) {
return (
<section>
<h1>Welcome to our catalog</h1>
<Image
src="/images/hero.jpg"
alt="Featured product"
width={1600}
height={900}
layout="responsive"
placeholder="blur"
blurDataURL={placeholder}
priority // hero remains prioritized
/>
<p>Discover our offers.</p>
</section>
);
}
Benefits:
- CLS is eliminated because space is reserved and the placeholder maintains the aspect ratio.
- LCP improves by serving an optimized, prioritized version.
- The code includes implicit error handling via
await fs.promises.readFile; if it fails, Next.js will throw an exception that stops the build, preventing broken deployments.
3.2. Data Strategy: From SSR to ISR + SWR
3.2.1. SSR with getServerSideProps (naïve)
// pages/product/[id].tsx
import { GetServerSideProps, NextPage } from 'next';
import { useRouter } from 'next/router';
import axios from 'axios';
type Product = {
id: string;
name: string;
price: number;
description: string;
};
type Props = {
product: Product;
};
export const getServerSideProps: GetServerSideProps<Props> = async (context) => {
const { id } = context.params!;
try {
const { data } = await axios.get<Product>(`https://api.example.com/products/${id}`);
return { props: { product: data } };
} catch (error) {
// Return 404 if the product does not exist
return { notFound: true };
}
};
const ProductPage: NextPage<Props> = ({ product }) => {
const router = useRouter();
if (router.isFallback) return <p>Loading...</p>;
return (
<article>
<h1>{product.name}</h1>
<p>{product.description}</p>
<strong>${product.price}</strong>
</article>
);
};
export default ProductPage;
Problems: Every visit triggers an API request, increasing response time (LCP) and server load. Moreover, the bundle includes axios even though it is only used on the server.
3.2.2. ISR with getStaticProps + revalidation
// pages/product/[id].tsx
import { GetStaticPaths, GetStaticProps, NextPage } from 'next';
import axios from 'axios';
import { useRouter } from 'next/router';
import { SWRConfig } from 'swr';
import useSWR from 'swr';
type Product = {
id: string;
name: string;
price: number;
description: string;
};
type Props = {
fallback: { [key: string]: Product };
};
const fetcher = (url: string) => axios.get(url).then((res) => res.data);
export const getStaticPaths: GetStaticPaths = async () => {
// Pre‑generate only the most popular products
const { data: popular } = await axios.get<string[]>(
'https://api.example.com/products/popular-ids'
);
const paths = popular.map((id) => ({ params: { id } }));
return { paths, fallback: true };
};
export const getStaticProps: GetStaticProps<Props> = async (context) => {
const { id } = context.params!;
try {
const { data } = await axios.get<Product>(`https://api.example.com/products/${id}`);
return {
props: {
fallback: {
[`/api/products/${id}`]: data,
},
},
revalidate: 60, // 1 minute freshness
};
} catch {
return { notFound: true };
}
};
const ProductPage: NextPage<Props> = ({ fallback }) => {
const router = useRouter();
const { id } = router.query as { id: string };
const { data: product, error } = useSWR<Product>(`/api/products/${id}`, fetcher, {
fallbackData: fallback[`/api/products/${id}`],
});
if (error) return <p>Error loading the product.</p>;
if (!product) return <p>Loading...</p>;
return (
<article>
<h1>{product.name}</h1>
<p>{product.description}</p>
<strong>${product.price}</strong>
</article>
);
};
export default ProductPage;
Improvements:
- LCP drops because the HTML is pre‑generated and served from the CDN.
- FID decreases as only the necessary bundle is loaded; the rest of the data updates in the background with SWR.
- Revalidation guarantees freshness without sacrificing performance.
3.2.3. Final version with Edge Middleware and API caching
// middleware.ts (Edge)
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
// Cache API responses for 30 seconds at the edge
export async function middleware(request: NextRequest) {
const url = request.nextUrl.clone();
// Only intercept product API routes
if (url.pathname.startsWith('/api/products/')) {
const response = await fetch(request);
const cloned = response.clone();
// Add cache‑control headers
const headers = new Headers(cloned.headers);
headers.set('Cache-Control', 'public, max-age=30, stale-while-revalidate=60');
return new NextResponse(cloned.body, {
status: cloned.status,
headers,
});
}
// Pass through all other requests unchanged
return NextResponse.next();
}
// pages/api/products/[id].ts
import type { NextApiRequest, NextApiResponse } from 'next';
import axios from 'axios';
type Product = {
id: string;
name: string;
price: number;
description: string;
};
export default async function handler(
req: NextApiRequest,
res: NextApiResponse<Product | { error: string }>
) {
const { id } = req.query;
if (typeof id !== 'string') {
res.status(400).json({ error: 'Invalid ID' });
return;
}
try {
const { data } = await axios.get<Product>(`https://api.example.com/products/${id}`);
// Respond with cache controlled by middleware
res.setHeader('Cache-Control', 'public, max-age=0, s-maxage=30, stale-while-revalidate=60');
res.status(200).json(data);
} catch (err) {
res.status(404).json({ error: 'Product not found' });
}
}
Impact: The Edge Middleware serves API responses directly from the edge layer, reducing network latency (LCP) and preventing overload on the origin server. The stale‑while‑revalidate strategy keeps the endpoint available even when the origin is under heavy load.
3.3. Reducing FID with Deferred JavaScript Loading
3.3.1. Monolithic bundle (naïve)
// pages/dashboard.tsx
import React, { useEffect, useState } from 'react';
import Chart from 'chart.js';
import { getAnalytics } from '../lib/analytics';
export default function Dashboard() {
const [data, setData] = useState<number[]>([]);
useEffect(() => {
// Heavy client‑side load
getAnalytics().then(setData);
const ctx = document.getElementById('myChart') as HTMLCanvasElement;
if (ctx) {
new Chart(ctx, {
type: 'line',
data: {
labels: data.map((_, i) => `Point ${i}`),
datasets: [{ label: 'Analytics', data }],
},
});
}
}, [data]);
return (
<section>
<h1>Dashboard</h1>
<canvas id="myChart" />
</section>
);
}
Problems: Importing chart.js directly inflates the initial JavaScript bundle, increasing main‑thread work and hurting FID.
3.3.2. Dynamic import with next/dynamic
// pages/dashboard.tsx
import React, { useEffect, useState } from 'react';
import dynamic from 'next/dynamic';
import { getAnalytics } from '../lib/analytics';
// Load Chart.js only on the client and after the component mounts
const Chart = dynamic(() => import('chart.js/auto'), { ssr: false });
export default function Dashboard() {
const [data, setData] = useState<number[]>([]);
useEffect(() => {
getAnalytics().then(setData);
}, []);
useEffect(() => {
if (!data.length) return;
const ctx = document.getElementById('myChart') as HTMLCanvasElement;
if (ctx) {
// Chart.js is now loaded lazily, reducing initial bundle size
new Chart(ctx, {
type: 'line',
data: {
labels: data.map((_, i) => `Point ${i}`),
datasets: [{ label: 'Analytics', data }],
},
});
}
}, [data]);
return (
<section>
<h1>Dashboard</h1>
<canvas id="myChart" />
</section>
);
}
Improvements:
- The heavy
chart.jslibrary is excluded from the initial bundle and fetched only when the component mounts, lowering main‑thread work and improving FID.
