SleepDr Case Study: WordPress to Next.js Migration (Lighthouse 35→94)
Last quarter, we took on a project that perfectly illustrates why WordPress isn't always the right tool for the job. SleepDr.com — a sleep health content site with 228 blog posts, a contact form, and a mobile Lighthouse score of 35 — came to us desperate for speed. Their organic traffic had been sliding for months. Google's March 2026 Core Update, which introduced holistic site-wide Core Web Vitals scoring, had hammered them. Every slow blog post was dragging down the entire domain.
We migrated them to Next.js 15 + Payload CMS 3 + Supabase + Vercel. The result: mobile Lighthouse 94, desktop 99. Organic traffic recovered within 6 weeks. This is the full story of how we got there — every optimization, every metric, every decision — so you can apply the same thinking to your own projects.
Table of Contents
- The Before: Why WordPress Was Killing SleepDr
- The Migration Stack: Why We Chose What We Chose
- Before and After: The Numbers
- Optimization 1: Image Optimization (LCP -3s)
- Optimization 2: Font Optimization (FCP -1.5s)
- Optimization 3: JavaScript Reduction (TBT 1200ms → 50ms)
- Optimization 4: Server-Side Rendering and Edge Deployment (TTFB -85%)
- Optimization 5: Third-Party Script Management
- Optimization 6: CSS Optimization (800KB → 35KB)
- The Step-by-Step Checklist
- What 2026 Core Web Vitals Mean for Your Site
- FAQ

The Before: Why WordPress Was Killing SleepDr
SleepDr's WordPress setup was a textbook example of accumulated technical debt. Over three years, they'd installed 34 plugins. The theme loaded jQuery plus two additional JavaScript libraries. Every page request hit a MySQL database, generated HTML on the fly, and served unoptimized images through a shared hosting plan that was already struggling under load.
Here's what the initial Lighthouse audit looked like 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 was jumping everywhere from ads, images without dimensions, and web font loading
- TBT: 1,200ms — the main thread was locked for over a second
- TTFB: 2.1 seconds — the server itself was slow before anything even rendered
The site wasn't just slow. It was actively hostile to users on mobile devices. And since Google's Lighthouse mobile simulation mimics a mid-tier phone on a throttled 4G connection, the scores reflected what real users in less-than-ideal conditions were experiencing.
After Google's March 2026 Core Update introduced holistic CWV scoring — aggregating performance across the entire domain rather than per-page — SleepDr's 228 slow blog posts were poisoning their rankings site-wide. Early data from the rollout showed 20-35% traffic drops for affected sites. SleepDr saw roughly a 30% decline.
Something had to change.
The Migration Stack: Why We Chose What We Chose
We didn't pick this stack because it's trendy. We picked it because each piece solves 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 to minimize client-side JavaScript. This is our bread and butter — we've built dozens of projects on it through our Next.js development practice.
- Payload CMS 3: Self-hosted headless CMS that gave SleepDr's content team the same editing experience they were used to with WordPress, minus the bloat. We handle a lot of headless CMS implementations and Payload 3 has become our go-to for content-heavy sites.
- Supabase: PostgreSQL database with real-time capabilities. Handled the contact form submissions, analytics events, and any dynamic data.
- Vercel: Edge deployment. The site serves from the node closest to the user. TTFB becomes almost negligible.
The total migration took 7 weeks. Content migration — all 228 posts with their images, metadata, and URL structures — took about 2 weeks of that. 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. These are Lighthouse mobile scores, which is where the real story is.
| 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 |
I want to be clear: these aren't cherry-picked numbers from a single page. We ran Lighthouse on 20 representative pages and averaged the results. The mobile score ranged from 91 to 97 across all tested pages. Desktop was 97 to 100.
Now let me walk you through exactly how we achieved each of these improvements.

