Skip to main content
Back to Blog
7 min read

How I hit Lighthouse 98 on a tri-lingual Next.js site

The wins that actually moved the score, the two I chased that didn't, and the part most Lighthouse tutorials quietly lie about.

next.jsperformancei18n

This site (the one you're reading) scores 98 on mobile Lighthouse in production, across three locales, with a real blog, real routing, and a real contact form. I didn't ship a static HTML clone to get there. Here's what actually moved the number, what didn't, and what I'd skip next time.

Before anything: a Lighthouse score is a proxy, not the goal. The goal is that the site loads fast for the person on a 4G connection in a hotel lobby. If optimizing for the score makes the site slower for them, I ignore the score. I mention this because most "get to 100" guides are optimizing for a screenshot, not a user.

The short version — what moved the number

In order of impact on my own audits:

  1. Self-hosting fonts, subsetting to the glyphs I actually use
  2. Replacing every decorative PNG/JPG with inline SVG
  3. Measuring what client-side JS was shipping and killing half of it
  4. Paying the i18n tax exactly once, at build time
  5. Tailwind 4 + tree-shaken CSS, no global reset kitchen sink

That's the stack ranking. The last two are often skipped in guides and they're worth a lot more than most image-optimization tips.

1. Fonts — the biggest free win

Google Fonts via <link> is the single most common perf mistake I see on modern sites. Even with font-display: swap, the request chain is:

  1. HTML loads
  2. Browser parses, finds the <link> to fonts.googleapis.com
  3. DNS, TLS, fetch the CSS
  4. Parse, find the actual .woff2 URLs (different domain)
  5. DNS, TLS, fetch the fonts
  6. Render

Five round-trips before text paints. On a slow 4G connection that's 800ms+ of blank screen before LCP.

What I do instead: next/font/local, .woff2 files in /public/fonts/, subsetted to Latin + Latin Extended + Cyrillic. font-display: swap. One request, same origin, cached aggressively.

Code:

// app/fonts.ts
import localFont from 'next/font/local'

export const inter = localFont({
  src: [
    {
      path: './fonts/Inter-Variable.woff2',
      weight: '100 900',
      style: 'normal',
    },
  ],
  variable: '--font-inter',
  display: 'swap',
  preload: true,
})

Subset the .woff2 with pyftsubset or glyphhanger to drop the CJK, Arabic, and mathematical glyphs you'll never use. A variable font that was 480KB became 86KB for me after subsetting to the three scripts I actually need.

Score delta: ~+8 points on mobile.

2. SVG for every decorative image

Every icon, every illustration, every accent on this site is an inline SVG or an SVG from /public. Zero decorative PNG/JPG. Photos are served via next/image with AVIF + responsive srcset; I just don't have many photos.

Why this is a bigger deal than image-compression tutorials admit: SVGs are part of the HTML stream. They paint with the rest of the page. Raster images are a second request, a second decode, and they block LCP until they land.

Cost: I spent an afternoon in Figma exporting clean SVGs and another hour optimizing them with SVGO. Worth every minute.

Score delta: ~+5 points on mobile, but the bigger win is the felt-responsiveness — the page visually completes in one paint instead of two.

3. Auditing client-side JS honestly

The thing that humbles you: open DevTools Coverage tab, load your site, scroll a bit, and look at what JS you shipped vs what actually ran.

My first audit on this site: 180KB of JS shipped to the client, ~40KB actually executed on first paint. 140KB of unused bytes sitting in the network tab for no reason.

Where it was coming from:

Fix pattern for each:

// framer-motion — use the lazy-loaded entry
import { LazyMotion, domAnimation, m } from 'framer-motion'

// syntax highlighter — load only when a code block is visible
const SyntaxHighlighter = dynamic(() => import('react-syntax-highlighter'), {
  ssr: false,
})

// icons — import the four I use, not the barrel
import ShieldIcon from '@/components/icons/ShieldIcon' // inline SVG, 200 bytes

Down to ~55KB shipped after the audit, ~40KB executed. That extra 15KB is routing + hydration, unavoidable without giving up Next.

Score delta: ~+6 points, but more importantly a ~400ms drop in Time to Interactive.

4. The i18n tax, paid once at build time

This site renders in English, Russian, and Ukrainian. The naive approach is to ship all three translation dictionaries to every page and pick one at runtime. That's ~90KB of JSON for a feature that only serves one language at a time.

next-intl (the library I use) supports static loading per locale at build time with the App Router. Each locale compiles its own static HTML with only its own messages inlined. Per-locale JSON in the client bundle: ~8KB, not 90KB.

// i18n/request.ts
export default getRequestConfig(async ({ locale }) => ({
  messages: (await import(`../messages/${locale}.json`)).default,
}))

The trick is the template literal. If you write await import('../messages/en.json') statically, the bundler ships only that. If you write it with a dynamic variable, Next inlines per-locale at build time because the routing is per-locale. One locale, one message file, no switching on the client.

Score delta: not a huge number on Lighthouse (maybe +2 because the bundle is smaller), but it's the difference between a site that feels fast and a site that feels bloated for users who open DevTools.

5. Tailwind 4, no global reset junk

Tailwind 4 with @theme and @utility ships a fraction of the CSS of Tailwind 3 when used properly. The production bundle for this site is ~9KB of CSS (gzipped), and it includes a small custom theme, the full component library, and typography styles for MDX.

Two specific things:

Score delta: small on Lighthouse directly, but every KB of CSS is a KB delaying first paint on slow connections.

What didn't help

Two things I spent time on that moved the score by less than a point:

Edge rendering everything. I tried moving my server components to the edge runtime on Vercel. Real-world latency improvement: 30ms on average, rarely reaching the critical path. And I lost access to some Node-only APIs I didn't want to refactor away. Reverted.

Hand-tuning critical CSS extraction. I tried a manual critical-CSS extractor that inlined the above-the-fold styles in <head> and deferred the rest. It worked, technically — but with Tailwind 4 already producing 9KB total CSS, the critical fold was effectively the whole file. Added complexity for a 0.2 point improvement.

If your CSS is 80KB+ on a page, critical extraction is probably worth it. Below 15KB, skip.

What I'd do differently

If I started this site from scratch tomorrow, the one thing I'd do earlier: measure from day 1. Set up a Lighthouse CI check in the GitHub Actions pipeline, fail the build below a threshold, look at the trace on every PR. The late wins on this site came after I had infrastructure to see regressions — which took me two months longer to set up than it should have.

The rest of the optimizations are small wins stacked carefully. There's no single trick. If you're below 90, start with fonts. If you're stuck at 92–94, audit your JS. If you're at 95+ and chasing the last three points, the return on effort drops hard.


If you want a perf audit on your Next.js or React Native app, I do one-off audits (€500, turnaround 3 days, written report). Mention it in the contact form and I'll send a date.