SEO for Next.js

Next.js Core Web Vitals: Native Optimizations and Where They Fall Short

Next/Image, Server Components, and Streaming give you CWV defaults out of the box. Here are the edge cases where you still have to tune.

Published May 24, 20269 min readBy RankCrab Team

Next.js gives you a strong Core Web Vitals baseline without much effort. next/image handles responsive sizing and WebP conversion automatically. Server Components mean your page ships less JavaScript by default. next/font eliminates layout shift from font swaps. Streaming with Suspense makes slow data fetches feel faster to the user.

But "Next.js handles CWV" is only half true. The framework gives you the right tools — it doesn't prevent you from misusing them. The 'use client' directive, placed carelessly, can ship hundreds of kilobytes of third-party JS that tanks your INP score. A next/image with priority in the wrong place, or without it where it's needed, causes LCP regressions. Heavy event handlers in client components stall the main thread.

Here's what Next.js handles for you, and where you still need to tune.

What Next.js Handles Automatically

next/image

The next/image component does several things that would otherwise require manual work:

  • Serves WebP/AVIF instead of JPEG/PNG, reducing transfer size 30–50%
  • Generates responsive srcset automatically — the right size for the viewport
  • Lazy loads images below the fold by default
  • Reserves space in the DOM to prevent CLS (requires width and height props or fill layout)

The single most important prop for LCP: priority on your above-the-fold hero image.

// This image is your LCP element — tell Next.js to preload it
<Image
  src="/hero.jpg"
  alt="Hero image"
  width={1200}
  height={600}
  priority
/>

Without priority, Next.js lazy-loads the image. On a page where the hero is the LCP element, this delays the metric significantly. priority adds a <link rel="preload"> in the document head.

Server Components and Reduced Client JS

Server Components don't ship any JavaScript to the client. A page that fetches data, renders HTML, and returns it — with no interactivity — ships zero JS for that component. This reduces your Total Blocking Time and improves INP by giving the main thread less to do.

The baseline JavaScript for a Next.js app (React runtime, router, prefetching) is around 70–90kb gzipped. Add components thoughtfully and you can build pages that stay close to this baseline.

next/font

Font swaps cause CLS. A fallback system font loads, text renders at one size, the custom font loads, and elements shift to accommodate the new font metrics. next/font eliminates this by:

  1. Self-hosting fonts at build time (no third-party DNS lookup)
  2. Injecting size-adjust on the fallback font to match the metrics of the actual font
  3. Using font-display: optional or font-display: swap depending on your config
// app/layout.tsx
import { Inter } from 'next/font/google'

const inter = Inter({
  subsets: ['latin'],
  display: 'swap',
  variable: '--font-inter',
})

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en" className={inter.variable}>
      <body>{children}</body>
    </html>
  )
}

Use display: 'optional' for the best CLS scores — this tells the browser to skip the font swap entirely if the custom font hasn't loaded within 100ms. The downside: users on slow connections always see the fallback. display: 'swap' is the middle ground.

Streaming with Suspense

Streaming lets Next.js send the page shell immediately and stream in slower data as it resolves. For pages with database queries, this improves Time to First Byte and perceived performance:

// app/dashboard/page.tsx
import { Suspense } from 'react'
import { UserStats } from './user-stats'
import { RecentActivity } from './recent-activity'

export default function DashboardPage() {
  return (
    <div>
      <h1>Dashboard</h1>
      <Suspense fallback={<StatsSkeleton />}>
        <UserStats />
      </Suspense>
      <Suspense fallback={<ActivitySkeleton />}>
        <RecentActivity />
      </Suspense>
    </div>
  )
}

The page shell renders immediately. UserStats and RecentActivity stream in as their data resolves. LCP is typically the page shell or a hero element — streaming keeps this fast even when data is slow.

Where Next.js Falls Short

Large Client Bundles from 'use client'

'use client' makes a component (and all its imports) run on the client. The problem: it's easy to add a lightweight interactive component and accidentally pull in a large dependency through its imports.

Diagnose with @next/bundle-analyzer:

npm install @next/bundle-analyzer
// next.config.js
const withBundleAnalyzer = require('@next/bundle-analyzer')({
  enabled: process.env.ANALYZE === 'true',
})

module.exports = withBundleAnalyzer({})
ANALYZE=true npm run build

