Writing · Performance · Next.js · SEO

I Rebuilt My Site With Zero JavaScript and Got 100 on Mobile Lighthouse

By Ezequiel Carrizo

The new ezequielcarrizo.com: I build products that work for a living
Stack
Next.js 16 static export
Hosting
S3 + CloudFront via SST
Client JavaScript
None
Lighthouse (mobile)
100 / 100 / 100 / 100

My site had stopped describing me. It opened with a résumé-style hero, a typewriter effect and a theme switcher, while the work I care about most, the products I build outside my day job, was nowhere on it. So I rebuilt it around them, and set myself one hard rule: 100 on every Lighthouse category, on mobile, on every page.

The short version: the site is now static HTML with no client-side JavaScript, no backend and no analytics, and all 25 indexable pages score 100 / 100 / 100 / 100 on mobile Lighthouse.

What I removed

The old site was a Next.js 14 static export with Tailwind, four colour themes, scroll animations, client-side code highlighting, a contact form and a newsletter form. The two forms needed an API: API Gateway, two Lambdas, Amazon SES to email me, and Cloudflare Turnstile to keep bots out. The newsletter did not even store subscribers; it emailed me when someone signed up.

All of that is gone:

  • Forms became mailto: links. No Lambdas, no SES, no captcha, no spam to filter.
  • Tailwind became one stylesheet of about 15 KB, inlined into each page.
  • Prism became Shiki at build time. Code blocks arrive as coloured HTML.
  • Google Analytics went. Search Console tells me what I need about search, without cookies.
  • Remote cover images became local WebP files. One of the old Unsplash URLs was already a 404 in production.

The runtime dependencies are now three: next, react and react-dom.

The configuration

const nextConfig = {
  output: 'export',
  trailingSlash: true,
  images: { unoptimized: true },
  experimental: { inlineCss: true },
}

inlineCss puts the stylesheet in a <style> tag instead of a render-blocking <link>. With a small hand-written stylesheet that is a clear win: Lighthouse had flagged 270 ms of render-blocking CSS before it.

Why mobile was stuck at 98

With all of that done, desktop scored 100 everywhere, but mobile Performance sat at 98 with a Largest Contentful Paint of 2.5 seconds. The LCP element on the home page is the headline, plain text, and First Contentful Paint was 0.9 seconds. Something was holding the headline back.

I tried font-display: optional on the main font. No change. Then I copied the build, stripped every script tag from it and ran Lighthouse again: LCP dropped to 1.5 seconds and Performance hit 100.

The culprit was the Next.js client runtime, about 120 KB of React and router code. Lighthouse simulates a slow 4G connection, and those scripts compete with the font and the HTML for bandwidth before the headline paints. On this site that runtime does nothing useful: every page is a server component, there is no client state, and nothing on the page needs React in the browser.

Removing the runtime

So the build now runs a small script after next build that removes every script tag except JSON-LD:

// scripts/strip-js.mjs, run after `next build`
const scripts = /<script(?![^>]*application\/ld\+json)[^>]*>[\s\S]*?<\/script>/g
const scriptPreloads = /<link rel="preload" as="script"[^>]*\/>/g

for (const page of htmlFilesIn('deployment')) {
  const html = readFileSync(page, 'utf8')
  writeFileSync(page, html.replace(scripts, '').replace(scriptPreloads, ''))
}

The trade-off is that internal links become normal page loads instead of client-side transitions. For a site of small static pages served from CloudFront, nobody will notice. If a page ever needs interactivity, the script has to go first, and the README says so.

Images

On posts, the LCP element is the cover image. A 1200 px cover is far more than a phone needs, so each cover can have a 640 px copy next to it, and the post template adds a srcset when it finds one:

// A cover gets a srcset when a 640px copy sits next to it as <name>-640.webp
function smallVariant(src: string) {
  const small = src.replace(/\.webp$/, '-640.webp')
  return small !== src && existsSync(join(process.cwd(), 'public', small)) ? small : undefined
}

That took the image-heavy posts from 99 to 100 on mobile.

SEO, and being readable by answer engines

Speed was half the job. The other half was making it easy for search engines and LLM-based answer engines to understand who I am and what I build:

  • JSON-LD: a Person with sameAs links to LinkedIn, GitHub and Stack Overflow, a SoftwareApplication for each product with me as creator, and a BlogPosting on every post.
  • llms.txt: a plain-text summary of the site, the products and every post, generated at build time.
  • robots.txt allows every crawler, AI crawlers included. Being cited in an answer is part of the point.
  • A canonical URL on every page, a sitemap, an RSS feed and a noindex 404.

Auditing every page turned up problems that had been live for a long time: one post had an empty title and description, another had a description with a stray code fragment pasted into it, and one list was invalid HTML. None of them broke the page visibly. All of them cost search visibility.

What I would tell someone doing the same

  • Measure on mobile. Desktop hid the problem completely.
  • When a metric will not move, remove things from a copy of the build until it does. It took one experiment to find the cause.
  • Audit the HTML you ship, not the code you wrote. Empty metadata does not show up in a browser.

Two older posts describe the previous version of the site: how I built the original blog and my first attempt at a perfect score.

Written by Ezequiel Carrizo, lead software engineer at The Knot Worldwide.