← back to index
LighthouseApr 2, 2026 · 7 min read

How to Improve First Contentful Paint (FCP) in Next.js

FCP is only 10% of your Lighthouse score, but it is the first thing your users experience. Here is what blocks it and how to fix it in Next.js.

This is part 2 of the Lighthouse Performance series. If you haven't read part 1, it covers the scoring system and the 5 metrics.

FCP is 10% of your Lighthouse score. That is the smallest weight. And yet, it is the first thing your user sees, or does not see. If FCP is slow, the page is blank. A blank page bounces users before anything else has a chance to load.

That is why we start here.

FCP vs LCP: a quick clarification

These two metrics get confused often. They measure different moments.

FCPLCP
What it measuresTime until any content appearsTime until the main content appears
Triggered byFirst text, image, canvas elementLargest visible element (hero image, heading)
Good threshold≤ 1.8s≤ 2.5s
Weight in score10%25%

FCP fires early. It tells you the page is alive. LCP tells you the page is useful. Both matter, but they have different causes and different fixes.

What blocks FCP: the critical rendering path

Before the browser can paint anything, it follows a strict sequence. The HTML arrives, the browser starts parsing it, then encounters resources in the <head>. If any of those resources are render-blocking, the browser stops and waits.

Every red box in that diagram is a potential delay on FCP. The goal is to remove blocking resources from the critical path, or at least make them faster.

The fixes below address each one of these boxes.

Fix 1: Fonts with next/font

Fonts are the most common FCP killer I encounter. By default, when a browser encounters a @font-face declaration and the font is not yet loaded, it makes text invisible while it waits. This is called FOIT (Flash of Invisible Text). The user sees a blank layout. FCP does not fire until text becomes visible.

The standard fix is font-display: swap, which tells the browser to render text with a fallback font immediately and swap it when the custom font loads. Next.js handles this automatically through next/font.

import { Inter } from 'next/font/google';
 
const inter = Inter({
    subsets: ['latin'],
    display: 'swap', // default in next/font, shown here for clarity
});
 
export default function RootLayout({
    children,
}: {
    children: React.ReactNode;
}) {
    return (
        <html lang="en" className={inter.className}>
            <body>{children}</body>
        </html>
    );
}

Beyond font-display: swap, next/font also:

If you are loading fonts manually with a <link> tag, you are likely blocking FCP on every page. Switching to next/font is usually the fastest single improvement you can make.

Fix 2: Third-party scripts with next/script

Any <script> tag placed in the <head> without async or defer is render-blocking. The browser stops, downloads the script, executes it, then continues. This delays everything, including FCP.

According to Google's documentation, a script is render-blocking when it:

Third-party scripts are the usual suspect: analytics, chat widgets, A/B testing tools, tag managers.

next/script exposes a strategy prop that controls when a script loads:

StrategyWhen it loadsUse for
beforeInteractiveBefore page hydrationCritical scripts only (rare)
afterInteractiveAfter hydrationAnalytics, tag managers
lazyOnloadDuring browser idle timeChat widgets, non-critical third-parties
import Script from 'next/script';
 
export default function RootLayout({
    children,
}: {
    children: React.ReactNode;
}) {
    return (
        <html lang="en">
            <body>
                {children}
 
                {/* Loads after the page is interactive, does not block FCP */}
                <Script
                    src="https://www.googletagmanager.com/gtm.js?id=GTM-XXXXXXX"
                    strategy="afterInteractive"
                />
 
                {/* Loads during idle time, lowest priority */}
                <Script
                    src="https://widget.intercom.io/widget/xxxxx"
                    strategy="lazyOnload"
                />
            </body>
        </html>
    );
}

If you have scripts in your <head> right now without async or defer, this is likely contributing to a slow FCP. Check the Coverage tab in Chrome DevTools to see which scripts are executing before first paint.

Fix 3: Reduce your TTFB

TTFB (Time to First Byte) is the time between the browser sending a request and receiving the first byte of the server response. It is not an FCP metric directly, but it is a prerequisite for it. A slow TTFB means the HTML arrives late, and everything else shifts later too.

As Google's documentation states: TTFB is the sum of redirect time, DNS lookup, connection and TLS negotiation, and the request itself until the first byte arrives. A good TTFB is 0.8 seconds or less.

The three most common TTFB problems and their Next.js fixes:

