SleepDr, a sleep-health content site with 228 WordPress posts, moved to Next.js 15, Payload CMS 3, and Supabase on Vercel. The rebuild swapped 34 plugins and render-blocking scripts for static generation, edge delivery, and optimized images and fonts. Mobile Lighthouse score rose from 35 to 94, and desktop hit 99.

Key takeaways

  • Moving SleepDr from WordPress to Next.js 15 + Payload CMS 3 + Supabase raised mobile Lighthouse from 35 to 94 and desktop to 99. See the full case study.
  • The biggest gains came from image optimization with next/image, self-hosted fonts with next/font, cutting render-blocking JavaScript, and static generation on Vercel's edge network.
  • Total JavaScript on page load dropped from about 1.8MB to roughly 85KB. CSS dropped from about 800KB to 35KB. This came from replacing 34 WordPress plugins with purpose-built components.
  • Google's Core Web Vitals field data can roll up at the origin level when single pages lack enough traffic. So many slow templates can drag down rankings across a whole domain.
  • A WordPress-to-Next.js migration of similar size usually costs $15,000-$30,000 and takes several weeks. Cost depends on content volume and custom features.

Updated 15 August 2026: sources added, experience claims checked against our project record, summary added.

SleepDr Case Study: WordPress to Next.js Migration (Lighthouse 35→94)

The Before: Why WordPress Was Killing SleepDr

SleepDr's WordPress setup was a textbook case of piled-up technical debt. Over three years, the practice had installed 34 plugins. The theme loaded jQuery plus two more JavaScript libraries. Every page request hit a MySQL database, built HTML on the fly, and served unoptimized images through a shared hosting plan that was already buckling under the load.

Here's what the first Lighthouse audit showed on mobile:

  • Overall Score: 35 (red, failing)
  • FCP: 4.2 seconds
  • LCP: 6.8 seconds, nearly three times the "Good" threshold
  • CLS: 0.28, layout jumped from ads, images without set dimensions, and web font loading
  • TBT: 1,200ms, the main thread locked up for over a second
  • TTFB: 2.1 seconds, the server was slow before anything even rendered

The site was actively hostile to mobile users, not just slow. Lighthouse's mobile simulation mimics a mid-tier phone on a throttled connection, so these scores reflected what real users faced in less-than-ideal conditions.

Google's Core Web Vitals field data can fall back to origin-level results when a single page doesn't get enough Chrome traffic for its own data set. With 228 slow blog posts dragging down the average, SleepDr's rankings and organic traffic had been sliding for months before the migration.

Something had to change.

The Migration Stack: Why We Chose What We Chose

Each piece of the new stack fixes a specific problem SleepDr had.

  • Next.js 15 (App Router): Hybrid rendering. Static generation for blog posts, server-side rendering where needed. React Server Components keep client-side JavaScript to a minimum. See our Next.js development practice for more on how we use it.
  • Payload CMS 3: A self-hosted headless CMS that gave SleepDr's content team an editing experience close to WordPress, minus the plugin bloat. More in our headless CMS implementations.
  • Supabase: A PostgreSQL database with real-time features, handling contact form submissions, analytics events, and other dynamic data.
  • Vercel: Edge deployment. The site serves from the node closest to the user, which keeps TTFB low.

Content migration, all 228 posts with images, metadata, and URL structures, took about two weeks. We wrote a custom script to pull content from the WordPress REST API, transform it, and push it into Payload CMS.

Before and After: The Numbers

Here's the full breakdown, based on Lighthouse mobile scores.

Metric Before (WordPress) After (Next.js 15) Improvement
First Contentful Paint (FCP) 4.2s 1.1s -3.1s (74% faster)
Largest Contentful Paint (LCP) 6.8s 1.8s -5.0s (74% faster)
Cumulative Layout Shift (CLS) 0.28 0.01 -0.27 (96% reduction)
Total Blocking Time (TBT) 1,200ms 50ms -1,150ms (96% reduction)
Time to First Byte (TTFB) 2.1s 0.3s -1.8s (85% faster)
Overall Mobile Score 35 94 +59 points
Overall Desktop Score 61 99 +38 points

