Skip to content
5 min read

How I cut First Contentful Paint on a Next.js blog

How to improve First Contentful Paint in a Next.js app: measuring it, shipping less JavaScript, taming fonts, deferring scripts and serving static HTML.

On this page
  1. FCP is not a Core Web Vital, and that matters
  2. Measure first, and not the thing that feels slow
  3. Ship less JavaScript
  4. The fonts were the problem
  5. Render-blocking resources, in the order they bite
  6. Make the above-the-fold region cheap
  7. Serve it static
  8. Where it landed

I ran this blog through PageSpeed Insights and the mobile score was not where I wanted it. The cause was a slow First Contentful Paint, which on mobile is often the difference between someone reading a post and someone leaving before it renders.

These are the changes that actually moved it, on this site, rather than a generic list.

FCP is not a Core Web Vital, and that matters

Worth clearing up first, because plenty of posts get it wrong and it changes how much you should care.

First Contentful Paint measures how long it takes, from the moment a page starts loading, for the browser to render the first piece of content on screen. Text, an image, anything non-blank. Good is 1.8 seconds or less.

The Core Web Vitals are LCP, CLS and INP. FCP is not one of them. It is a diagnostic: it tells you how fast your HTML, CSS and fonts arrive and render, which is usually why a bad LCP is bad. So fix FCP because it explains your LCP, not because Google is scoring you on it directly.

The distinction between FCP and LCP is worth holding onto too. FCP is "something appeared". LCP is "the biggest above-the-fold thing appeared". On a text-heavy page they are usually driven by the same handful of factors.

Measure first, and not the thing that feels slow

I do not guess at performance. Measure, change one thing, measure again.

PageSpeed Insights or Lighthouse on the mobile profile, since mobile is almost always the bottleneck, then the field data in Search Console over time to see whether it held.

The mistake I see most often is optimizing the thing that feels slow rather than the thing the trace says is slow. Those are different, reliably, and Lighthouse's Opportunities and Diagnostics sections will tell you which is which if you let them.

Ship less JavaScript

React apps get slow when they ship too much JS. The browser downloads, parses and executes it before the page is interactive, and all of that competes with the first paint.

App Router components are Server Components by default. Keep them that way and push "use client" down to the leaves that genuinely need interactivity.

good-vs-bad.tsxtsx
// ❌ whole page becomes client JS just for one button
'use client';
export default function Page() { /* ...lots of static markup... */ }

// ✅ page stays a Server Component; only the button ships JS
export default function Page() {
  return <><StaticContent /><LikeButton /></>; // LikeButton is 'use client'
}

Below the fold or rarely used, load it lazily so it is not in the critical path:

lazy.tsxtsx
import dynamic from 'next/dynamic';
const CommentsWidget = dynamic(() => import('./CommentsWidget')); // not in first paint

The fonts were the problem

This is the one that surprised me on my own site, and it is the reason the post exists.

Web fonts block text from painting, and I was loading more fonts than I was using. I had a decorative font declared that no CSS referenced, which is a pure wasted download on every page view, and it had been there long enough that I had stopped seeing it. Audit what is genuinely referenced and delete the rest.

Then preload only the font on the critical path. The heading font should load first. A code or label font can wait, and preload: false keeps it from competing with the one your first paint actually needs.

app/layout.tsxtsx
import { Plus_Jakarta_Sans, Geist_Mono } from 'next/font/google';

// heading/body font — on the critical path, preloaded by default
const sans = Plus_Jakarta_Sans({ subsets: ['latin'], display: 'swap' });

// code font — not needed for first paint, so don't preload it
const mono = Geist_Mono({ subsets: ['latin'], display: 'swap', preload: false });

display: "swap" paints your text immediately in a fallback and swaps when the web font arrives, so text appears instead of waiting. next/font also self-hosts the files, which removes an extra connection to Google, and reserves space to avoid layout shift.

