←all writing
0626 Aug 2026/9 min read

Making a Next.js marketing site fast: a 90 PageSpeed score, and the 11 days we lost analytics

Performance work on happypet.tech in the order it happened: what each fix changed in Lighthouse, what did nothing, the mistake that switched off our analytics for 11 days, and where it stands now (90 on desktop, Core Web Vitals passed).

Next.jsPerformanceCore Web Vitals

happypet.tech is the marketing website of Happy Pet Tech. It is a Next.js site on Vercel, with a CMS, a blog, a cookie banner, Google Analytics, Microsoft Clarity and ad pixels.

In early August its mobile Lighthouse score was 62. The largest element on the home page took 6.8 seconds to show. On production I saw that number go as high as 11 to 16 seconds.

Three weeks later the home page scores 88 on mobile, as the middle value of five local runs. This post lists what I did in the order I did it, with the number each step moved. Two steps moved nothing, and one broke our analytics. They are in here too.

Where it started

PageScoreLCPCLS
Home626.8 s0.192
Grooming software page658.8 s
Mobile app page749.0 s

LCP is the time until the biggest thing on the first screen is visible. CLS measures how much the page jumps around while loading. Total blocking time was already fine, between 0 and 73 ms. So JavaScript was not the problem. The first screen showed late and then moved.

1. The hero was waiting for things it did not need

Four changes went out together:

  • The hero section was rendered in the browser. I moved it to the server, so it is in the HTML.
  • The root layout preloaded five images on every page. Three of them were not used anywhere any more.
  • A slider’s CSS was loaded from a CDN in the <head>. That one file blocked rendering for about 900 ms.
  • The hero had an entrance animation that started at opacity: 0. The browser does not count an invisible element as painted, so the animation itself was delaying LCP.
PageScoreLCPCLS
Home62 → 866.8 s → 4.1 s0.192 → 0.027
Grooming software page65 → 828.8 s → 4.9 s
Mobile app page74 → 849.0 s → 4.5 s

One lesson from this step: adding priority to more images made things worse. While the dead preloads were still there, LCP went from 4.4 s to 5.8 s. Every preload competes with the one image that matters.

2. Six small avatars weighed 4.77 MB

The customer review section shows six small round photos. Together they weighed 4.77 MB.

They were SVG files. Inside each SVG was a full-size PNG photo, stored as base64 text. And next/image does not optimise SVG files. It passes them through as they are.

I converted them to WebP. The six files went from 4.77 MB to 23 KB.

BeforeAfter
Home page images4,890 KB1,288 KB
Home page total weight5,773 KB2,114 KB
Pricing page images7,220 KB125 KB
Pricing page total weight8,023 KB899 KB

The pricing page had the same problem with more avatars. Its LCP went from 7.7 s to 5.2 s from this change alone.

If your page weight looks wrong, sort the network tab by size and look at the SVGs first.

I also added the sizes attribute to large images. Without it, one image was being downloaded at 2,048 pixels wide (427 KB) on a phone.

3. The page jumped because of wrong image dimensions

CLS kept appearing in some runs and not in others. Lighthouse pointed at the slider. The slider was innocent.

The real cause was width and height values that did not match the files. One hero image was declared as 244 by 244. The file was 244 by 36. The browser reserved a square, the image loaded, and the box collapsed to a thin strip. Everything below it jumped up.

I compared the declared sizes with the real file sizes across the site. 114 images had the wrong ratio. I fixed the worst 14.

CLS went from 0.222 to 0.013, and it stopped changing between runs.

On a first visit the biggest thing on the screen is the cookie banner. So the banner was our LCP, and it was arriving about 10 seconds late on mobile.

I changed the banner script to load later (afterInteractive). In five local runs the score went from 72 to 83 and CLS from 0.222 to 0.041.

This is the change that broke analytics. I will come back to it.

Then the next problem showed itself. A “book a demo” popup opened three seconds after load, on a timer. With the banner fixed, the popup became the LCP element, with a load delay of 14.9 seconds in one report. Now it opens on the visitor’s first interaction, not on a timer.

5. Static files were not being cached

This one was hard to find, because it did not happen on my machine.