These aren't cherry-picked numbers from one page. We ran Lighthouse on 20 representative pages and averaged the results. Mobile scores ranged from 91 to 97 across all tested pages, and desktop ranged from 97 to 100.

Here's how we got each of these gains.

SleepDr Case Study: WordPress to Next.js Migration (Lighthouse 35→94) - architecture

Optimization 1: Image Optimization (LCP -3s)

Images were the single biggest performance killer on the old site. SleepDr's blog posts leaned heavy on product photos and infographics, often uploaded as full-resolution PNGs straight from a designer's machine. Some images ran 3-4MB each.

What We Did

Used next/image for every image. This component handles several things on its own:

import Image from 'next/image';

export function HeroImage({ src, alt }) {
  return (
    <Image
      src={src}
      alt={alt}
      width={1200}
      height={630}
      priority // Above-the-fold hero: preload it
      sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 1200px"
      quality={80}
    />
  );
}
  • Format conversion: Serves WebP or AVIF instead of PNG/JPEG, which usually cuts image size well below the original file.
  • Responsive srcset: Builds multiple sizes so mobile users don't download desktop-sized images.
  • Lazy loading by default: Images below the fold don't load until the user scrolls near them.
  • Explicit dimensions: The width and height props reserve layout space, which directly cuts CLS.

The Key Insight: Priority Loading for LCP Elements

The priority prop on the hero image was critical. Without it, Next.js lazy-loads the image. If the hero image is the LCP element, which it was on most SleepDr pages, lazy loading hurts LCP instead of helping it.

We checked every page template and marked the LCP element with priority. Blog post pages used the featured image, and the homepage used the hero banner. It was a small change, but it made a real difference to LCP.

Image CDN

Vercel's built-in image optimization acts as the CDN. Images get processed and cached at the edge on first request, so later visitors get the cached, optimized version in milliseconds.

Net impact: LCP dropped from 6.8s to about 3.8s from image optimization alone. The rest of the LCP gains came from faster TTFB and font loading.

Optimization 2: Font Optimization (FCP -1.5s)

SleepDr's WordPress theme loaded three Google Fonts via external stylesheet links. Each one meant a render-blocking request to fonts.googleapis.com, followed by another to fonts.gstatic.com for the actual font files, six network round trips before the browser could even paint text.

What We Did

Self-hosted fonts using next/font:

import { Inter, Merriweather } from 'next/font/google';

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

const merriweather = Merriweather({
  weight: ['400', '700'],
  subsets: ['latin'],
  display: 'swap',
  variable: '--font-merriweather',
});

What next/font does differently:

  • Self-hosts the font files: No external network requests. The fonts ship with the build and serve from the same CDN.
  • Automatic subsetting: Only includes the character sets you need.
  • display: 'swap': Text renders right away with a fallback font, then swaps to the web font once it loads, so nothing blocks FCP.
  • CSS variable injection: The font applies through CSS custom properties, and matching fallback font metrics stops the swap from causing layout shift.

We also dropped a third, decorative heading font that added visual noise without helping readability, leaving two fonts total.

Net impact: FCP improved by about 1.5 seconds from cutting render-blocking font requests.

Optimization 3: JavaScript Reduction (TBT 1200ms → 50ms)

This was the biggest single improvement. A TBT of 1,200ms means the browser's main thread was locked for over a second, so users couldn't click, scroll, or interact with anything during that time.

Where Was All That JavaScript Coming From?

The WordPress site loaded:

  • jQuery (roughly 87KB minified), used by the theme and most plugins
  • 34 plugin scripts, contact form, analytics, social sharing, cookie consent, two slider libraries, a lightbox, and more
  • Theme JavaScript, another 150KB of menu toggles and animation libraries
  • Inline scripts, snippets from various plugins injected into the <head>

Total JavaScript on page load: about 1.8MB. Parsing and running that on a throttled mobile connection takes well over a second.

What We Did

Zero jQuery. Next.js uses React, so jQuery wasn't needed.

Zero plugins. Every feature was rebuilt as a purpose-built component:

  • Contact form: 4KB React component + Supabase server action
  • Cookie consent: 2KB component with next/script strategy
  • Social sharing: Native Web Share API with fallback links, no library needed
  • Analytics: A lightweight Plausible script under 1KB

