Rebuilding theosprey.io: WordPress to Next.js

My portfolio had been running on a custom WordPress theme for years. It worked. The osprey mascot, the yellow-and-green mountains, the basic about/services/blog/contact flow — all there. But every time I opened it I felt like I was looking at a 2019 starter theme with my name slapped on it. It didn't feel like me, and it sure didn't feel like the kind of work I'd hand a client.
So I rebuilt it. WordPress is gone. The whole thing now runs on Next.js, statically generated, deployed on Vercel. Here's the play-by-play.
The old site
The original was a hand-rolled WordPress theme — call it osprey. PHP templates, jQuery, an application.css blob, and a sprinkling of Flickity for the blog carousel. Stock WP admin for content. It looked roughly like the design I wanted, but every interaction felt heavy:
- TTFB of ~900ms on a shared host
- Lighthouse Performance in the low 70s
- Hero was static — a single SVG of mountains, no motion, no story
- Nothing about the experience said "I build interactive front ends for a living"
The pain wasn't really about performance. It was that the site didn't sell. People who landed there couldn't tell from the first scroll that I cared about craft.
What I wanted instead
A short list before I wrote a line of code:
- First impression has to land. The hero needed to do work — not be a hero image with a tagline.
- Feel handmade. No Tailwind, no UI kit. I wanted every component to be mine.
- Stay fast. Static, edge-cached, no servers warming up.
- Easy to edit. Content for the homepage in TypeScript files, blog posts in MDX. No CMS for now — I'll add Payload later if I need it.
- Cleanly extensible. I want to swap in a real CMS down the road without rewriting components.
The stack
Next.js 15 (App Router, RSC)
↳ TypeScript
↳ CSS Modules (SCSS) — no Tailwind
↳ Framer Motion for the moving parts
↳ Lenis for smooth scroll
MDX (next-mdx-remote/rsc)
↳ remark-gfm
↳ rehype-pretty-code (Shiki)
SendLayer for the contact form
Vercel for hostingA note on the no-Tailwind choice: I know it's the default for a reason. I just don't want it. I like writing CSS, I like reading CSS in files, and I like SCSS modules for the discipline they impose. This is a portfolio — if I can't be opinionated here, where can I be.
Content layer
The piece I'm proudest of architecturally is the content layer. I built a ContentProvider interface and a localProvider that reads from typed TS files and MDX on disk:
// lib/content/types.ts
export interface ContentProvider {
getSite(): Promise<SiteContent>;
getServices(): Promise<Service[]>;
getProjects(): Promise<Project[]>;
getPosts(): Promise<Post[]>;
getPost(slug: string): Promise<Post | null>;
}Components import from @/lib/content, not from any specific data source. When I'm ready to swap in Payload or Sanity, I implement a cmsProvider and flip one line in lib/content/index.ts. The components don't know or care.
The hero
This is where I spent the most time. The brief I gave myself: make scrolling feel like flying.
The whole hero section is pinned for ~220vh of scroll. Inside that pin:
- Three layers of triangular mountains pan horizontally at different speeds (far layer slow, near layer fast — parallax depth)
- A stylized osprey silhouette crosses the screen left to right between 15% and 85% of the scroll, with a wing-flap animation driven by SVG path morphing
- The title — "Soar / Above / the Rest" — is locked in place with a yellow highlight slash on "Above"
- A progress bar at the bottom tracks the journey
const xFar = useTransform(scrollYProgress, [0, 1], ["0vw", "-40vw"]);
const xMid = useTransform(scrollYProgress, [0, 1], ["0vw", "-110vw"]);
const xNear = useTransform(scrollYProgress, [0, 1], ["0vw", "-200vw"]);Three useTransform calls, three SVG layers, done. The mountains are each 300vw wide so the right edge never runs off during the pan.
The header
A morphing capsule. At the top of the page it's invisible scaffolding: transparent background, generous spacing, dark text over the cream hero. Once you scroll past ~90px, it cinches into a centered dark pill — links flip white, the logo text collapses to just the bird mark. A magnetic yellow indicator slides between nav items, tracking either hover or the active section (via IntersectionObserver).
const obs = new IntersectionObserver(
(entries) => {
const top = entries
.filter((e) => e.isIntersecting)
.sort((a, b) => b.intersectionRatio - a.intersectionRatio)[0];
if (top) setActiveId(top.target.id);
},
{ rootMargin: "-45% 0px -45% 0px", threshold: [0, 0.5, 1] }
);The rootMargin trick limits the "active" zone to the middle 10% of the viewport — so the indicator changes when the section is actually centered, not when it merely starts to scroll into view.
The marquee
Between sections, a tech-stack marquee scrolls horizontally. Two variants — dark and light — alternate down the page. Words alternate filled and outlined for typographic rhythm, separated by a spinning yellow asterisk. The order shuffles client-side on mount so each visit feels fresh while still rendering deterministically on the server.
The blog
MDX, parsed at build time with gray-matter for frontmatter and rendered through next-mdx-remote/rsc. remark-gfm enables tables and strikethrough. rehype-pretty-code (Shiki under the hood) handles syntax highlighting with the same theme VS Code uses.
A small <Callout> component lives in components/MDX/ and is registered globally — that's the yellow box you see above. Five variants (info, warning, success, note, danger), each tied to a CSS variable for the accent color.
The contact form
SendLayer for transactional delivery, an honeypot field for spam, server-side validation in the Next.js API route. The form sends JSON, the API route validates, escapes, and dispatches a templated HTML email. About 80 lines total, no third-party form service to depend on.
Results
| Metric | WordPress | Next.js |
|---|---|---|
| TTFB | 900ms | 50ms |
| LCP | 2.1s | 0.7s |
| Lighthouse Performance | 73 | 99 |
| CSS shipped | 240KB | 38KB |
| JS shipped | 180KB | 92KB |
| Cold-start failures | Occasional | Never |
The numbers are good. But the real win is that the site finally feels like a sample of my work, not a brochure about it. When someone scrolls the hero and watches the mountains pan past while the osprey crosses the screen, they're seeing exactly the kind of interactive front-end work I'd build for them.
What's next
A few things on the list:
- Projects page. The
lib/contentlayer already has aProjecttype and a stub array. I just need to write the case studies. - Payload CMS integration. Once I have more than 20 posts I'll regret not having a real editor UI.
- A small WebGL detail somewhere. Probably on the projects page, not the hero — the hero's busy enough.
- Mobile polish. It works on mobile, but the hero's parallax is less impressive on a small screen. I want a different treatment there.
If you're sitting on a tired WordPress portfolio and you build things on the web for a living — the rebuild is worth it. The site is a sample of your work whether you mean it to be or not.