Skip to main content
Back to blogWeb Performance

Lovable Website to Lighthouse 100/100: A Complete Guide

Patrick LotteApril 19, 202614 min read
Lighthouse 100/100 score on a Lovable website

From Good to Perfect: Lighthouse 100/100 on a Lovable Site

Lovable is a fantastic AI-powered tool for building professional websites fast. It outputs a React + Tailwind CSS app that you can export as static HTML and deploy anywhere — including Cloudflare Pages.

The default export scores well, but not perfectly. After running Google Lighthouse on googleadsexpert.com we found eight fixable issues preventing a 100/100 across all four categories. This post walks through every fix with copy-paste Node.js scripts so you can do the same.

Prerequisites

  • You have a Lovable site exported to a dist/ folder
  • Node.js 18+ installed
  • npm install purgecss (only needed for Fix 7)
  • About 30 minutes of your time

Fix 1 — Eliminate Render-Blocking CSS

Problem: Lovable generates a <link rel="stylesheet" href="/assets/index-[hash].css"> in the <head>. The browser must fully download and parse this file before it can paint anything — adding 300–600 ms to your Time to First Byte on mobile.

Fix: Read the CSS file and write it directly into a <style> tag, then remove the blocking <link>.

// inline-css.mjs
import fs from 'fs';
import path from 'path';

const DIST = './dist';
const cssFile = fs.readdirSync(path.join(DIST, 'assets'))
  .find(f => f.startsWith('index-') && f.endsWith('.css'));
const cssContent = fs.readFileSync(path.join(DIST, 'assets', cssFile), 'utf8');

const PRELOAD_RE    = /<link[^>]+rel="preload"[^>]+as="style"[^>]*>s*/g;
const STYLESHEET_RE = /<link[^>]+rel="stylesheet"[^>]+index-[^.]+.css[^>]*>/g;

function walkHtml(dir, cb) {
  for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
    const full = path.join(dir, e.name);
    if (e.isDirectory() && e.name !== 'assets') walkHtml(full, cb);
    else if (e.name.endsWith('.html')) cb(full);
  }
}

walkHtml(DIST, filePath => {
  let html = fs.readFileSync(filePath, 'utf8');
  html = html.replace(PRELOAD_RE, '');
  html = html.replace(STYLESHEET_RE,
    '<style id="main-css">' + cssContent + '</style>');
  fs.writeFileSync(filePath, html, 'utf8');
});

Run: node inline-css.mjs

Fix 2 — Restore Broken CTA Buttons

Problem: Lovable's CTA buttons use React onClick handlers to scroll to form sections. In the static HTML export those handlers are gone — the buttons do nothing when clicked.

Fix: Replace the <button> elements with plain <a href="#section-id"> links.

// fix-buttons.mjs — adapt OLD/NEW to match your button text and target IDs
const OLD = '<button class="...">Get Started</button>';
const NEW = '<a href="#contact" class="...">Get Started</a>';

Tip: open DevTools → Elements panel on the exported site to find the exact class strings to replace.

Fix 3 — Fix the LCP Animation Delay

Problem: Lovable applies animate-slide-up with an animation-delay to the hero headline or subtitle. Tailwind's slide-up animation starts at opacity: 0, meaning the Largest Contentful Paint element is invisible for 600–800 ms after the page loads — directly inflating your LCP time.

Fix: Identify the LCP element (usually the hero paragraph — check the Lighthouse LCP breakdown) and add a CSS override to skip the animation on it.

// Add id="lcp-element" to the hero paragraph in your HTML, then add this CSS:
<style>
  #lcp-element {
    animation: none !important;
    opacity: 1 !important;
    transform: none !important;
  }
</style>

All other hero animations remain intact — only the LCP element renders immediately.

Fix 4 — Remove the Forced Reflow

Problem: Lovable's nav script calls setSolid(false) (which writes styles to the DOM) immediately before new IntersectionObserver(...).observe(heroLogo) (which reads layout). This write → read pattern forces the browser to recalculate layout synchronously — adding ~130 ms of blocking time.

Fix: Remove the redundant setSolid(false) call. The nav starts transparent via CSS (background-color: transparent) by default, so the explicit call is unnecessary.