Dynamic imports for anything below the fold:

import dynamic from 'next/dynamic';

const NewsletterSignup = dynamic(
  () => import('@/components/NewsletterSignup'),
  { ssr: false } // Only loads on client, only when needed
);

const RelatedPosts = dynamic(
  () => import('@/components/RelatedPosts')
);

React Server Components handled most of the rendering. Blog post content, headers, footers, and navigation were all server-rendered with zero client-side JavaScript. Only interactive pieces, like the mobile menu toggle, contact form, and newsletter signup, shipped JS to the browser.

Total JavaScript on page load after migration: about 85KB, a 95% cut.

Net impact: TBT dropped from 1,200ms to 50ms. The main thread is basically free.

Optimization 4: Server-Side Rendering and Edge Deployment (TTFB -85%)

TTFB measures how long it takes for the server to send the first byte of a response. SleepDr's WordPress site had a 2.1-second TTFB on mobile. That meant users stared at a blank screen for over two seconds before anything else, images, fonts, JavaScript, could even start loading.

Why WordPress Was So Slow

Every page request on WordPress had to:

  1. Hit the shared hosting server (already slow)
  2. Load PHP
  3. Run WordPress core
  4. Run through 34 plugin hooks
  5. Query MySQL multiple times
  6. Build HTML on the fly
  7. Send the response

Even with WP Super Cache installed, the cache hit rate was inconsistent and the server itself was underpowered.

What We Did

Static generation for all 228 blog posts. At build time, Next.js pre-renders every blog post into static HTML, spread across Vercel's edge network.

When a user requests a blog post, they get a pre-built HTML file from the nearest edge node, with no database query and no server-side work.

// app/blog/[slug]/page.tsx
export async function generateStaticParams() {
  const posts = await payload.find({
    collection: 'posts',
    limit: 300,
  });

  return posts.docs.map((post) => ({
    slug: post.slug,
  }));
}

export default async function BlogPost({ params }: { params: { slug: string } }) {
  const post = await payload.find({
    collection: 'posts',
    where: { slug: { equals: params.slug } },
    limit: 1,
  });

  return <ArticleLayout post={post.docs[0]} />;
}

The contact form page used server-side rendering since it needed dynamic behavior. Even SSR on Vercel's Edge Functions runs well under 200ms because the compute happens at the edge instead of one central data center.

Net impact: TTFB dropped from 2.1s to 0.3s, an 85% improvement. On repeat visits with caching, it's closer to 50ms.

Optimization 5: Third-Party Script Management

Third-party scripts are a common cause of slow pages. SleepDr's WordPress site loaded Google Analytics (GA4), Google Tag Manager, a Facebook pixel, a Hotjar recording script, and a cookie consent manager, all render-blocking in the <head>.

What We Did

Next.js provides the next/script component with loading strategies. We used them with intent:

import Script from 'next/script';

{/* Analytics: load after the page becomes interactive */}
<Script
  src="https://plausible.io/js/script.js"
  strategy="afterInteractive"
  data-domain="sleepdr.com"
/>

{/* Cookie consent: load when browser is idle */}
<Script
  src="/scripts/cookie-consent.js"
  strategy="lazyOnload"
/>

The afterInteractive strategy loads a script after Next.js hydration finishes, once the user can already see and use the page. The lazyOnload strategy waits until the browser is fully idle, which works well for non-critical scripts.

We also swapped Google Analytics for Plausible (under 1KB, privacy-focused) and dropped Hotjar, since SleepDr wasn't reviewing the recordings. We cut the Facebook pixel too, since the practice had stopped running Facebook ads.

Removing unused third-party scripts is often the easiest performance win around, since many sites keep loading tools nobody on the team uses anymore.

Optimization 6: CSS Optimization (800KB → 35KB)

SleepDr's WordPress theme shipped about 800KB of CSS: the theme's stylesheet, plugin stylesheets, a full unused Bootstrap grid system, and Font Awesome for about 12 icons.

What We Did

Tailwind CSS with automatic purging. Tailwind scans your template files at build time and generates CSS only for the utility classes you actually use. Production CSS bundle: 35KB (about 8KB gzipped).

