The Osprey
The Osprey

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:

  1. First impression has to land. The hero needed to do work — not be a hero image with a tagline.
  2. Feel handmade. No Tailwind, no UI kit. I wanted every component to be mine.
  3. Stay fast. Static, edge-cached, no servers warming up.
  4. 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.
  5. 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 hosting

A 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

MetricWordPressNext.js
TTFB900ms50ms
LCP2.1s0.7s
Lighthouse Performance7399
CSS shipped240KB38KB
JS shipped180KB92KB
Cold-start failuresOccasionalNever

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/content layer already has a Project type 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.