Skip to main content
All posts
ArchitecturePerformanceNext.jsCase Study

How I Built a 100 Lighthouse Score Portfolio — Every Decision Explained

12 min read

A behind-the-scenes look at every architectural, design, and performance decision made while rebuilding my portfolio from scratch — with AI as a pair programmer.

This isn't a tutorial. It's a decision log.

Every choice made while rebuilding this portfolio — from framework selection to how the theme toggle avoids flash — is documented here. Some decisions were mine. Some were made with AI as a pair programmer. All of them compound into a site that scores 100 across every Lighthouse category.

If you're building a portfolio (or any performance-critical site), steal whatever's useful.


The Starting Point

The old site was built on:

  • Next.js Pages Router with React 17
  • SCSS with Google Fonts loaded via CDN
  • No mobile responsiveness
  • 11 dependencies including mongoose, three.js, and react-slick
  • TypeScript strict mode disabled
  • No SEO metadata beyond a basic <title> tag

It worked. But it felt like a 2020 bootcamp project. Time to fix that.


Decision 1: Next.js 15 App Router — Not Pages Router

Why App Router?

  • Server Components by default — less JS shipped to the client
  • generateMetadata for type-safe, per-page SEO
  • generateStaticParams for blog pages — zero runtime data fetching
  • Streaming and Suspense built in for future complexity
  • Layout nesting without prop drilling

The result: the home page ships **566 bytes** of page-specific JS. Everything else is server-rendered HTML.


Decision 2: TypeScript Strict Mode

json
{
  "compilerOptions": {
    "strict": true
  }
}

Non-negotiable. The old codebase had strict: false and scattered any types. Strict mode catches bugs at compile time — before they reach users.

Zero any types in the entire codebase. Every prop, every function return, every data structure is typed.


Decision 3: Tailwind CSS — Not SCSS

The old site used SCSS with deeply nested BEM selectors and a custom color system. The new site uses Tailwind CSS with CSS custom properties for theming.

Why Tailwind?

  • Utility-first means no context switching between files
  • PurgeCSS built in — only used classes ship
  • CSS variables for dark mode — instant theme switches without re-rendering
  • No class name collisions, no specificity wars

Why CSS variables on top of Tailwind?

css
:root {
  --color-bg: #ffffff;
  --color-text: #0f172a;
  --color-accent: #3b82f6;
}

.dark {
  --color-bg: #0f172a;
  --color-text: #f1f5f9;
  --color-accent: #60a5fa;
}

Theme switching is just toggling a class on <html>. No React state. No re-render. The browser handles the rest via CSS inheritance.


Decision 4: Self-Hosted Fonts via next/font

tsx
const inter = Inter({
  subsets: ["latin"],
  variable: "--font-inter",
  display: "swap",
});

Why this matters:

  • **No external network request** — fonts are bundled at build time
  • **No layout shift** — display: swap with proper size hints
  • **No FOUT** — font loads with the initial HTML
  • Google Fonts CDN import was adding a render-blocking request. Gone.

Decision 5: Server Components by Default

Only 3 components in the entire site are client components:

1. navbar.tsx — needs useState for mobile menu and theme toggle 2. section-animate.tsx — uses IntersectionObserver for scroll animations 3. code-block.tsx — needs navigator.clipboard for copy button

Everything else — Hero, About, Experience, Projects, Skills, Contact, Footer, blog pages — are Server Components. They render to HTML on the server and ship zero JavaScript.

**How to leverage this:** Before making any component a client component, ask: "Does this need browser APIs or React state?" If not, it stays on the server.


Decision 6: Theme Persistence Without Flash

The classic dark mode problem: user selects dark → navigates → sees a flash of light mode before useEffect kicks in.

The fix: a blocking inline script in <head> that runs before the first paint.

html
<script>
  (function(){
    try {
      var t = localStorage.getItem("theme");
      if (t === "dark" || (!t && window.matchMedia("(prefers-color-scheme:dark)").matches)) {
        document.documentElement.classList.add("dark");
      }
    } catch(e) {}
  })()
</script>

This runs synchronously before React hydrates. Combined with suppressHydrationWarning on <html>, there's no flash and no hydration mismatch.


Decision 7: CMS-Ready Data Layer

All content lives behind an async abstraction:

Components → content.ts (async getters) → data.ts (static adapter)

Components receive data as **props**, never importing data.ts directly. Every getter is async even though it currently returns static data.

Why? When a CMS is integrated later:

1. Create adapters/sanity.ts (or Contentful, Strapi, etc.) 2. Swap the import in content.ts 3. Zero component changes

The data layer cost nothing to build but saves weeks of refactoring later.


Decision 8: Intersection Observer — Not Framer Motion

The site has subtle fade-in animations on scroll. The obvious choice is Framer Motion (it was already in the deps). Instead, we used a 50-line client component with IntersectionObserver + CSS transitions.