// tailwind.config.ts
export default {
  content: [
    './app/**/*.{ts,tsx}',
    './components/**/*.{ts,tsx}',
  ],
  theme: {
    extend: {
      fontFamily: {
        sans: ['var(--font-inter)'],
        serif: ['var(--font-merriweather)'],
      },
    },
  },
};

For the 12 icons, we used inline SVGs instead of an icon library. Each SVG runs about 500 bytes, for a total icon weight of about 6KB versus Font Awesome's 70KB+.

The result: zero render-blocking CSS requests, since Next.js inlines Tailwind's output in the initial HTML.

Net impact: CSS cut by 96%, helping both FCP and TBT.

The Step-by-Step Checklist

If you're facing similar performance issues, here's a sensible order to tackle things, ranked by impact-to-effort ratio.

Phase 1: Quick Wins (Week 1)

  • Run Lighthouse on your 10 highest-traffic pages (mobile mode)
  • Find the LCP element on each page template
  • Add explicit width and height to all images and iframes
  • Add loading="lazy" to below-fold images
  • Add fetchpriority="high" to LCP images
  • Audit third-party scripts and remove anything unused
  • Move remaining third-party scripts to async or defer

Phase 2: Font and CSS (Week 2)

  • Self-host web fonts (cut external font requests)
  • Add font-display: swap to all @font-face declarations
  • Subset fonts to only needed character sets
  • Audit CSS and remove unused stylesheets
  • Swap icon fonts for inline SVGs

Phase 3: JavaScript (Week 3)

  • Bundle-analyze to find the largest JS dependencies
  • Remove jQuery if possible
  • Dynamic import non-critical components
  • Defer non-essential JavaScript
  • Set up code splitting per route

Phase 4: Infrastructure (Week 4+)

  • Look at CDN and edge deployment options
  • Add static generation for content pages
  • Set up proper cache headers
  • Consider a full migration to a modern framework if WordPress is the bottleneck

If you're weighing that last point, reach out to us. We've documented a similar migration for bdManagedIT, and our pricing page has details on typical headless migration costs.

What 2026 Core Web Vitals Mean for Your Site

Core Web Vitals remain a confirmed part of Google's ranking systems. The field data behind them can roll up at the origin level when single pages don't get enough Chrome traffic for page-level results. In practice, this means:

  • A single slow page template used by 200 blog posts can drag down the reported performance for the whole domain
  • High-traffic pages carry more weight in the aggregate score
  • Optimizing only the homepage and calling it done isn't enough

Sites with widespread failing Core Web Vitals scores often see organic traffic decline, since CWV thresholds factor into ranking. The sites that recover fastest tend to fix performance at the infrastructure level rather than tweak individual pages.

This is why SleepDr's migration worked. Instead of optimizing 228 individual WordPress pages one at a time, the team rebuilt the delivery system so every page is fast by default.

For sites not ready for a full migration, frameworks like Astro offer another path, especially for content-heavy sites that want close to zero JavaScript by default.

Approach Typical Cost Timeline Expected Lighthouse Gain
WordPress plugin optimization (WP Rocket, ShortPixel) $100-500/yr 1-2 weeks +10-20 points
WordPress theme replacement $2,000-5,000 2-4 weeks +15-25 points
Headless CMS migration (Next.js/Astro) $15,000-50,000 4-10 weeks +30-60 points
Full platform rebuild $30,000-100,000+ 8-20 weeks +40-65 points

These are rough industry ranges, not fixed quotes. Actual cost depends on content volume, custom features, and integrations. SleepDr's migration fell within the headless CMS range above, including content transfer of all 228 posts, CMS setup, and custom components. Vercel's Pro plan runs about $20 a month, a modest bump over typical shared hosting.

Organic traffic improved after the relaunch, in line with the jump from a 35 to a 94 Lighthouse score documented in the case study. For a business that depends on organic search, a migration like this can pay for itself within a few months of recovered traffic.

What the Client Said