Lighthouse on production said static files had no long cache lifetime. Next.js serves everything under /_next/static with an immutable cache header, so this should not happen.

Our middleware had no matcher. It ran on every request, including static files. On Vercel, that removed the immutable header from them. With next start on my machine it did not.

// src/middleware.ts
export const config = {
  matcher: ['/((?!_next/static|_next/image|favicon.ico|.*\\..*).*)'],
};

Lighthouse estimated the saving at about 1.7 seconds.

6. Small things that added up

  • The home page called router.push('/') to itself after the region check. That cost 830 ms.
  • The analytics script (183 KB) was fetched 226 ms into the page load. It now waits for the load event and then for idle time.
  • Five stylesheets became two. First paint went from 1.35 s to 1.05 s.

Two things that did nothing

I changed the heading font from woff to woff2 and from display: block to swap. Every guide tells you to do this. It made no measurable difference on this site.

I turned on experimental.optimizeCss. On the App Router it does nothing. I removed it.

I keep these in the list because most performance posts only show the wins. A good part of what you try will not move the number, and you only know if you measure each change alone.

The mistake: 11 days without analytics

On August 10 our analytics went to zero. It stayed there until August 21.

Here is what happened. The cookie banner tool works by rewriting script tags. You write your analytics tag as type="text/plain", which the browser ignores. When the visitor accepts, the banner script changes the type, and the script runs.

That rewriting is patched in when the banner script starts. When I moved the banner to load later, its patch arrived after React had already built the page. The tags stayed as plain text. No analytics, for anyone, with consent or without.

While fixing it I found a second problem that had been there all along. next/script downloads the src of a script even when its type is text/plain. So the analytics file was being requested 18 ms into the page load, before any consent. It was not running, but it was being fetched.

The fix was to stop depending on the banner’s rewriting. There are no tracking script tags in the HTML at all now. One small inline script reads the consent cookie itself and adds the scripts when allowed:

function granted(cat) {
  var v = ('; ' + document.cookie).split('; cookieyes-consent=')[1];
  if (!v) return false;
  return decodeURIComponent(v.split(';')[0]).indexOf(cat + ':yes') > -1;
}

function check() {
  if (granted('analytics')) bootAnalytics();
  if (granted('advertisement')) bootAds();
  return booted.analytics && booted.ads;
}

var tries = 0;
function poll() {
  if (check()) return;
  if (++tries < 15) setTimeout(poll, 2000);
}

function start() {
  'requestIdleCallback' in window
    ? requestIdleCallback(poll, { timeout: 5000 })
    : setTimeout(poll, 2000);
}
document.readyState === 'complete'
  ? start()
  : window.addEventListener('load', start, { once: true });

document.addEventListener('cookieyes_consent_update', check);

The polling is there for one case. Outside the EU, the banner grants consent by itself a moment after it loads. If our first check runs before that, it would miss it. So it looks again every two seconds, up to 15 times.

Two things I do differently now. When I change how a third-party script loads, I check the next day that its data still arrives. And I remember that this banner refuses to run on localhost, so a change to it can only be tested on the real domain.

Where it stands

Home page, mobile, middle of five local runs: 88. CLS is 0.013. Image weight is about a quarter of what it was.

LCP is still above four seconds on the slow mobile profile. What is left is the first-screen image and the cookie banner, and that is the next round of work.

Update, 30 September 2026

The team kept going in September: the cookie banner script now loads async with a preload, a slider library was replaced with native scrolling in two places, a 724 KB date library was swapped for the browser’s built-in Intl.DateTimeFormat, and the blog is cached and rebuilt every five minutes.

Today’s PageSpeed Insights report for the home page, on desktop:

Score
Performance90
Accessibility91
Best Practices100
SEO100

The part I care about more is the real-user data in the same report. It comes from real visitors over the last 28 days, not from a test machine, and the site passes Core Web Vitals:

Real users, desktopValue
Largest Contentful Paint0.8 s
Interaction to Next Paint63 ms
Cumulative Layout Shift0.02
First Contentful Paint0.7 s
Time to First Byte0.4 s

A lab score tells you what to fix. Real-user numbers tell you if it worked. If you only track one of them, track the second.

← all writing nextHow voice to notes works: speech to text, then an LLM, then a human →