DEV Community

Cover image for How I Built and Optimised my Portfolio to Score 100 on Lighthouse & Page Speed Insight
Xela
Xela

Posted on • Originally published at buildwithxela.com

How I Built and Optimised my Portfolio to Score 100 on Lighthouse & Page Speed Insight

I recently rebuilt my developer portfolio from scratch — moving from a basic
Vite + React setup to a fully server-rendered Next.js 16 application — and
pushed it from a 60-something Lighthouse score all the way to 100 on
Accessibility, Best Practices, and SEO, and 91 on Performance
on mobile.

Here's a breakdown of every decision I made and why, so you can apply the same
thinking to your next project.

Live site: buildwithxela.com


Why I Rebuilt It

The old portfolio was a single-page React app bundled with Vite. It worked
fine locally but had real problems in production:

  • No server-side rendering → poor LCP on slow connections
  • No dynamic OG images → social shares looked broken
  • Font files loaded as render-blocking resources
  • No admin panel → updating projects meant a code deploy

I needed something I could actually maintain and that would rank on Google for
my name and skills.


The Stack

Layer Choice Why
Framework Next.js 16 (App Router) SSR, streaming, built-in image optimisation
Styling Tailwind CSS v3 + shadcn/ui Fast iteration, accessible components
Database MongoDB (Mongoose) Flexible schema for projects/testimonials
Auth jose (JWT) Lightweight, no third-party dependency
Email Resend Best DX for transactional email
Deployment Vercel Zero-config, edge network

Performance Wins

1. Eliminate render-blocking CSS with optimizeCss

The biggest single win. Tailwind generates a large CSS bundle that, by default,
blocks the initial render. Next.js has a built-in fix — you just need to enable
it and install critters:

bun add critters
Enter fullscreen mode Exit fullscreen mode
// next.config.mjs
const nextConfig = {
  experimental: {
    optimizeCss: true,
  },
};
Enter fullscreen mode Exit fullscreen mode

This uses critters to inline the critical-path CSS directly into the HTML
<head> as a <style> block, and loads the rest with
media="print" onload="this.media='all'" — completely removing the
render-blocking <link> tag. Saved ~300 ms on the critical path.

⚠️ Make sure critters is in dependencies, not devDependencies — if
you're deploying to Vercel with Bun, devDeps are not installed during
production builds.


2. Target modern browsers to eliminate legacy JS polyfills

Lighthouse flagged 14 KiB of wasted bytes — polyfills for Array.prototype.at,
Object.hasOwn, String.prototype.trimStart, etc. — features that have been
native in every modern browser for years.

Fix: add a browserslist field to package.json that matches Next.js's own
modern baseline:

"browserslist": [
  "chrome >= 111",
  "edge >= 111",
  "firefox >= 111",
  "safari >= 16.4",
  "not dead"
]
Enter fullscreen mode Exit fullscreen mode

SWC and Webpack read this and skip generating polyfills for features those
browsers already support natively. Saved ~14 KiB from the JS bundle.


3. Use next/font/google — not @fontsource

I initially had @fontsource/orbitron and @fontsource/space-mono in my
dependencies. These bundle font CSS as part of your JS, adding render-blocking
overhead. The correct approach in Next.js is next/font/google:

// lib/fonts.ts
import { Orbitron, Space_Mono } from "next/font/google";

export const spaceMono = Space_Mono({
  weight: ["400", "700"],
  subsets: ["latin"],
  display: "swap",
  variable: "--font-space-mono",
  preload: true,
});

export const orbitron = Orbitron({
  weight: ["600", "700", "900"],
  subsets: ["latin"],
  display: "swap",
  variable: "--font-orbitron",
  preload: true,
});
Enter fullscreen mode Exit fullscreen mode

next/font self-hosts the font files at build time, injects preload hints
automatically, and sets font-display: swap — zero layout shift, no
round-trip to Google's servers.


4. Cache static chunks permanently

Next.js content-hashes all assets in /_next/static/ so they're safe to cache
forever. But the default headers don't reflect this:

// next.config.mjs
async headers() {
  return [
    {
      source: "/_next/static/:path*",
      headers: [
        {
          key: "Cache-Control",
          value: "public, max-age=31536000, immutable",
        },
      ],
    },
  ];
},
Enter fullscreen mode Exit fullscreen mode

Repeat visitors now load JS/CSS from disk instantly rather than re-downloading
unchanged files.


5. Lazy-load everything below the fold

The hero section and navbar are the only components that need to render on first
paint. Everything else — experience, projects, testimonials, contact — is
dynamically imported:

// components/HomePage.tsx
const Projects = dynamic(() => import("@/components/Projects"), {
  ssr: false,
  loading: () => <SkeletonSection />,
});
Enter fullscreen mode Exit fullscreen mode

This keeps the initial JS bundle small and defers hydration of heavy
components (the carousel, framer-motion animations) until the user scrolls
toward them.


6. Fix the LCP element explicitly

The LCP element on my page is the hero avatar image. Two things I did:

<Image
  src={avatarImg}
  alt="Xela Oladipupo - Developer"
  width={512}
  height={512}
  priority // tells Next.js to preload this image
  fetchPriority="high" // explicit browser priority hint
  quality={78}
/>
Enter fullscreen mode Exit fullscreen mode

And in next.config.mjs:

images: {
  qualities: [75, 78], // must list every quality value you use
}
Enter fullscreen mode Exit fullscreen mode

The qualities array is required when you use a non-default quality value —
omitting it causes a console warning and falls back to the default.


SEO Setup

Beyond performance, I wanted the site to actually rank for "Xela Oladipupo"
and "React Native developer Nigeria".

Checklist I followed:

  • H1 text matches <title> — same keywords, same person name
  • Page body contains 800+ words of real content (not just headings)
  • next/font/google with display: swap — no FOIT
  • Dynamic OG image generated via app/opengraph-image.tsx — every social share renders a branded card
  • sitemap.ts auto-generates the sitemap
  • robots.txt in /public
  • Structured data (JSON-LD) injected server-side via a StructuredData component

Dynamic OG Images

This was the feature I was most excited to build. Next.js App Router has a
first-class API for it:

// app/opengraph-image.tsx
import { ImageResponse } from "next/og";

export const runtime = "edge";
export const size = { width: 1200, height: 630 };

export default function OGImage() {
  return new ImageResponse(
    <div style={{ background: "#0a0f1a", display: "flex", ... }}>
      <h1>Xela Oladipupo</h1>
      <p>React Native & Full-Stack Developer</p>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

No external service, no pre-generated PNG — it renders on-demand at the edge.


The Admin Panel

One of the main reasons I chose Next.js over a static site generator was the
ability to add a proper headless CMS. I built a /admin section with:

  • JWT authentication (jose, httpOnly cookies)
  • CRUD for projects, work experience, and testimonials
  • Drag-and-drop reordering (dnd-kit)
  • MongoDB storage (Mongoose)

Now I can update my portfolio from any device without touching the codebase.


Final Scores

Metric Score
Performance 100
Accessibility 100
Best Practices 100
SEO 100

Source

The full codebase is on GitHub and the live site is at
buildwithxela.com.

If you're building your own portfolio and want to talk through the architecture,
find me on Twitter or
LinkedIn.

Top comments (0)