Redirects: Every redirect adds a full network round-trip. A chain of two redirects before your page loads can add 300-600ms before the browser even starts downloading HTML. Audit your redirects in next.config.js and remove any that are not strictly necessary.

Slow server response: If your page fetches data before responding, that time is included in TTFB. Use static generation or ISR (Incremental Static Regeneration) when possible.

// Option 1: fully static (TTFB will be near-instant, served from CDN edge)
export const dynamic = 'force-static';
 
// Option 2: ISR — regenerates the page at most every hour
export const revalidate = 3600;
 
export default async function BlogPost({
    params,
}: {
    params: { slug: string };
}) {
    // This fetch is cached and reused across requests during the revalidation window
    const post = await fetch(`https://api.example.com/posts/${params.slug}`, {
        next: { revalidate: 3600 },
    }).then(r => r.json());
 
    return <article>{post.content}</article>;
}

Compression: Your server should compress HTML responses with gzip or brotli. Next.js enables gzip by default in production. If you are behind a reverse proxy (Nginx, Cloudflare), make sure brotli is enabled there as well. You can verify this in DevTools: open the Network tab, click on the HTML request, and check the Content-Encoding response header.

Fix 4: Cut unused JavaScript with dynamic()

Unused JavaScript harms FCP in two ways. If it is render-blocking, it delays paint directly. Even if it is async, it competes for bandwidth during the critical load window.

In Next.js, any component marked with "use client" gets bundled into the client-side JavaScript. If that component is heavy and not needed for the initial paint, it is still downloaded and parsed before the user can see anything.

next/dynamic lets you split that code out and load it only when needed:

import dynamic from 'next/dynamic';
 
// This component and its dependencies are NOT included in the initial bundle.
// They are downloaded only when this part of the page renders.
const HeavyChartComponent = dynamic(
    () => import('@/components/analytics/HeavyChart'),
    {
        loading: () => (
            <div className="h-64 animate-pulse bg-muted rounded-lg" />
        ),
        ssr: false, // use this if the component uses browser-only APIs
    }
);
 
export default function Dashboard() {
    return (
        <main>
            <h1>Dashboard</h1>
            {/* Initial page load does not include HeavyChart JS */}
            <HeavyChartComponent />
        </main>
    );
}

The loading fallback is important here: it reserves space for the component while it loads, which also prevents layout shifts (a CLS problem we will cover in a later article).

To identify what is in your initial bundle, use the Next.js bundle analyzer:

# Install once
npm install @next/bundle-analyzer
 
# Run the analysis
ANALYZE=true next build

Then set it up in your config:

// next.config.ts
import withBundleAnalyzer from '@next/bundle-analyzer';
 
const bundleAnalyzer = withBundleAnalyzer({
    enabled: process.env.ANALYZE === 'true',
});
 
export default bundleAnalyzer({
    // your existing Next.js config
});

Look for large chunks that appear in the initial load. Those are the candidates for dynamic().

Measuring FCP in Next.js

Next.js has a built-in hook for reporting Web Vitals. You can use it to log FCP (and other metrics) to your analytics platform.

// src/app/web-vitals.tsx
'use client';
 
import { useReportWebVitals } from 'next/web-vitals';
 
export function WebVitals() {
    useReportWebVitals(metric => {
        if (metric.name === 'FCP') {
            // Replace with your actual analytics call
            console.log(`FCP: ${metric.value}ms`);
        }
    });
 
    return null;
}
import { WebVitals } from './web-vitals';
 
export default function RootLayout({
    children,
}: {
    children: React.ReactNode;
}) {
    return (
        <html lang="en">
            <body>
                <WebVitals />
                {children}
            </body>
        </html>
    );
}

If you prefer using the web-vitals library directly, without the Next.js wrapper:

// src/lib/vitals.ts
import { onFCP } from 'web-vitals';
 
onFCP(metric => {
    console.log(`FCP: ${metric.value}ms`);
});

In both cases, you get the value as your real users experience it, not as a lab measurement. Lighthouse gives you a synthetic result. This gives you field data.

What's coming next

FCP is your entry metric. It tells you the page started. LCP tells you the page is ready.

LCP carries 25% of the Lighthouse score and is directly influenced by your hero image, your server response time, and how you handle resource prioritization. It is where most sites lose the most points, and where the most concrete optimizations live.

The next article covers LCP in depth.