// BEFORE (causes forced reflow)
setSolid(false);
new IntersectionObserver(entries => {
  setSolid(!entries[0].isIntersecting);
}, { threshold: 0.1 }).observe(heroLogo);

// AFTER (no reflow)
new IntersectionObserver(entries => {
  setSolid(!entries[0].isIntersecting);
}, { threshold: 0.1 }).observe(heroLogo);

Fix 5 — Add Image Dimensions to Prevent Layout Shift

Problem: Images without explicit width and height attributes cause Cumulative Layout Shift (CLS) — the page reflows when the image loads and the browser discovers its real size.

Fix: Add width and height attributes matching the image's intrinsic dimensions. If you also constrain the image with CSS (e.g. h-8), add w-auto so the aspect ratio is maintained.

<!-- Before -->
<img src="/assets/badge.png" class="h-8" alt="Verified">

<!-- After -->
<img src="/assets/badge.png" class="h-8 w-auto"
     width="651" height="102" alt="Verified">

To find an image's intrinsic dimensions: right-click it in the browser → "Open image in new tab" → check the title bar, or use identify badge.png (ImageMagick) / sharp.metadata() in Node.

Fix 6 — Fix WCAG Colour Contrast for Accessibility 100

Problem: Lovable's default accent colour is orange (hsl(25 95% 53%)#f97316). Against a white or light-tinted background this gives a contrast ratio of only ~2.9:1 — well below the WCAG AA minimum of 4.5:1 for normal text.

Fix: Override the --accent CSS variable with a darker shade. We chose #b7382a (deep red, 5.8:1 on white). Add the override after the main CSS so it wins the cascade:

<style id="contrast-fixes">
  /* Override accent to pass WCAG AA (4.5:1) on light backgrounds */
  :root {
    --accent: 6 63% 44%; /* #b7382a */
    --ring:   6 63% 44%;
  }

  /* Dark text on light backgrounds */
  .text-accent, .text-orange-500 {
    color: #b7382a !important;
  }

  /* Restore a lighter shade inside dark sections (hero, footer) */
  .bg-hero-gradient .text-accent,
  footer .text-accent {
    color: hsl(6 80% 65%) !important;
  }
</style>

Important: check your contrast on stacked transparent backgrounds too. If a badge has bg-accent/10 inside a section with bg-secondary/30, the effective background luminance is higher than white — compute the blended colour before assuming a pass.

A useful tool: WebAIM Contrast Checker.

Fix 7 — Safely Purge Unused CSS

Problem: The full Tailwind CSS bundle inlined by Fix 1 is ~97 KB. Only ~36 KB of it is actually used on any given page. The remaining 61 KB inflates transfer size and parse time.

Why the obvious approach breaks things: Running PurgeCSS with the default extractor strips Tailwind's responsive classes (md:grid-cols-3, lg:flex, etc.) because the default extractor splits on :. The layout collapses to mobile on every screen size.

The real fix — two key changes:

  1. Return CSS-escaped tokens from the extractor. HTML contains md:grid-cols-3 but the CSS file contains .md:grid-cols-3. PurgeCSS checks whether the CSS selector includes the extracted token — so you must return md:grid-cols-3 (with backslash) from the extractor, not md:grid-cols-3.
  2. Strip the main CSS from the HTML before scanning. If the <style id="main-css"> block is included in the content, every class defined in the CSS keeps itself alive — defeating the whole point.
// purge-css-safe.mjs  (requires: npm install purgecss)
import { PurgeCSS } from 'purgecss';
import fs from 'fs';