Optimization 1: Image Optimization (LCP -3s)
Images were the single biggest performance killer on the old site. SleepDr's blog posts were heavy on product photography and infographics — often uploaded as full-resolution PNGs straight from a designer's machine. Some images were 3-4MB each.
What We Did
Used next/image for every single image. This component does a lot of heavy lifting:
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}
/>
);
}
Here's what next/image handles automatically:
- Format conversion: Serves WebP (or AVIF where supported) instead of PNG/JPEG. This alone cut image sizes by 60-80%.
- Responsive srcset: Generates multiple sizes so mobile users aren't downloading desktop-sized images.
- Lazy loading by default: Images below the fold don't load until the user scrolls near them.
- Explicit dimensions: The
widthandheightprops reserve space in the layout, which directly fixes 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. But if the hero image is the LCP element — which it was on most of SleepDr's pages — lazy loading it actually hurts your LCP score. You want it to start downloading immediately.
We audited every page template, identified the LCP element, and marked it with priority. Blog post pages used the featured image. The homepage used the hero banner. Simple, but it made a 3-second difference on LCP.
Image CDN
Vercel's built-in image optimization serves as the CDN. Images are processed and cached at the edge on first request. Subsequent visitors get the cached, optimized version in milliseconds. No Cloudinary subscription needed. No WordPress plugin trying to do the same thing but worse.
Net impact: LCP dropped from 6.8s to approximately 3.8s from image optimization alone. The remaining LCP gains came from TTFB improvements and font loading.
Optimization 2: Font Optimization (FCP -1.5s)
SleepDr's WordPress theme loaded three Google Fonts via external stylesheet links. Each one was a render-blocking request to fonts.googleapis.com, followed by another request to fonts.gstatic.com for the actual font files. That's six network round trips before the browser could 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 are bundled with the build and served from the same CDN.
- Automatic subsetting: Only includes the character sets you actually need. The Latin subset of Inter is about 20KB instead of the full 100KB+ file.
display: 'swap': Text renders immediately with a fallback font, then swaps to the web font when it's loaded. No invisible text. No flash of unstyled content blocking FCP.- CSS variable injection: The font is applied via CSS custom properties, which means zero layout shift when the font swaps because we carefully matched the fallback font metrics.
We also dropped the third font entirely. The old site used a decorative font for headings that added visual noise without improving readability. Two fonts. That's it.
Net impact: FCP improved by approximately 1.5 seconds from eliminating render-blocking font requests.
Optimization 3: JavaScript Reduction (TBT 1200ms → 50ms)
This was the most dramatic single improvement. A TBT of 1,200ms means the browser's main thread was blocked for over a full second — the user couldn't click, scroll, or interact with anything during that time.
Where Was All That JavaScript Coming From?
The WordPress site loaded:
- jQuery (87KB minified) — used by the theme and most plugins
- 34 plugin scripts — contact form, analytics, social sharing, cookie consent, two different slider libraries, a lightbox, and more
- Theme JavaScript — another 150KB of menu toggles and animation libraries
- Inline scripts — random snippets from various plugins injected into the
<head>
Total JavaScript on page load: approximately 1.8MB. On a throttled mobile connection, parsing and executing that takes well over a second.
What We Did
Zero jQuery. Next.js uses React. We don't need jQuery.
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/scriptstrategy - Social sharing: Native Web Share API with fallback links — no library needed
- Analytics: Lightweight Plausible script (< 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, navigation — all server-rendered with zero client-side JavaScript. Only interactive elements (the mobile menu toggle, contact form, newsletter signup) shipped JS to the browser.
Total JavaScript on page load after migration: approximately 85KB. That's a 95% reduction.
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 start sending the first byte of the response. SleepDr's WordPress site had a 2.1-second TTFB on mobile. That means before anything could happen — before images loaded, before fonts downloaded, before JavaScript executed — the user was staring at a blank screen for over two seconds waiting for the server to respond.
Why WordPress Was So Slow
Every page request on WordPress:
- Hit the shared hosting server (already slow)
- Loaded PHP
- Executed WordPress core
- Ran through 34 plugin hooks
- Queried MySQL multiple times
- Generated HTML dynamically
- Sent 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 to static HTML. The result is a set of .html files sitting on Vercel's Edge Network — distributed across 80+ global locations.
When a user requests a blog post, they're served a pre-built HTML file from the nearest edge node. No database query. No server-side processing. Just a file read from a CDN.
// 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]} />;
}
For the contact form page, we used server-side rendering since it needed dynamic behavior. But even SSR on Vercel's Edge Functions runs in under 100ms because the compute happens at the edge, not in a centralized 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 the silent killers of web performance. 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 intentionally:
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 the script after Next.js hydration completes. The user can already see and interact with the page. The lazyOnload strategy waits until the browser is completely idle — great for non-critical scripts.
We also replaced Google Analytics with Plausible (< 1KB, privacy-focused, no cookie consent needed in most jurisdictions). Removed Hotjar entirely — SleepDr wasn't actually reviewing the recordings. Removed the Facebook pixel since they'd stopped running Facebook ads six months prior.
Killing unnecessary third-party scripts is the easiest performance win in web development. I keep saying this. Most sites load scripts for services nobody on the team is actively using.
Optimization 6: CSS Optimization (800KB → 35KB)
SleepDr's WordPress theme shipped approximately 800KB of CSS. That included the theme's stylesheet, plugin stylesheets, a full Bootstrap grid system (unused), 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. Our production CSS bundle: 35KB (gzipped: ~8KB).
// 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. No icon library. Each SVG is about 500 bytes. Total icon weight: ~6KB versus Font Awesome's 70KB+.
The result is zero render-blocking CSS requests. Tailwind's output is inlined in the initial HTML payload by Next.js, so the browser can start rendering immediately.
Net impact: CSS reduced by 96%, contributing to both FCP and TBT improvements.
The Step-by-Step Checklist
If you're facing similar performance issues, here's the exact order I'd tackle things. This is prioritized by impact-to-effort ratio.
Phase 1: Quick Wins (Week 1)
- Run Lighthouse on your 10 highest-traffic pages (mobile mode)
- Identify LCP elements on each page template
- Add explicit
widthandheightto all images and iframes - Add
loading="lazy"to below-fold images - Add
fetchpriority="high"to LCP images - Audit third-party scripts — remove anything unused
- Move remaining third-party scripts to
asyncordefer
Phase 2: Font and CSS (Week 2)
- Self-host web fonts (eliminate external font requests)
- Add
font-display: swapto all@font-facedeclarations - Subset fonts to only needed character sets
- Audit CSS — remove unused stylesheets
- Replace icon fonts with inline SVGs
Phase 3: JavaScript (Week 3)
- Bundle analyze to identify largest JS dependencies
- Remove jQuery if possible
- Dynamic import non-critical components
- Defer non-essential JavaScript
- Implement code splitting per route
Phase 4: Infrastructure (Week 4+)
- Evaluate CDN / edge deployment options
- Implement static generation for content pages
- Set up proper cache headers
- Consider full migration to a modern framework if WordPress is the bottleneck
If you're considering that last point — a full migration — reach out to us. We've done this exact type of project many times. Our pricing page has details on what headless migrations typically cost.
What 2026 Core Web Vitals Mean for Your Site
Google's March 2026 Core Update changed the game. CWV is no longer evaluated per-page — it's aggregated across your entire domain, weighted by traffic. This means:
- A single slow page template used by 200 blog posts will tank your whole site's rankings
- High-traffic pages carry more weight in the aggregate score
- You can't just optimize your homepage and call it done
Early data from the rollout shows sites with poor CWV experienced 20-35% organic traffic drops. Some saw over 50% losses. The sites that recovered fastest were those that addressed performance at the infrastructure level — not by tweaking individual pages, but by fixing the underlying architecture.
This is exactly why SleepDr's migration was so effective. We didn't optimize 228 individual WordPress pages. We rebuilt the entire delivery system so that every page is fast by default.
For sites that aren't ready for a full migration, frameworks like Astro offer another compelling path — especially for content-heavy sites where you want near-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 |
SleepDr's project fell in the $20,000-25,000 range for the full migration, including content transfer of all 228 posts, CMS setup, custom components, and performance optimization. Vercel hosting runs $20/month on the Pro plan. That's roughly $740/year in hosting versus the $300/year they were paying for shared hosting that couldn't keep TTFB under 2 seconds.
The ROI? Their organic traffic recovered within 6 weeks and surpassed pre-decline levels by week 10. For a business that depends on organic search, the migration paid for itself within the first quarter.
FAQ
How long does it take for Core Web Vitals improvements to affect Google rankings? In our experience with SleepDr and similar projects, Search Console starts showing updated CWV data within 28 days of deployment. Ranking improvements typically follow 2-3 months later. Google needs to re-crawl your pages, collect fresh field data from real Chrome users (CrUX data), and factor that into its ranking algorithms. Don't expect overnight results — but do expect measurable improvement within a quarter.
Is a Lighthouse score of 94 actually achievable for a real production site? Yes, but it requires intentional architecture choices from the start. Lab scores above 90 on mobile are achievable with modern frameworks like Next.js or Astro when you control your third-party scripts, optimize images properly, and deploy on an edge network. The key is that every component must be performance-aware. One bad embed or unoptimized third-party widget can knock you back down to the 70s.
Do I need to migrate away from WordPress to get good Core Web Vitals scores? Not necessarily. WordPress sites can score well with the right theme, aggressive caching (WP Rocket + Cloudflare), optimized hosting (Kinsta, WP Engine), and minimal plugins. Realistically though, most WordPress sites we audit score between 30-60 on mobile because of accumulated plugin bloat and theme overhead. If you're 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 throttled 4G and gives you synthetic scores. Core Web Vitals in Search Console are field data — real measurements from actual Chrome users visiting your site over a 28-day rolling window. Google uses field data for ranking signals, not lab scores. Lighthouse is useful for diagnosing problems and testing fixes, but your goal is green CWV status in Search Console at the 75th percentile.
What's the most impactful single optimization for LCP?
Image optimization. In roughly 60% of the sites we audit, the LCP element is an image. Properly sizing it, serving it in WebP/AVIF format, adding fetchpriority="high", and ensuring it doesn't lazy-load will typically cut LCP by 2-4 seconds. On SleepDr, image optimization alone accounted for approximately 3 seconds of LCP improvement.
How does Google's 2026 holistic CWV scoring work? Since the March 2026 Core Update, Google aggregates Core Web Vitals data across your entire domain rather than evaluating pages individually. High-traffic pages carry more weight in the calculation. This means a slow blog archive template used on hundreds of pages will drag down your homepage rankings too. The fix is architectural — you need every page template to pass CWV thresholds, not just your key landing pages.
How much does a WordPress to Next.js migration typically cost? For a content site similar to SleepDr (200+ pages, standard blog layout, contact forms, no e-commerce), expect $15,000-30,000 from an experienced agency. E-commerce migrations run higher — $30,000-75,000+ depending on complexity. Ongoing hosting on Vercel Pro is $20/month. The ROI depends on how much organic traffic is worth to your business, but for sites that experienced traffic drops from poor CWV, the migration typically pays for itself within 3-6 months.
Should I focus on mobile or desktop Lighthouse scores? Mobile. Always mobile first. Google uses mobile-first indexing, and Lighthouse mobile scoring is significantly more punishing than desktop because it simulates a constrained device and network. If your mobile score is 94, your desktop score will almost certainly be 95+. SleepDr's desktop score of 99 required zero additional work beyond what we did for mobile optimization.