My /projects page in Next.js 16 shipped 854 KB of HTML for a list of 49 projects. Almost all of that weight was the same few strings repeated, and in the App Router every repeat ships twice: once as HTML and once more inside the inline React Server Components payload (the self.__next_f.push scripts at the bottom of the page). Three changes took it to 459 KB: an SVG sprite for the icon on every row, JSON-LD that links to detail pages instead of copying them, and named Tailwind utilities for the row classes. A fourth stopped the page from prefetching 36 routes it didn't need.

Why is my Next.js page HTML so large?

Open view-source on any App Router page and scroll to the bottom. After the HTML you will find self.__next_f.push([1,"..."]) scripts. That is the RSC payload, the serialized component tree React uses to hydrate the page and to navigate without a full reload. Every element a server component renders appears in it as data, props included.

So a server component that renders an inline <svg> 49 times puts that path in the document 98 times. On my page today the payload is 261 KB of the 460 KB document.

Compression hides this. The live page is 33 KB with brotli and 45 KB with gzip, because repeated strings are what compressors handle best. That is why the problem never showed up as a slow download. The phone still has to decompress, parse and hold every byte, and that cost lands on the main thread of the slowest device that opens the page.

How do I find what is making the page heavy?

Weigh it by parts before changing anything. My first guess was the usual one, large props passed into client components and duplicated into the payload. Every component on /projects was a server component, so that guess was wrong, and work planned around it would have saved nothing.

This script fetches a page and prints the HTML size, the RSC payload, the JSON-LD, and the long attribute values that repeat most:

// weigh.mjs: node weigh.mjs https://example.com/page const html = await (await fetch(process.argv[2])).text() const kb = (n) => `${(n / 1024).toFixed(0)} KB` const flight = [...html.matchAll(/self\.__next_f\.push\(\[1,"([\s\S]*?)"\]\)<\/script>/g)] .reduce((sum, m) => sum + m[1].length, 0) const jsonLd = [...html.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)] .reduce((sum, m) => sum + m[1].length, 0) console.log(`HTML ${kb(html.length)}, RSC payload ${kb(flight)}, JSON-LD ${kb(jsonLd)}`) // Long SVG paths and class strings, in the HTML (d="…") and the payload (\"d\":\"…\"). const counts = new Map() for (const m of html.matchAll(/(?:\bd|class|className)\\?"?[=:]\\?"([^"\\]{80,})/g)) { counts.set(m[1], (counts.get(m[1]) ?? 0) + 1) } ;[...counts] .map(([value, n]) => ({ n, bytes: n * value.length, value: value.slice(0, 50) })) .sort((a, b) => b.bytes - a.bytes) .slice(0, 5) .forEach((r) => console.log(`${kb(r.bytes).padStart(7)} ${String(r.n).padStart(3)}x ${r.value}…`)) 

On the 854 KB version the top line was a single 2,115-character path, the GitHub mark, 98 times.

Fix 1: draw repeated icons once with an SVG sprite

The GitHub link on each row rendered its own <svg> with the full path. I moved the paths into one hidden sprite rendered by the root layout, and every icon now points at it with <use>:

const SYMBOLS = { github: { viewBox: '0 0 438.549 438.549', d: 'M409.132 114.573c…' }, } as const function symbolIcon(id: keyof typeof SYMBOLS) { return function SymbolIcon(props: React.SVGProps<SVGSVGElement>) { return ( <svg viewBox={SYMBOLS[id].viewBox} fill="currentColor" {...props}> <use href={`#icon-${id}`} /> </svg> ) } } export function IconSprite() { return ( <svg aria-hidden="true" width="0" height="0" style={{ position: 'absolute', overflow: 'hidden' }}> {Object.entries(SYMBOLS).map(([id, { viewBox, d }]) => ( <symbol key={id} id={`icon-${id}`} viewBox={viewBox}> <path fill="currentColor" d={d} /> </symbol> ))} </svg> ) } 

<IconSprite /> goes once in app/layout.tsx. Each use is now under 200 bytes instead of 2.1 KB, and currentColor still follows the link's text colour. /projects went from 854 KB to 676 KB.

The sprite is not free. It adds about 3 KB to every page, so a page with one GitHub link got slightly heavier. That trade only pays off for icons that repeat. A single logo stays inline.

Fix 2: JSON-LD on a list page should link, not copy

The page's ItemList structured data held a full SoftwareApplication for each of the 49 projects: name, description, image, author, operating system. Each project's own page already publishes that same entity. The list was 40 KB of JSON-LD, shipped twice.

Google's carousel documentation describes this case as a summary page: list items that point at detail pages carry only position and url. The helper now returns the short form whenever a detail page exists:

export function projectListItem(position: number, detailUrl: string | undefined, item: object) { return detailUrl ? { '@type': 'ListItem', position, url: detailUrl } : { '@type': 'ListItem', position, item } } 

The JSON-LD went from 40 KB to 10 KB, and no entity is described in two places that could drift apart.

Fix 3: name the class strings every row repeats

Each row carried long Tailwind strings for its grid, thumbnail, title, counter, tag and links, and they shipped twice like everything else. In Tailwind v4 a string that repeats down a long list can become a named utility, variants included:

@utility feed-row { @apply relative flex flex-col gap-3 border-b border-slate-300/10 py-6 last:border-none sm:grid sm:grid-cols-[200px_1fr] sm:gap-6 sm:py-7 lg:grid-cols-[240px_1fr]; } 

One catch: if the element's classes go through cn() (tailwind-merge), keep colours out of the utility. tailwind-merge can't see inside feed-link, so it won't know that a caller's text-teal-300 should replace the colour inside it. My feed-link holds layout only, and the colours stay on the element.

To prove nothing changed visually, I compared computed styles for every row part between a local next start build and production. They matched except for a rounding difference in one oklab channel.

Fixes 2 and 3 together took the page from 658 KB to 459 KB. (A rebuild between the two passes measured 658 KB, not the 676 KB from the sprite step.)

Fix 4: prefetch links on intent, not on scroll

This one doesn't change the HTML. Next.js prefetches every <Link> as it enters the viewport. On a page with a link per row, one visit to /projects fired 36 route prefetches, about 1 MB decoded, and on Vercel each one is a CDN request plus ISR reads.

The Next.js docs suggest a hover-only pattern for this. I added focus and touchstart so keyboard and phone users still get a head start:

'use client' import Link from 'next/link' import { useState } from 'react' export function IntentLink(props: React.ComponentProps<typeof Link>) { const [intent, setIntent] = useState(false) return ( <Link {...props} prefetch={intent ? null : false} onMouseEnter={() => setIntent(true)} onFocus={() => setIntent(true)} onTouchStart={() => setIntent(true)} /> ) } 

null is the default prefetch behaviour; false turns it off until there is intent. The <a href> in the HTML is identical, so crawlers see the same links.

How do I keep the page from growing back?

Set a budget that fails. My production check reads every URL in the sitemap and fails any page over 520 KB of uncompressed HTML:

const HTML_BUDGET_KB = 520 const kb = Math.round(html.length / 1024) if (kb > HTML_BUDGET_KB) fail(`${url}: ${kb} KB of HTML, budget ${HTML_BUDGET_KB} KB`) 

And pin each fix with a test. The sprite test renders each repeated icon and fails if one use is over 200 bytes or doesn't reference its symbol.

The budget alone wasn't enough. Writing this article, I ran the weighing script again and found a link arrow drawn inline 82 times on the page, about 11 KB, still under the budget and so never flagged. It is a symbol now too, and the page is 448 KB. So I re-run the weigh script whenever a list page changes, not only when the budget fails.