Render-blocking resources, in the order they bite

Anything the browser must fetch and process before it can paint delays FCP.

Third-party scripts first, because they are the worst and the easiest to fix. Load analytics and tags with next/script and strategy="afterInteractive", or lazyOnload for anything low priority, so they never block the first paint.

app/layout.tsxtsx
import Script from 'next/script';

// deferred — doesn't block first paint
<Script src="https://www.googletagmanager.com/gtag/js?id=..." strategy="afterInteractive" />

Then CSS: ship only what the page needs, and CSS Modules keep styles scoped and removable. Then paint effects, which people rarely suspect. A heavy backdrop-filter: blur() or a large filter: blur() background is genuinely expensive on mobile GPUs, so use them sparingly above the fold.

Make the above-the-fold region cheap

FCP and LCP both live up there, so this is the highest-leverage area on the page.

Use next/image with priority on the single above-the-fold image so it is not lazy-loaded. Give images explicit dimensions, or an aspect ratio, so they do not shift the layout while loading. And keep the hero simple, because text paints faster than images and a lighter hero is a faster FCP almost for free.

Serve it static

The fastest response is one that is already rendered. Static generation or ISR for content pages means the HTML, and so your first paint, comes off a CDN instead of being built per request.

app/blog/[slug]/page.tsxts
export const revalidate = 60; // ISR: serve static HTML, refresh in the background

One caveat worth knowing: caching defaults changed in Next.js 15 and changed again in 16, so advice written for 14 will mislead you. I went through the current model in Next.js App Router caching, and the ranking side is in the technical SEO checklist.

Where it landed

None of it was a rewrite. Fewer fonts, less client JS, deferred scripts, static HTML.

That is the shape of FCP work generally. There is no single fix, because FCP is just a count of things standing between the user and the first paint, and you remove them one at a time and measure after each one. The font audit was the biggest single win here, which I would not have guessed before measuring, and that is rather the point.

Abhi Venkata's Improving First Contentful Paint (FCP) in React apps is a good companion read on the React side.

Frequently asked

What is a good First Contentful Paint (FCP) score?

Google considers an FCP of 1.8 seconds or less "good," 1.8–3.0 seconds "needs improvement," and over 3.0 seconds "poor." These thresholds are measured on mobile at the 75th percentile of your real users, so optimize for the slower devices and networks, not your fast laptop.

What is the difference between FCP and LCP?

First Contentful Paint (FCP) marks when the browser renders the first content of any kind — the first sign the page is loading. Largest Contentful Paint (LCP) marks when the largest above-the-fold element (usually a heading or hero image) finishes rendering. FCP is "something appeared"; LCP is "the main content appeared."

Do web fonts slow down FCP?

Yes. Web fonts can block text from painting until the font file downloads. Reduce the impact by self-hosting and subsetting fonts (next/font does this automatically), using font-display: swap so text shows immediately in a fallback, preloading only the font on the critical path, and removing any font you declare but do not actually use.

How does React or Next.js affect FCP?

Shipping too much client-side JavaScript delays FCP because the browser must download and process it before rendering. In the Next.js App Router, keep components as Server Components by default, push "use client" to the leaves, lazy-load below-the-fold widgets with next/dynamic, and serve pages statically (SSG/ISR) so the HTML arrives pre-rendered.

How do I measure FCP?

Use PageSpeed Insights or Chrome DevTools Lighthouse (mobile profile) for lab data, and Google Search Console’s Core Web Vitals report for real-user field data. Measure before and after each change so you know what actually helped rather than guessing.

Is backdrop-filter bad for performance?

Heavy backdrop-filter or large filter: blur() effects can be expensive to paint, especially on mobile GPUs and on elements that repaint during scroll (like a sticky blurred navbar). They are fine used sparingly, but avoid large blurred regions above the fold where they can delay your first paint.

Found this useful?Share on XShare on LinkedIn

Related reading