Building Vladify: A 3D Portfolio with Next.js and React Three Fiber

June 15, 2025|5 min read
next.jsreact-three-fiberthree.jsportfoliotypescript

Why Build a 3D Portfolio?

Most developer portfolios are static pages with a hero section, a project grid, and a contact form. They work, but they don't feel like anything. I wanted something that felt like opening an app — interactive, spatial, a little playful.

The idea: a single-page experience where a 3D scene fills the viewport, and content appears in draggable terminal-style windows. Navigation triggers camera movements between "stages" rather than page transitions. The whole thing statically generated for SEO and performance.

The Tech Stack

  • Next.js 16 (App Router) — SSG by default, Server Components where possible
  • React Three Fiber + Drei — declarative 3D in React
  • MeshLine — hand-drawn wireframe scribble effects
  • Framer Motion — DOM animations, draggable windows
  • React Spring — spring physics for 3D object interactions
  • Zustand — lightweight state management across 3D and DOM layers
  • Tailwind CSS v4 — styling with custom design tokens

Architecture: One Page, Multiple Scenes

The entire portfolio lives on a single page (app/page.tsx). Instead of routing between pages, a Zustand store tracks the current "scene" state:

type SceneState = "intro" | "menu" | "projects" | "about" | "contact";

When the scene changes, two things happen simultaneously:

  1. The CameraController animates the camera to a new position defined in a stage config
  2. The SceneOverlay swaps which DOM content is rendered via AnimatePresence

This gives the feel of navigating a 3D space while keeping the DOM layer fully accessible.

The Scribble Effect

The most distinctive visual element is the hand-drawn wireframe aesthetic. Every 3D object — the rocket, code blocks, scene decorations — uses MeshLine to render paths that look sketched rather than precise.

The Scribble component takes an array of 3D points and renders them as a thick, slightly imperfect line:

// Simplified — the real component handles React 19 compat
function Scribble({ points, color, lineWidth }) {
  const ref = useRef();

  useEffect(() => {
    const geometry = new MeshLineGeometry();
    geometry.setPoints(points.flat());

    const material = new MeshLineMaterial({
      color,
      lineWidth,
      resolution: new Vector2(window.innerWidth, window.innerHeight),
    });

    ref.current.geometry = geometry;
    ref.current.material = material;
  }, [points, color, lineWidth]);

  return <mesh ref={ref} />;
}

Behind each scribble, a solid ShapeGeometry at z=-0.01 provides depth occlusion — objects behind the rocket are properly hidden, even though the wireframe itself has gaps.

The Draggable Terminal Windows

Content appears in DraggableWindow components styled like terminal emulator windows. They have:

  • A title bar with window controls (the red dot flips the window on click)
  • Framer Motion drag with boundary constraints
  • Spring-animated entrance with configurable delay
  • A stagger pattern where multiple windows appear sequentially

On portrait devices, dragging is disabled and windows stack vertically like cards. The useBreakpoints hook uses aspect-ratio media queries rather than width breakpoints — this handles tablets in landscape better than traditional responsive design.

Gotchas with React 19 + R3F

A few things bit me during development:

Drei's <Text> component suspends forever on React 19. The troika-three-text library it wraps hasn't been updated for the new Suspense semantics. Solution: use R3F's <Html> component for all text overlays instead.

MeshLine JSX types don't work with React 19's stricter type checking. The extend() pattern that registers custom Three.js elements fails at the type level. Solution: create MeshLine geometry and material imperatively in useEffect hooks rather than declaratively in JSX.

Screen-space <Html> ignores parent group scale. If you animate a group's scale to 0 to hide content, the Html element stays visible. Solution: conditionally render with return null instead of animating scale.

SEO on a 3D Page

A fully 3D page is a nightmare for search engines — they can't read WebGL canvases. The solution is the ForBotsOnly component: a visually hidden <div> that contains the full semantic HTML version of all content.

<ForBotsOnly>
  <h1>Vladify — Creative Developer Portfolio</h1>
  <section aria-label="About">
    <h2>About Vlad</h2>
    <p>Software engineer building anything from clinic websites...</p>
  </section>
  {/* Full semantic content for every section */}
</ForBotsOnly>

Combined with JSON-LD structured data (Person, WebSite, CreativeWork for each project), search engines get rich, structured content while users get the immersive 3D experience.

What I'd Do Differently

  1. Start with mobile layout first. The 3D scene was designed for desktop, and adapting it for portrait orientations required significant refactoring of the stage config system.

  2. Use Rapier for physics instead of React Spring. Spring animations are great for simple bob/hover effects, but a physics engine would make object interactions feel more natural.

  3. Build a visual scene editor. Manually tweaking [x, y, z] coordinates in config files to position 3D objects is tedious. A debug overlay with draggable handles would save hours.

What's Next

The blog you're reading right now is Phase 5 of the project. Next up: syntax highlighting with Shiki, OG image generation, Lighthouse optimization, and eventually deep-linking to scenes via URL hashes.

The source code is on GitHub.