July 24, 202612 min read

How I Got KittenPlein.nl to Near-Perfect Lighthouse Scores

Next.jsTypeScriptWeb PerformanceFull-Stack Development

KittenPlein's most important Next.js category pages now score 100 for Performance on desktop and 98 on mobile in Lighthouse. Both runs also score 100 for Best Practices and SEO. The result came from a focused set of changes: making the likely LCP image win the network race, sending accurately sized images, keeping the listing grid on the server, designing a fast backend and keeping tracking and marketing scripts out of the critical rendering path.

This is a practical Next.js Lighthouse optimization case study, not a promise that one score guarantees search rankings. Lighthouse is controlled lab data; real-user Core Web Vitals in Search Console remain the more important long-term check. Google includes Core Web Vitals in its ranking systems, but also makes clear that a perfect tool score is not a substitute for relevant content and a good overall page experience.

In KittenPlein After Launch, I wrote that the work had shifted from building the marketplace to growing it through SEO. Performance is the other half of that story. The search landing pages are also the heaviest pages in the product: current kittens for sale and breed routes such as Ragdoll kittens combine a photo-first grid, filters, pagination and substantial supporting content.

Lighthouse runPerformanceAccessibilityBest PracticesSEO
Desktop10095100100
Mobile9895100100

Here is how I got there—and which changes mattered enough to reuse on another image-heavy Next.js site.

Why Next.js Category Pages Are Hard to Optimize

A listing detail page has one hero image and a lot of text. It is naturally fast. A category page is the opposite. It renders a grid of image-dominant cards, and every one of those cards wants to download a photo, run layout and paint. On mobile, on a throttled connection, that is where Performance scores go to die.

The three Lighthouse metrics that put the most pressure on this kind of page are:

  • LCP (Largest Contentful Paint): on an image grid, the LCP element is a card photo. If it loads late, the score collapses.
  • CLS (Cumulative Layout Shift): cards, badges and the filter sidebar must not jump as images and fonts arrive.
  • TBT (Total Blocking Time): JavaScript parsing, execution and other long main-thread tasks delay interaction and pull the score down.

Almost everything I did maps back to one of those three. The theme is the same one from the SEO post: the same structured data model that powers discovery also lets the page be fast, because the server already knows exactly what it needs to render.

Step 1: Optimize next/image for Faster LCP

On a photo grid, images are the whole game. I spent more time here than anywhere else, and it paid back the most.

Serve AVIF and stop downloading oversized images

The first lever is Next.js image config. AVIF first, WebP as the fallback, and a device-size ladder tuned to the sizes the cards actually render at.

images: {
  // AVIF first (~20-30% smaller than WebP), then WebP fallback.
  formats: ["image/avif", "image/webp"],
  // Add a ~400px bucket so listing cards (sizes ≈ 390px) stop jumping to the
  // default 640 bucket and downloading images larger than they're displayed.
  deviceSizes: [400, 640, 750, 828, 1080, 1200, 1920],
  qualities: [10, 70, 75],
},

Two small decisions here matter more than they look. Next.js documents AVIF as generally compressing about 20% smaller than WebP, with a slower first encode that caching can offset. The extra 400 device-size bucket exists because these cards render at roughly 390px wide on desktop. Without it, Next.js jumps to the next bucket up (640) and downloads an image far larger than it will ever display. Adding one number to an array measurably cut bytes on the busiest page on the site.

Match the sizes attribute to the CSS grid

A wrong or missing sizes attribute is one of the easiest ways to undermine next/image. If the hint does not match the rendered layout, the browser picks the wrong source from the generated srcset and either downloads too many pixels or shows a soft image. With fill, omitting sizes also makes the browser assume the image may be 100vw.

I keep the size hints in one place so they can't drift from the CSS grid:

export const THREE_COLUMN_CARD_IMAGE_SIZES =
  "(min-width: 1280px) 390px, (min-width: 1024px) 33vw, (min-width: 640px) 50vw, 100vw";

This mirrors the grid exactly: three columns on wide screens, two on tablet, one on mobile. The browser now downloads the right resolution at every breakpoint, which directly helps LCP on mobile where bandwidth is scarce.

Give high fetch priority to one likely LCP image

This is the detail I'm most happy with. On these routes, the first visible card is the likely LCP element. Giving every above-the-fold image high fetch priority makes them compete for bandwidth and weakens the signal. Current LCP guidance recommends reserving fetchpriority="high" for one or, at most, a very small number of likely LCP images.

The production helper calls that critical state priority; it is an internal label rather than a recommendation to use the deprecated Next.js priority prop. I gave the card a three-state loading strategy instead of a boolean:

// Image loading strategy for the sharp card photo:
// - "priority": the single LCP card — eager + fetchpriority=high, so it wins
//   the connection uncontested (only ever one card per grid).
// - "eager": above-the-fold on smaller breakpoints — loaded eagerly (not
//   lazy) but at normal priority, so it's in flight early without starving
//   the LCP image's bandwidth on mobile.
// - "lazy" (default): everything below the fold.
imageLoading?: "priority" | "eager" | "lazy";

The grid decides which card gets which state:

const imageLoading =
  isFirstChunk && listingIndex === 0
    ? "priority"
    : isFirstChunk && listingIndex < EAGER_CARD_COUNT
      ? "eager"
      : "lazy";

Only the very first card gets priority. The next five get eager so the rest of the largest desktop fold is in flight early without competing at high priority, and everything after that stays lazy. EAGER_CARD_COUNT is 6, which covers a three-column-by-two-row desktop fold. On smaller screens it intentionally fetches several upcoming cards; testing showed that cost was acceptable, but it is a trade-off I will keep checking against field data.

The result is that the browser spends its first, most valuable moments on the one pixel-region Lighthouse is timing, and nothing competes with it.

Reserve the card ratio to prevent layout shift

KittenPlein sellers upload portrait photos from their phones. A hard object-cover crop would chop the kitten's head off; a plain object-contain would leave ugly empty bars. So each card draws two copies of the same image: a heavily blurred, cheap backdrop that fills the frame, and the sharp photo on top with object-contain.

{/* Blurred fill so portrait photos aren't cropped — the sharp image is
    drawn on top with object-contain. */}
<Image src={thumbnail} alt="" aria-hidden fill sizes="32px" quality={10}
  className="scale-110 object-cover blur-xl brightness-90" />
<Image src={thumbnail} alt={listingImageAlt(...)} fill sizes={imageSizes}
  quality={70} className="object-contain object-center" />

The important performance detail is the backdrop's cost: sizes="32px" and quality={10}. A blurred-out background does not need resolution, so it downloads as a tiny thumbnail — a few hundred bytes — while the sharp copy renders at quality={70}, which is visually indistinguishable from the default 75 but lighter. The card also has a fixed aspect-[4/5] ratio, so the frame reserves its space before any image arrives. Nothing shifts. That is CLS handled at the card level, for every card, by construction rather than by luck.

Step 2: Reduce TBT with React Server Components

The second big lever is TBT, and the honest way to win it is to not ship the JavaScript in the first place.

The category page is a React Server Component. The listing card is an async server component too — it does its favourite-status lookup on the server and renders as pure HTML. The only interactive island on a card is the favourite toggle button. There is no client-side data fetching, no hydration of the grid, no heavy state library booting up on load. The page arrives as HTML that is already correct, and the browser has almost nothing to execute before it is interactive.

This is where the App Router earns its place. Because the server already holds the structured listing data — breed, city, age, trust signals, counts — it can render the entire grid, the SEO pill links, the FAQ and the breadcrumbs without asking the client to do anything. The same data model that makes the marketplace discoverable is what keeps its heaviest pages cheap to render.

Fonts get the same discipline. Both typefaces load through next/font with display: "swap" and Latin subsetting, so text paints immediately in a fallback and there's no invisible-text delay dragging on LCP or a reflow spiking CLS when the web font swaps in.

Treat tracking and A/B testing as performance costs

Client-side tracking can quietly undo otherwise careful performance work. Analytics libraries, tag managers, heatmaps, marketing pixels and A/B-testing frameworks all add network requests and JavaScript that the browser has to download, parse and execute. Experiment tools can be especially expensive when they rewrite the page after it renders, creating visual flicker or layout shift as well as extra main-thread work.

That does not mean measurement is bad. It means every client-side tool needs to earn its place in the performance budget. On KittenPlein, anything that does not need to run before the page becomes useful stays out of the critical path. Essential product events can be recorded with lightweight code, while non-essential marketing tools should load later, be scoped to the routes that need them or be handled on the server where possible. A script does not become free just because it came from a trusted analytics dashboard.

Step 3: Improve Server Response Time with SQL and ISR

A good backend is instrumental to frontend performance. React Server Components remove work from the browser, but they do not make a slow database fast. If the server has to wait for a long chain of queries before it can produce HTML, the user still stares at an empty page.

Query count matters as much as individual query speed. A category page can easily accumulate separate calls for listings, totals, breeds, cities, filters, favourites, seller data and SEO content. Run those calls sequentially and their latency stacks up. Run too many at once and they can compete for database connections, duplicate work and put unnecessary pressure on the backend. The right answer is a deliberate read model: fetch only what the route needs, aggregate close to the data, avoid N+1 lookups and keep the number of round trips bounded.

Two habits kept the KittenPlein server response quick.

