Why Your AI-Built Site Fails Lighthouse
The v0 hero you shipped scores 41 on mobile. Here's the exact map from each slop pattern — framer-motion bundles, JS-gated reveals, a 2.4MB unsplash hero — to the audit it tanks, and the fix.
A landing page came across my desk last week: clean, dark, a bg-zinc-950 hero with a gradient headline, three feature cards that fade up on scroll. Built in v0, lightly edited in Cursor, shipped to Vercel. It looked fine. Then I ran Lighthouse on it, mobile, throttled to the default Moto G Power profile.
Performance 41
LCP 5.8 s
TBT 610 ms
CLS 0.31
First Load JS 412 KBForty-one. The site that "looked fine" was in the red on every Core Web Vital. And the thing is, none of these numbers are random. Each one traces back to a specific, recognizable slop pattern — the same patterns that make these sites look identical in the first place. The visual sameness and the performance failure come from the same generated defaults. So let me map them, one failing audit at a time.
CLS 0.31: the scroll-reveal is eating your layout
Cumulative Layout Shift measures how much stuff jumps around as the page loads. Google's "good" threshold is 0.1. "Poor" starts at 0.25. This page hit 0.31, and I knew exactly where it came from before I even opened the source, because it's the single most common AI-generated motion pattern: the fade-in-up scroll reveal.
Here's the generated code, near-verbatim from what v0 and Bolt emit:
<motion.div
initial={{ opacity: 0, y: 40 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, ease: "easeOut" }}
>
<FeatureCard />
</motion.div>The problem isn't the animation itself — it's opacity: 0 combined with content that hasn't reserved its space. Worse is the common variant where the whole section is display: none or height-collapsed until it enters the viewport. When the IntersectionObserver fires, the card pops into existence and everything below it lurches down. That lurch is your CLS. Every card on the page that animates in on scroll is a layout-shift event waiting to happen, and AI scaffolds put this on *everything* — hero, features, testimonials, footer CTA.
I wrote a whole piece on why this exact pattern is a motion-slop tell; the performance cost is the part nobody mentions when they paste it in.
The fix is not "remove the animation." It's: reserve the space, then animate inside it. The element must occupy its final dimensions from first paint. Animate opacity and transform only — both are composited properties that never trigger layout:
/* The wrapper holds its size. Only the visual transforms. */
<div className="min-h-[var(--card-h)]">
<motion.div
initial={{ opacity: 0, y: 24 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
style={{ willChange: "transform, opacity" }}
>
<FeatureCard />
</motion.div>
</div>transform: translateY() does not affect surrounding layout — the browser composites it on the GPU and nothing reflows. So the card can slide up 24px visually while its layout box never moves. CLS contribution: zero. Even simpler, drop JS entirely and use a CSS @keyframes with animation-timeline: view() where browser support allows, so there's no observer, no hydration dependency, no shift.
LCP 5.8s: the hero image nobody compressed
Largest Contentful Paint is when the biggest above-the-fold element finishes rendering. Good is under 2.5s. This page took 5.8s, and the culprit was a single file: hero.jpg, 2.4 MB, 4000×2667, served as a raw JPEG straight from an Unsplash download.
This is the most boring failure in the catalog and the most common. The AI builder generates or a CSS 
background-image, you drop in a stock photo, and nobody ever runs it through anything. The browser downloads two-and-a-half megabytes over a throttled mobile connection before it can paint the hero. On a 1.6 Mbps Slow 4G profile, that download alone is roughly 12 seconds of theoretical transfer; even with real-world ramping you're looking at multiple seconds of dead screen.
Three things are wrong and each one independently tanks LCP:
- Format. It's a JPEG. AVIF would be 60-80% smaller at the same perceived quality. WebP, 25-35% smaller.
- Dimensions. It's 4000px wide rendering into a container that's never more than 1200px. You're shipping 11x the pixels you display.
- Priority. There's no
fetchpriority="high", no preload. The browser discovers the hero late, after it's parsed the CSS that references it.
The fix, using Next.js which handles most of this if you let it:
import Image from "next/image";
import hero from "@/public/hero.jpg"; // local import = build-time optimization
<Image
src={hero}
alt="..."
priority // preloads + fetchpriority=high
sizes="100vw"
placeholder="blur" // no CLS while it loads
quality={70}
/>If you're not on Next, the raw-HTML equivalent does the same work explicitly:
<link rel="preload" as="image" href="/hero.avif" fetchpriority="high">
<img
src="/hero.avif"
width="1200" height="800"
fetchpriority="high"
decoding="async"
alt="...">Compress that 2.4 MB JPEG to a properly-sized AVIF and you're at roughly 90-140 KB. LCP drops from 5.8s to under 2s on the same throttle. One file, and you've cleared the single hardest Core Web Vital.
Note the width and height attributes — those also feed back into CLS, because an without intrinsic dimensions reserves zero space until it loads, then shoves the page down when it arrives. AI builders omit them constantly.
TBT 610ms: framer-motion is 34 KB you didn't need
Total Blocking Time is how long the main thread is frozen during load — unable to respond to taps, scrolls, anything. Good is under 200ms. This page sat at 610ms, and the biggest single contributor was JavaScript that exists purely to do animations CSS could do for free.
Crack open the bundle. framer-motion (now motion) is roughly 34 KB gzipped for the core, more once you pull in layout animations and gestures. On the page above it was being used for exactly one thing: the fade-in-up reveals. Thirty-four kilobytes of parse-compile-execute, blocking the main thread, to fade some cards in. The same effect in CSS is zero bytes of JavaScript:
@media (prefers-reduced-motion: no-preference) {
.reveal {
animation: fade-up 0.6s ease-out both;
animation-timeline: view();
animation-range: entry 0% cover 30%;
}
}
@keyframes fade-up {
from { opacity: 0; transform: translateY(24px); }
to { opacity: 1; transform: translateY(0); }
}No library. No hydration. No main-thread cost. And for the browsers that don't support scroll-driven CSS animations yet, the content just shows — which is the correct fallback anyway.
This is the broader pattern with AI-built React sites: they reach for a heavy dependency for every small interaction. A counter that animates up? react-countup. A carousel? embla plus framer-motion. A tooltip? An entire Radix import. Each one is fine in isolation; stacked, they produce a First Load JS of 412 KB where a hand-built equivalent ships 80-120 KB. The TBT is the sum of parsing and executing all of it before the page is interactive.
The audit to watch is "Reduce unused JavaScript" and "Avoid serving legacy JavaScript." Run npx @next/bundle-analyzer or vite-bundle-visualizer and look at the treemap. On the AI sites I audit, the three fattest blocks are almost always: the animation library, an icon library imported wholesale (import * as Icons pulls in all of lucide), and a date/charting library used on one component. Tree-shake the icons (import { ArrowRight } from "lucide-react", never the barrel), lazy-load anything below the fold, and replace framer-motion's reveals with CSS.
First Load JS 412 KB: the hydration tax
There's a quieter failure underneath all of this. The page is mostly static — a hero, some cards, a pricing table, a footer. None of it needs client-side JavaScript to render. But because the AI scaffold made everything a client component (every file gets "use client" slapped on top by default in a lot of generated output), the entire thing ships its markup *and* a JS payload to re-render that same markup on the client. You pay for the HTML twice.
In the Next App Router, the fix is to delete "use client" from everything that doesn't have an event handler or a hook. A that's just markup is a Server Component — it ships as HTML, zero JS. Push the "use client" boundary down to the smallest leaf that actually needs interactivity — the one with an onClick, not the section that contains it.
// page.tsx — Server Component, no "use client"
export default function Page() {
return (
<>
<Hero /> {/* static, 0 KB JS */}
<Features /> {/* static, 0 KB JS */}
<SignupButton /> {/* this one is "use client" */}
</>
);
}That single discipline — interactivity at the leaves, static everything else — is what separates a 120 KB first load from a 412 KB one. The AI default does the opposite because it's safer for the generator to mark everything client-side; it just costs you the whole performance budget.
Why this is also an SEO problem
These aren't vanity numbers. Core Web Vitals are a confirmed ranking signal, and Google's documentation is explicit that page experience compounds with content quality. A site that's both thin AI content and slow gets hit from both directions — the helpful-content systems discount the text, and the CWV signals discount the page. The 41 Lighthouse score isn't just a developer-experience embarrassment; on a competitive query it's the difference between page one and page three.
The 15-minute audit
You don't need a methodology. Open the deployed site, DevTools, Lighthouse tab, mobile, analyze. Then walk the failures top to bottom:
- CLS over 0.1 → find the scroll reveals and the dimensionless images. Reserve space, add
width/height, animate onlytransform/opacity. - LCP over 2.5s → it's the hero image 95% of the time. AVIF, correct dimensions,
priority/preload. - TBT over 200ms → bundle analyzer. Kill framer-motion if it's only doing reveals. Tree-shake icons. Lazy-load below-fold JS.
- First Load JS over ~150 KB → hunt
"use client". Push the boundary to the leaves.
Every one of these maps to a generated default, not a design decision you made. That's the tell. The site is slow for the same reason it looks like every other AI site: nobody made a choice, the model did, and the model optimizes for output that compiles, not output that's fast.
Run Lighthouse before you ship. If it's under 90 on mobile, the patterns above are almost certainly why, and the fixes above are almost certainly enough. The whole audit is fifteen minutes. The 2.4 MB hero image alone is probably ten points.
SHIP CODE THAT LOOKS INTENTIONAL
Scan your frontend for AI patterns. Generate a unique design system. Stop shipping the same blue gradient as everyone else.