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.
// ❌ 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:
import dynamic from 'next/dynamic';
const CommentsWidget = dynamic(() => import('./CommentsWidget')); // not in first paintThe 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.
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.
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.
export const revalidate = 60; // ISR: serve static HTML, refresh in the backgroundOne 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.