How we scored 98/97/100/100 on Lighthouse.
Mobile. Production. GSAP animations, canvas particle effects, and MDX content. No tricks, just good engineering.
~2800 words; about an 8 minute readShip less, load smarter.
We didn't set out to chase Lighthouse scores. We built the site the way we think sites should be built. The scores were a side effect.
The biggest win was the simplest one. Next.js Server Components. Most of our pages ship zero client-side JavaScript. Homepage, about, work, writing. All server-rendered at build time. Client JS only goes where it's actually needed: scroll animations, canvas effects, cookie consent. Out of the entire codebase, only 20 files carry the "use client" directive.
Self-hosted fonts
We load three font families (Satoshi, Gambetta, Bebas Neue) from our own server using next/font/local. No Google Fonts CDN. No external network requests at all. Every font file is WOFF2 only with font-display: swap, which means text is visible immediately and the custom font swaps in when ready. No flash of invisible text.
Image optimization
Next.js <Image> component with AVIF and WebP formats configured at the framework level. The hero image gets priority which adds a <link rel="preload"> for LCP. Every below-fold image gets loading="lazy". Every image has explicit sizes so the browser fetches the right resolution instead of downloading the largest one.
Animation discipline
This is where most sites bleed performance. We run GSAP ScrollTrigger animations, a 6000-particle canvas effect, and an infinite marquee. Every single one of them cleans up on unmount via gsap.context().revert().
Every canvas animation pauses when it scrolls out of view using IntersectionObserver. The particle system pre-renders a sprite to an offscreen canvas instead of calling arc() and fill() on every frame for every particle. On mobile, the heavy canvas effects fall back to static images entirely.
Static generation everywhere
Every dynamic route uses generateStaticParams to pre-render pages at build time. No server-side rendering on request. The entire site is static HTML served from a CDN. Google Analytics loads with afterInteractive strategy so it never blocks rendering or hydration.
Tell search engines everything.
100 is the easy score if you're methodical about it. No clever hacks, just cover every base.
Metadata on every page
Root layout defines a title template (%s | Laser Revolver), metadataBase for URL resolution, and canonical tags. Every page exports its own metadata with a unique description. Dynamic routes use generateMetadata to pull titles and descriptions straight from MDX frontmatter. No page is anonymous to search engines.
Structured data
Global JSON-LD with Organization and WebSite schemas on every page. Each blog post injects a BlogPosting schema with headline, datePublished, and author. Each case study gets a CreativeWork schema. Search engines don't have to guess what any page is about.
Dynamic OG image
Built with next/og ImageResponse. Loads our actual Bebas Neue font file and brand icon at build time, generates a 1200x630 PNG. Twitter card set to summary_large_image. Every social share looks intentional.
The infrastructure
Programmatic sitemap.xml generated from file-system content with priority weighting. robots.txt pointing to the sitemap. RSS feed with Atom self-link. Full favicon suite: ICO, SVG, PNG, Apple Touch Icon. <time> elements with machine-readable dateTime attributes. lang="en" on the root element. Nothing left to chance.
Respect every user.
97. We know where the missing 3 points are. But first, what got us here.
prefers-reduced-motion everywhere
Not just CSS transitions. Every GSAP animation checks this preference. Every ScrollTrigger. Every canvas effect. The 6000-particle FlowingImage animation falls back to a static <img> entirely. We counted 22+ instances across SCSS and JavaScript. In CSS, every transition with a duration gets transition-duration: 0s inside a prefers-reduced-motion: reduce media query.
If a user says they don't want motion, we listen.
ARIA where it matters
role="img" with aria-label on canvas elements, because canvas doesn't have an alt attribute. role="list" on styled lists where list-style: none strips semantics in Safari. aria-hidden="true" on decorative echo effects, cloned marquee content, and avatar placeholders. Cookie consent is a proper role="dialog" with aria-label. Every navigation landmark has a label.
Keyboard navigation
Focus outlines show on :focus-visible only, so keyboard users see them but mouse clicks don't trigger outlines. Card components respond to :focus-within for keyboard accessibility. Headings have scroll-margin-block-start so they don't hide behind the fixed nav when you click an anchor link.
Semantic HTML
<main>, <header>, <footer>, <nav>, <article>, <aside>, <section>, <hgroup>. Description lists (<dl>) for article metadata. Decorative images have empty alt="". CSS reset preserves text-size-adjust without disabling user zoom. Smooth scrolling only activates on :focus-within so it doesn't interfere with initial page load. The boring stuff that makes screen readers actually work.
Don't cut corners.
External link security
Every target="_blank" link includes rel="noopener noreferrer". No exceptions, no forgetting. This prevents reverse tabnapping and referrer leaking.
Analytics gated behind consent
GA4 scripts only load after the user explicitly accepts cookies via the consent dialog. Not before. The cookie consent component stores the preference in localStorage and is a proper role="dialog" with clear accept and decline options.
TypeScript, no any
Every component uses explicit interfaces. Props are typed. Content types are defined centrally in lib/types.ts. The compiler catches problems before the browser does. Zero any types in the entire codebase.
Animation memory management
Every GSAP animation runs inside a scoped gsap.context() with ctx.revert() on unmount. Canvas animations clean up with cancelAnimationFrame. Event listeners are removed. IntersectionObservers are disconnected. No memory leaks, no stale DOM references on client-side navigation.
Error boundaries
Custom error.tsx for 500 errors with a reset button. Custom not-found.tsx for 404s with a link home. MDX processing uses only trusted, well-known plugins. No dangerouslySetInnerHTML on user content.
What we'd still improve.
We're not pretending this is perfect.
Font subsetting
We serve full font files right now. Subsetting to only the characters we actually use would shave kilobytes off the initial load. For three font families, that adds up.
Dynamic imports for canvas components
The particle animation bundle loads immediately even if the component is below the fold. next/dynamic with ssr: false would defer that entire bundle until the component scrolls into view.
will-change hints
We don't use will-change on any animated elements. For GSAP-driven transforms, this would give the browser a heads-up to promote layers earlier instead of doing it on the fly.
Those 2 missing Performance points and 3 Accessibility points are probably hiding in font loading timing and color contrast edge cases. We'll get there.
Good scores aren't about tricks. They're about making consistent decisions. Ship less JavaScript. Respect user preferences. Tell search engines what you are. Clean up after yourself.
We build TV apps that run on devices with 512MB of RAM. Performance isn't something we bolt on at the end. It's how we think about every project.
❦