A high Lighthouse score is easy to game and easy to lose. Here's how we build marketing sites that are fast for real users, and stay fast as they grow.
- Optimise for field data (real users on real devices), not the lab score on your development machine — the gap between them is where sites go slow.
- The biggest lever is shipping less JavaScript. Server components plus small, deliberate client islands make a marketing page fast almost by default.
- Fast at launch is not fast forever. A performance budget enforced in CI is the only thing that stops quiet decay from third-party scripts and stray images.
A fast website compounds. It ranks better, converts better, and reads as more credible — a slow, janky site quietly signals that the company behind it might be slow and janky too. And yet a great many business sites are slow, including plenty that were "optimised" once and drifted back.
The trouble is that performance is easy to measure badly. A perfect Lighthouse score on your laptop, on your office fibre, tells you almost nothing about how the site feels to someone on a three-year-old Android on a train. The gap between those two numbers is the entire problem. This piece is about closing it in a Next.js build, and — the part everyone skips — keeping it closed once the marketing team starts adding things.
Lab data lies; field data doesn't
There are two kinds of performance measurement, and confusing them is the root cause of most "but it scored 100" conversations.
- Lab data is a synthetic run in a controlled environment — Lighthouse in your terminal, PageSpeed Insights' simulated throttling. Reproducible, good for catching regressions, but it is one device on one connection that is probably nothing like your median visitor.
- Field data is what real users actually experienced, gathered from their browsers. Google exposes this as the Chrome User Experience Report (CrUX), and it is the data that feeds search ranking.
You want to optimise the field data, and to do that you have to measure it. The browser gives you the real numbers through the web-vitals library; ship them to whatever you already use for analytics.
import { onLCP, onINP, onCLS } from "web-vitals";
function report(metric: { name: string; value: number; rating: string }) {
// Send to your own endpoint; keep the payload tiny and non-blocking.
navigator.sendBeacon(
"/api/vitals",
JSON.stringify({ name: metric.name, value: metric.value, rating: metric.rating }),
);
}
onLCP(report);
onINP(report);
onCLS(report);The three metrics worth watching map cleanly onto felt experience:
| Metric | What it captures | The feeling | Good |
|---|---|---|---|
| LCP | Largest Contentful Paint — when the main content appears | "Is it loading?" | ≤ 2.5s |
| INP | Interaction to Next Paint — response to a tap or click | "Is it responsive?" | ≤ 200ms |
| CLS | Cumulative Layout Shift — how much things jump around | "Why did the button move?" | ≤ 0.1 |
The biggest lever is the JavaScript you don't send
On most marketing sites the single heaviest cost is JavaScript, and it is expensive twice: once to download, and again — more painfully — to parse and execute on the user's CPU. That execution tax falls hardest on the cheap phones where your slowest users already are. A hero image is bytes over the wire; a megabyte of JavaScript is bytes and main-thread time.
The App Router's default is a React Server Component: it renders on the server and ships HTML with no client-side JavaScript for that component at all. You opt into interactivity deliberately, per component, with "use client". A marketing page is mostly content, so most of it should never become client JavaScript.
// app/page.tsx — a Server Component by default. Zero JS shipped for this.
import { PricingTable } from "@/components/pricing-table"; // also server-rendered
import { NewsletterForm } from "@/components/newsletter-form"; // a client island
export default function Home() {
return (
<main>
<Hero /> {/* static content — stays HTML */}
<PricingTable /> {/* static content — stays HTML */}
<NewsletterForm /> {/* the one genuinely interactive bit */}
</main>
);
}The interactive island stays small and self-contained. Only this file carries "use client", so only its subtree ships to the browser.
"use client";
import { useState } from "react";
export function NewsletterForm() {
const [email, setEmail] = useState("");
return (
<form action="/api/subscribe" method="post">
<input value={email} onChange={(e) => setEmail(e.target.value)} type="email" />
<button type="submit">Subscribe</button>
</form>
);
}The mental model is islands of interactivity in a sea of static HTML. Draw the boundary as low in the tree as you can: a "use client" at the top of a layout drags everything beneath it into the client bundle, which is the most common way a Next.js site quietly gets heavy.
┌──────────────────────── page (server) ────────────────────────┐
│ Hero (server) PricingTable (server) Footer (server) │
│ │
│ ┌─────────────────────┐ │
│ │ NewsletterForm │ ◀── "use client" │
│ │ (client island) │ only here │
│ └─────────────────────┘ │
└────────────────────────────────────────────────────────────────┘
everything outside the island ships as HTML, 0 JSImages are usually the real weight
After JavaScript, images are the heaviest thing on a typical page and the easiest to get wrong. Next.js next/image handles the hard parts — modern formats, correct sizing, lazy-loading, reserved space — but only if you feed it properly.
import Image from "next/image";
import hero from "@/public/hero.jpg";
export function Hero() {
return (
<Image
src={hero} // static import gives width/height automatically
alt="Team at work"
priority // this is the LCP element — load it eagerly, preload it
sizes="100vw" // tell the browser how big it renders, so it picks the right file
placeholder="blur"
/>
);
}Two details do most of the work. First, priority on the LCP image — usually the hero — tells Next.js to preload it instead of lazy-loading it, which directly improves LCP. Everything below the fold should stay lazy (the default), so it doesn't compete for bandwidth during the initial load.
Second, sizes. Without it the browser assumes the image fills the viewport and downloads a needlessly large file on a phone. With an accurate sizes, it picks the smallest file that still looks sharp. Getting sizes wrong is the quiet reason a site with next/image everywhere is still shipping desktop-sized images to mobiles.
And every image needs intrinsic dimensions — which static imports provide for free — so the browser reserves the space before the pixels arrive. That reserved space is most of the battle against layout shift.
Fonts: a small detail with outsized effects
Web fonts cause two specific, visible problems: invisible or unstyled text while the font downloads, and a layout jump when it swaps in. next/font fixes both by self-hosting the font at build time (no third-party round-trip) and generating a size-adjusted fallback so the swap doesn't shift the layout.
import { Inter } from "next/font/google";
export const inter = Inter({
subsets: ["latin"], // ship only the glyphs you use
display: "swap", // show fallback text immediately, swap when ready
variable: "--font-inter",
});Subset to the character sets you actually serve, load only the weights you use, and let display: "swap" keep text readable from the first paint. It is a few lines that removes a flicker every visitor would otherwise see.
Staying fast is the actual hard part
Here is the uncomfortable truth: a site that is fast at launch rarely stays fast on its own. Six months later someone has added a chat widget, three analytics tags, an embedded video, and a couple of "quick" full-resolution images, and it is slow again. Performance decays under normal use unless something actively holds the line.
The fix is to make performance a standing constraint rather than a one-off project — a budget the build enforces, so a regression fails the pipeline instead of reaching production. Lighthouse CI does this against thresholds you set.
{
"ci": {
"assert": {
"assertions": {
"categories:performance": ["error", { "minScore": 0.9 }],
"largest-contentful-paint": ["error", { "maxNumericValue": 2500 }],
"cumulative-layout-shift": ["error", { "maxNumericValue": 0.1 }],
"total-byte-weight": ["warn", { "maxNumericValue": 900000 }]
}
}
}
}Wire that into CI so a pull request that regresses LCP or bloats the bundle gets flagged before merge, not discovered in a CrUX report weeks later.
Why it's worth the discipline
None of this is exotic. It is a small set of well-understood practices applied consistently: ship little JavaScript, draw the client boundary deep, let next/image and next/font do their jobs, measure the field rather than the lab, and defend the result with a budget in CI. The reason so many sites are slow is not that fast is hard — it is that fast is easy to lose and takes ongoing discipline to keep.
Get it right and the payoff is durable: better ranking, higher conversion, and a site that feels as competent as the business behind it. On the web, speed is a form of respect for a visitor's time — and they feel it, whether or not they could name it.