function cssEscape(token) {
  return token
    .replace(/:/g,  '\\:')
    .replace(/\[/g, '\\[')
    .replace(/\]/g, '\\]')
    .replace(/\//g, '\\/')
    .replace(/!/g,  '\\!');
}

function tailwindExtractor(content) {
  const fromAttrs = [...content.matchAll(/class(?:Name)?=["']([^"']+)["']/g)]
    .flatMap(m => m[1].split(/\s+/))
    .filter(Boolean);
  const broad = content.match(/[\w-]+(?:[\/:\[\]!][\w-]+)*/g) ?? [];
  const all = [...new Set([...fromAttrs, ...broad])];
  return all.flatMap(t => {
    const esc = cssEscape(t);
    return esc !== t ? [t, esc] : [t];
  });
}

const CSS_RE = /(<style id="main-css">)([\s\S]*?)(<\/style>)/;

async function purgeFile(filePath) {
  const html = fs.readFileSync(filePath, 'utf8');
  const match = html.match(CSS_RE);
  if (!match) return;
  const rawCss = match[2];
  // Strip the CSS from the content so it doesn't scan itself
  const htmlNoCss = html.replace(CSS_RE,
    '<style id="main-css"></style>');
  const [result] = await new PurgeCSS().purge({
    content: [{ raw: htmlNoCss, extension: 'html' }],
    css:     [{ raw: rawCss }],
    defaultExtractor: tailwindExtractor,
    safelist: {
      standard: [
        'nav-solid', 'active', 'open', 'loading',
        /^opacity-/, /^translate-/, /^pointer-events-/,
        /^animate-/, /^transition-/, /^group-/,
      ],
    },
  });
  fs.writeFileSync(filePath, html.replace(CSS_RE, '$1' + result.css + '$3'), 'utf8');
}

Result: 97.5 KB → 36.1 KB per page (63% reduction), no layout regressions.

Fix 8 — Hide the Invisible Nav Logo from the Contrast Auditor

Problem: Lovable's nav includes a brand logo that starts as opacity: 0 when the hero is visible, becoming visible on scroll. Lighthouse still audits contrast on opacity: 0 elements. White text on a transparent nav (showing through to the near-white page background) fails with a contrast ratio of ~1:1.

Fix: Add visibility: hidden when the element has the opacity-0 class. Lighthouse skips visibility: hidden elements. When JS removes opacity-0 on scroll, the CSS rule no longer applies and the element becomes accessible again — no JS changes needed.

<style id="contrast-fixes">
  /* Nav logo starts hidden — exclude from Lighthouse contrast audit */
  nav.fixed a.opacity-0 { visibility: hidden; }
</style>

The Result

After all eight fixes, running Lighthouse on mobile gave:

CategoryBeforeAfter
Performance~78100
Accessibility~82100
Best Practices~95100
SEO~92100

All fixes are applied as post-build Node.js scripts — no changes to the Lovable source are needed. Re-run the scripts each time you export a new build.

Quick Checklist

  • Inline CSS (inline-css.mjs) — eliminates render-blocking
  • Replace broken CTA buttons with <a href="#..."> links
  • Disable animation on the LCP element
  • Remove setSolid(false) before IntersectionObserver.observe()
  • Add width/height to all images; add w-auto if height-constrained
  • Override --accent to a darker shade (≥ 4.5:1 contrast on light bg)
  • Purge unused CSS with the Tailwind-aware extractor
  • Hide opacity-0 nav elements with visibility: hidden

Frequently Asked Questions

Do I need to redo all this every time I update my Lovable site?

Yes — but it takes under two minutes. Keep all the scripts in your project root and add them to a package.json post-build step: "postbuild": "node inline-css.mjs && node purge-css-safe.mjs --apply && ...". Each deploy then runs them automatically.

Will purging CSS break my site after a Lovable update?

Only if Lovable adds new classes that aren't referenced in any HTML at build time (for example, classes injected purely by JavaScript). Cover these with the safelist option in PurgeCSS, or add them to your HTML as hidden placeholder elements.

My contrast checker passes but Lighthouse still fails — why?

Lighthouse computes contrast against the computed background, which includes stacked transparent layers. A badge with bg-accent/10 inside a section with bg-secondary/30 has a blended background — not white. Compute the blended colour manually (or use the DevTools colour picker on the exact element) before checking.

Does a 100 Lighthouse score guarantee good Google rankings?

Lighthouse score correlates with Core Web Vitals, which are a confirmed ranking factor. But a perfect score is not a ranking guarantee — content quality, backlinks, and E-E-A-T matter too. Think of 100/100 as the technical foundation that lets your content compete on a level playing field.

What Lighthouse score does a fresh Lovable export typically get?

In our experience: Performance 75–85, Accessibility 80–88, Best Practices 90–95, SEO 88–95. The exact numbers depend on your page content, images, and which Lovable template you used.