tsx
// SectionAnimate — the entire animation system
const observer = new IntersectionObserver(
  ([entry]) => {
    if (entry.isIntersecting) {
      setIsVisible(true);
      observer.unobserve(el);
    }
  },
  { threshold: 0.1 }
);

Why?

  • Framer Motion is ~40KB. IntersectionObserver is 0KB (browser native).
  • CSS transitions are GPU-accelerated by default.
  • prefers-reduced-motion is handled with a single media query check.

The animation is: opacity 0→1, translateY 16px→0, 500ms ease. That's it. Calm. Intentional. Engineered.


Decision 9: Blog Without MDX or Heavy Libraries

The blog supports headings, lists, code blocks with copy buttons, blockquotes, and inline code — all without MDX, remark, rehype, or any markdown library.

A lightweight 120-line parser in markdown-renderer.tsx handles everything. The code block is the only client component (for clipboard API). The renderer itself is a Server Component — it parses markdown at build time.

Total blog JS overhead: the copy button. That's it.


Decision 10: Static Generation Everywhere

Every page is statically generated at build time:

  • / → Static (○)
  • /blog → Static (○)
  • /blog/[slug] → SSG via generateStaticParams (●)
  • /sitemap.xml → Static (○)
  • /robots.txt → Static (○)

No getServerSideProps. No runtime data fetching. No edge functions. The site is just HTML files served from a CDN. That's why it's fast.


Decision 11: SEO as Architecture

SEO wasn't bolted on at the end. It's structural:

  • generateMetadata on every route — unique title, description, OpenGraph, Twitter cards
  • JSON-LD Person schema on the root layout
  • JSON-LD BlogPosting schema on every blog post
  • Dynamic sitemap.ts that includes all blog slugs
  • robots.ts allowing full indexing
  • Canonical URLs derived from the content provider

Decision 12: Accessibility as Default

  • Semantic HTML: <section>, <article>, <nav>, <main>, <footer>
  • Skip-to-content link (visible on focus)
  • aria-label on every section
  • aria-hidden on decorative elements
  • aria-expanded on mobile menu toggle
  • Keyboard-navigable everything
  • prefers-reduced-motion disables all animations globally

Decision 13: Client-Side Navigation via next/link

Every internal link uses next/link — not <a> tags. This means:

  • Client-side navigation (no full page reload)
  • Theme state persists across pages
  • Prefetching on hover
  • Instant page transitions

Decision 14: Minimal Dependencies

The final dependency list:

  • next — framework
  • react + react-dom — UI
  • framer-motion — available but barely used
  • @vercel/analytics — page view tracking
  • @vercel/speed-insights — Core Web Vitals

That's it. No axios. No lodash. No moment.js. No UI library. Every icon is an inline SVG component. The markdown parser is custom. The animation system is 50 lines.

**How to leverage this:** Every dependency is a liability. Before adding one, ask: "Can I build this in under 100 lines?" If yes, build it.


The AI Pair Programming Dynamic

This site was built with AI as a pair programmer. Here's how the collaboration worked:

**What I decided (human):**

  • The product vision and positioning
  • Content tone and what to say
  • Which features to build and prioritize
  • When to say "that's not right, fix it"
  • Mobile layout feedback from real device testing
  • "Remove the cat" and "the dot is 2px off"

**What AI handled:**

  • Translating decisions into clean TypeScript
  • Generating all component markup and Tailwind classes
  • Setting up the CMS-ready data architecture
  • Writing SEO metadata, JSON-LD schemas, sitemap logic
  • The pixel-art kitten (RIP — it was fun while it lasted)
  • Fixing edge cases I spotted but didn't want to debug manually

**The key insight:** AI is excellent at translating intent into implementation. It's terrible at having opinions about what to build. The best results came from being extremely specific about what I wanted and letting AI handle the how.


Lighthouse Results

After all these decisions:

  • **Performance:** 100
  • **Accessibility:** 100
  • **Best Practices:** 100
  • **SEO:** 100

Not because we chased scores. Because every decision was made with performance, accessibility, and correctness as constraints — not afterthoughts.


How to Apply This to Your Own Projects

1. **Start with Server Components.** Add client interactivity only when the browser demands it. 2. **Self-host your fonts.** One line of config eliminates layout shift. 3. **Theme with CSS variables.** Don't re-render your entire app to change colors. 4. **Build your data layer as if a CMS exists.** The abstraction costs nothing now, saves weeks later. 5. **Write your own small utilities.** A 50-line animation component beats a 40KB library. 6. **Static generate everything.** If the data doesn't change per-request, don't compute it per-request. 7. **Use AI as an accelerator, not a replacement.** You decide what to build. AI decides how to type it fast.


Final Thought

A 100 Lighthouse score isn't a trophy. It's a side effect of making good decisions consistently.

Every line of code either makes the site faster or slower. Every dependency either earns its weight or doesn't. Every component either needs JavaScript or doesn't.

The site you're reading this on is proof that you don't need heavy frameworks, fancy animations, or clever hacks. You need clear thinking and the discipline to ship less.