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 |
| 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
// next.config.mjs
const nextConfig = {
experimental: {
optimizeCss: true,
},
};
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
crittersis independencies, notdevDependencies— 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"
]
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,
});
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",
},
],
},
];
},
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 />,
});
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}
/>
And in next.config.mjs:
images: {
qualities: [75, 78], // must list every quality value you use
}
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/googlewithdisplay: swap— no FOIT - Dynamic OG image generated via
app/opengraph-image.tsx— every social share renders a branded card -
sitemap.tsauto-generates the sitemap -
robots.txtin/public - Structured data (JSON-LD) injected server-side via a
StructuredDatacomponent
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>
);
}
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)