Look for large client-side chunks. Common offenders:

  • Date libraries (moment.js is 72kb gzipped — use date-fns or native Intl)
  • Rich text editors pulled in through a form component
  • Chart libraries that could be server-rendered static images
  • lodash imported as import _ from 'lodash' instead of import debounce from 'lodash/debounce'

The fix pattern: push 'use client' as far down the component tree as possible. Keep data fetching and layout in Server Components; only wrap the specific interactive element in a Client Component.

// Bad: the whole page is a client component because of one button
'use client'

export default function BlogPost({ post }) {
  const [liked, setLiked] = useState(false)
  return (
    <article>
      {/* 1000 lines of static content */}
      <button onClick={() => setLiked(!liked)}>Like</button>
    </article>
  )
}
// Good: only the button is a client component
// app/blog/[slug]/page.tsx (Server Component)
import { LikeButton } from './like-button'

export default async function BlogPost({ params }) {
  const post = await getPost(params.slug)
  return (
    <article>
      {/* static content — server rendered, no JS shipped */}
      <LikeButton postId={post.id} />
    </article>
  )
}

// app/blog/[slug]/like-button.tsx
'use client'
export function LikeButton({ postId }: { postId: string }) {
  const [liked, setLiked] = useState(false)
  return <button onClick={() => setLiked(!liked)}>Like</button>
}

INP Regressions from Heavy Event Handlers

INP measures the latency from user interaction to the next visual update. The main thread must be available to respond. Long-running synchronous work in event handlers kills INP scores.

Common causes in Next.js apps:

  • Filtering a large list synchronously in a onChange handler
  • Running layout calculations inside a useEffect triggered by user input
  • Third-party analytics scripts that run synchronously on click

Diagnosis: open Chrome DevTools, go to Performance, record a session while interacting with the page, and look for "Long tasks" (tasks over 50ms shown in red). The culprit is usually visible.

Fixes:

// Instead of synchronous filter on keydown:
const filtered = items.filter(item => item.name.includes(query))

// Use useDeferredValue to defer expensive computation:
import { useDeferredValue, useMemo } from 'react'

function SearchableList({ items }) {
  const [query, setQuery] = useState('')
  const deferredQuery = useDeferredValue(query)

  const filtered = useMemo(
    () => items.filter(item => item.name.includes(deferredQuery)),
    [items, deferredQuery]
  )

  return (
    <>
      <input value={query} onChange={e => setQuery(e.target.value)} />
      <ul>{filtered.map(item => <li key={item.id}>{item.name}</li>)}</ul>
    </>
  )
}

For deeper INP optimization in Next.js, see How to Fix INP in Next.js.

LCP Regressions from Misplaced priority

The priority prop should go on exactly one image per page: the one that's the LCP element. Common mistakes:

  • priority on a logo in the header when the hero image below it is actually the LCP element
  • priority missing on a hero image because it's rendered conditionally or inside a Suspense boundary
  • priority on a carousel image that may or may not be visible in the initial viewport

Use PageSpeed Insights or Chrome DevTools' Performance tab to identify your actual LCP element before placing priority. You want it on the image that Google identifies as the LCP element for your page.

The Optimization Workflow

  1. Measure first. Run your page through PageSpeed Insights. Note the specific metric that's failing and the element cited (for LCP) or interaction cited (for INP).

  2. Check bundle size. Run ANALYZE=true npm run build and look for unexpectedly large client chunks. Push 'use client' boundaries down the tree.

  3. Verify priority placement. Confirm that the LCP element (as identified by PageSpeed Insights) has the priority prop. Remove it from non-LCP images.

  4. Check for CLS. Ensure all next/image usage includes explicit width and height or uses the fill prop with a sized container. Check that next/font is configured and the fallback font has appropriate size-adjust.

  5. Profile INP. Use Chrome DevTools' Performance tab to find long tasks during interactions. Use useDeferredValue or useTransition for expensive state updates.

  6. Run a full audit. After optimizations, run an 80-check audit on RankCrab to verify the technical SEO layer is solid — CWV improvements don't matter if the page can't be crawled.

This article is part of the SEO for Next.js cluster. Related reading:

Ship optimized content this afternoon.

7-day trial. $29 when it converts. Cancel from the dashboard the second it stops earning its keep.