Aggregate and paginate in SQL

The category page shows breed and city counts next to its filter pills — "Ragdoll (12)", "Rotterdam (8)". The naive version fetches every matching listing and counts them in Node. Instead these are GROUP BY aggregates that come back as small numbers, scoped to the same filters as the grid:

const [totalListings, locations, breedCounts, cityCounts, filterOptions] =
  await Promise.all([
    countApprovedListings(filters),
    getApprovedListingLocations("kitten"),
    getApprovedBreedCounts(filters),   // SQL GROUP BY, not fetch-then-count
    getApprovedCityCounts(filters),
    getApprovedFilterOptions("kitten"),
  ]);

These five bounded queries are fetched in parallel with Promise.all, so the total wait is close to the slowest query rather than the sum of all five. That parallelism is useful, but it is not permission to keep adding queries indefinitely. The grid is also paginated with LIMIT/OFFSET, so a category page never loads more than one page of listings regardless of how much inventory exists behind it.

Cache category routes with ISR

Category pages don't change second to second, so the route revalidates on a schedule:

export const revalidate = 300;

For five minutes at a time, visitors are served a fully cached HTML page — no database round-trip, no render work — and the cache refreshes in the background. This is what makes the perfect score reproducible instead of a fluke that only appears when the database happens to be warm. New listings still appear within the window, which for a marketplace at this stage is exactly the right trade.

Why the Mobile Lighthouse Score Stopped at 98

Desktop hit 100. Mobile settled at 98, and closing the gap on mobile is a different problem: the CPU is slower, the connection is throttled, and the fold shows one full-width card instead of six small ones.

The wins there were the same levers pushed harder. The accurate sizes value means that single mobile card downloads a phone-sized image, not a desktop one. The eager tier starts the next cards before a user's thumb moves, while only the first request gets high priority. And because there is essentially no blocking JavaScript, the throttled CPU has little to grind through before the page responds to touch.

The remaining two points are mostly the physics of loading a real photo over a throttled mobile connection. I could claw them back by degrading image quality further, but 98 with genuinely sharp kitten photos is the right place to stop. The point of the exercise was never a vanity number — it was to make sure the pages Google sends buyers to feel instant on the device most of them are actually holding.

What the Other Lighthouse Scores Revealed

Performance gets the attention, but the other three categories are what make the score defensible. Best Practices and SEO sit at 100 because the fundamentals are handled by construction: correct image aspect ratios and dimensions, HTTPS everywhere, a canonical URL and robots directives per route, JSON-LD FAQPage structured data, and no console errors shipped to production.

Accessibility is 95, and I know exactly where the last points are. Every meaningful image has a descriptive, generated alt (the decorative blurred backdrop is correctly aria-hidden), interactive elements are real focusable controls, and breadcrumbs use a real nav. The remaining gap is contrast on a couple of the softest muted-text tokens layered over photography. That is a real accessibility defect, even if it is small, and the audit gives me a specific follow-up rather than a vague target.

What the Lighthouse Result Proves—and What It Does Not

A Lighthouse run is a repeatable diagnostic under controlled conditions, not a verdict on every visit. Scores can move with the test device, network, browser extensions and server state. The next check is field data: the 75th-percentile LCP, INP and CLS experienced by real visitors over time.

That distinction does not make the 100 and 98 less useful. It makes them useful for the right reason: they show that the page has no obvious lab bottleneck and give me a baseline for catching regressions. The business goal is still that the category routes receiving organic traffic load quickly and remain stable for a real buyer on a real phone.

Google's ranking systems use Core Web Vitals as part of page experience, but relevance still comes first. Speed and SEO are connected here because both serve the same user; neither replaces useful inventory, trustworthy information or clear search intent.

Next.js Lighthouse Optimization Checklist

What I like most is that almost none of this was exotic. There was no bundle-splitting heroics and no custom image pipeline. It was a handful of boring, correct decisions compounding:

  • Serve modern formats and stop over-downloading with an accurate device-size ladder.
  • Tell the browser the truth with sizes, kept in one shared constant.
  • Prioritise exactly one LCP image and let the rest load in a sensible order.
  • Reserve layout space so nothing ever shifts.
  • Render on the server and ship almost no JavaScript.
  • Give the page a deliberate backend read model with a bounded number of queries.
  • Count in SQL, avoid N+1 lookups, fetch independent data in parallel and cache the route with ISR.
  • Treat analytics, marketing pixels and A/B-testing tools as part of the JavaScript budget.

That is the same principle from the original KittenPlein build, applied to performance instead of product architecture: when the data model is structured and the server does the work, the fast version and the correct version stop being a trade-off. They become the same build.

You can see the result on KittenPlein's live category pages, or explore the wider KittenPlein marketplace case study.