The migration moved SleepDr off WordPress onto Next.js 15, Payload CMS, and Supabase with a HIPAA-safe architecture. Patient intake runs through a HIPAA-compliant Jotform account, so no PHI touches our servers and the build needs no separate HIPAA hosting add-on. The site shipped 20 city-specific landing pages with medical schema markup, plus four-language support with correct hreflang. The practice owns the code and the database outright.

The practice's CEO left a verified 5.0 review on Clutch:

"The level of detail and technical depth was impressive."

The review called out the 20 city landing pages, the HIPAA-compliant patient forms, and a Google Lighthouse score above 90.

FAQ

How long does it take for Core Web Vitals improvements to affect Google rankings?

Search Console typically shows updated Core Web Vitals field data within 28 days of a fix, since it relies on a rolling 28-day Chrome User Experience Report window. Ranking changes usually follow another two to three months later, once Google recrawls the site and reprocesses signals. SleepDr's organic performance improved after its Lighthouse score moved from 35 to 94, as documented in the case study.

Is a Lighthouse score of 94 actually achievable for a real production site?

Yes. A mobile Lighthouse score above 90 is doable on production sites built with modern frameworks like Next.js or Astro, as long as the team controls third-party scripts, optimizes images, and deploys on an edge network. SleepDr reached 94 this way, as documented in its case study. Every component still needs to stay performance-aware, since one bad embed or unoptimized widget can drop the score back into the 70s.

Do I need to migrate away from WordPress to get good Core Web Vitals scores?

Not necessarily. WordPress sites can score well on Core Web Vitals with a lightweight theme, strong caching such as WP Rocket paired with Cloudflare, optimized hosting like Kinsta or WP Engine, and a lean plugin count, though scores above 90 get harder to reach as content grows. Most WordPress sites in SleepDr's earlier condition score between 30 and 60 on mobile Lighthouse because of piled-up plugin bloat and theme overhead. Below 50, plugin optimization alone probably won't get you above 75. A headless approach, where WordPress serves as a content API while a frontend framework handles rendering, is often the middle ground worth exploring.

What's the difference between Lighthouse scores and real Core Web Vitals data?

Lighthouse is a lab tool: it simulates a mid-tier phone on a throttled connection and produces synthetic scores. Core Web Vitals in Search Console are field data: real measurements from actual Chrome users visiting your site, gathered over a 28-day rolling window. Google uses field data for ranking signals, not lab scores, so Lighthouse works best for diagnosing problems and testing fixes, not as a ranking proxy.

What's the most impactful single optimization for LCP?

Image optimization delivers the biggest LCP gains for most sites, since images are among the most common LCP elements across the web according to HTTP Archive's performance data. Properly sizing an image, serving it in WebP or AVIF, adding fetchpriority="high", and skipping lazy-loading on it can cut LCP by two to four seconds. On SleepDr, image optimization alone accounted for about 3 seconds of LCP improvement.

How can Core Web Vitals problems on some pages affect a whole website's rankings?

Google's Core Web Vitals field data can fall back to origin-level, site-wide results when a single page lacks enough Chrome traffic for its own data set. So a large batch of slow templates drags down the reported performance for the whole domain. A slow blog archive template used on hundreds of pages can pull down rankings for pages that load fine on their own. The fix is architectural: every template needs to pass Core Web Vitals thresholds, not just the key landing pages.

How much does a WordPress to Next.js migration typically cost?

A WordPress-to-Next.js migration for a content site similar to SleepDr, with roughly 200 pages, a standard blog layout, and contact forms but no e-commerce, typically costs between $15,000 and $30,000 with an experienced agency. E-commerce migrations usually run higher because of catalog and checkout complexity. Ongoing hosting on Vercel's Pro plan runs about $20 a month. For sites that lost traffic to poor Core Web Vitals, the migration often pays for itself within a few months of recovered organic traffic.

Should I focus on mobile or desktop Lighthouse scores?

Mobile scores should come first, since Google uses mobile-first indexing and Lighthouse's mobile mode simulates a constrained device on a throttled connection, which is far more punishing than the desktop test. A strong mobile score near 90 or above usually means the desktop score lands in the high 90s with no extra work. SleepDr's desktop score of 99 needed no extra effort beyond the mobile optimizations.

Key takeaway:

Next.js image optimization cuts LCP by getting rid of oversized, uncompressed assets.