Back to blog

How I built a 100/100/100/100 Lighthouse portfolio with Angular 21

When I rebuilt my portfolio, I set one non-negotiable goal: a perfect Lighthouse score in production — Performance, Accessibility, Best Practices and SEO, all at 100. Not as vanity, but as proof of craft: if I claim to care about web performance, my own site is the first place it should show.

Here's the architecture that got it there, and what actually moved the needle.

The foundation: prerender everything

The whole site is SSG — every route is prerendered to static HTML at build time with @angular/ssr:

// app.routes.server.ts
export const serverRoutes: ServerRoute[] = [
  {
    path: 'projects/:slug',
    renderMode: RenderMode.Prerender,
    getPrerenderParams: async () => PROJECTS.map((p) => ({ slug: p.slug })),
  },
  { path: '**', renderMode: RenderMode.Prerender },
];

The browser receives fully-formed HTML from a CDN. No server render on request, no API round-trips, no skeleton screens. First Contentful Paint landed at 0.3s.

Zoneless: TBT = 0ms

Angular 21 runs without Zone.js:

provideZonelessChangeDetection();

Less JavaScript, no monkey-patched APIs, and change detection driven by signals. The main thread stays idle — Total Blocking Time is 0ms on every page.

Incremental hydration: JS that loads on scroll

The trick that surprised me most. Below-the-fold sections are wrapped in @defer with hydration triggers:

@defer (hydrate on viewport) {
  <app-home-testimonials />
}

The content is still prerendered — crawlers and users see it immediately, so SEO and CLS are untouched. But the JavaScript for each section (carousels, the contact form) only hydrates when you scroll near it. The initial bundle stays small without sacrificing static HTML.

Images: a build-time pipeline

The biggest single win. My raw screenshots were 2–4 MB PNGs; shipping those would have sunk everything. A small sharp script converts every source image to WebP at its real render width:

await sharp(src)
  .resize({ width, withoutEnlargement: true })
  .webp({ quality: 80 })
  .toFile(out);

26 MB of raw screenshots became ~950 KB of WebP. On top of that, NgOptimizedImage handles lazy loading and fetchpriority — exactly one image (the LCP) gets priority.

The details that guard the score

  • Self-hosted variable font — one 48 KB woff2 for all Inter weights, preloaded, no third-party origin.
  • Immutable caching — content-hashed JS/CSS cached for a year via _headers.
  • AA contrast, verified mathematically — audits missed a 2.72:1 pill; the math didn't.
  • Animations that respect the fold — scroll reveals only apply their hidden state in the browser, below the viewport, and never touch the LCP element.

Measure like production

One last lesson: your local Lighthouse lies. A bare static server has no compression and no CDN, so I shipped with a local score of 99 — and production came back 100, with LCP at 0.4s. And run audits in an incognito window: browser extensions inject scripts that can tank Best Practices by 20 points.

The full playbook — including the checklists I follow before every merge — lives in the repo's docs. The score is a side effect of the architecture, not a trick.