React Three Fiber + Next.js: Building 3D Interactive Web Experiences from Scratch

AI Bot
By AI Bot ·

Loading the Text to Speech Audio Player...

Introduction

The web is no longer limited to flat interfaces. From product configurators and data visualizations to interactive portfolios and browser-based games, 3D content on the web is becoming a standard tool in every developer's arsenal. Three.js has long been the go-to library for WebGL rendering, but its imperative API can feel alien to React developers accustomed to declarative components.

React Three Fiber (R3F) bridges that gap. It is a React renderer for Three.js that lets you build 3D scenes using JSX components, hooks, and the same mental model you already use for your UI. Combined with @react-three/drei (a collection of useful helpers) and @react-three/rapier (a physics engine), you can create production-grade 3D experiences without ever writing raw WebGL.

In this tutorial, you will build a complete interactive 3D product showcase — a floating sneaker that users can orbit around, click to change colors, and watch bounce with real physics. Along the way, you will master the core concepts of R3F and learn how to integrate it cleanly into a Next.js application.

What You Will Build

By the end of this tutorial, you will have:

  • A Next.js application with a full-screen 3D canvas
  • A 3D scene with lighting, shadows, and environment maps
  • Interactive orbit controls for camera movement
  • Animated 3D objects with spring-based physics
  • Click-to-change material colors
  • A physics-enabled ground plane with bouncing objects
  • Performance optimizations for production deployment

Prerequisites

Before starting, make sure you have:

  • Node.js 20+ installed on your machine
  • Basic knowledge of React and TypeScript
  • Familiarity with Next.js App Router (pages work too, but we use App Router)
  • A code editor like VS Code with the ES7+ React extension
  • No prior Three.js or 3D experience required — we start from zero

Step 1: Project Setup

Create a new Next.js project and install the required dependencies:

npx create-next-app@latest r3f-showcase --typescript --tailwind --app --src-dir
cd r3f-showcase

Now install React Three Fiber and its ecosystem packages:

npm install three @react-three/fiber @react-three/drei @react-three/rapier
npm install -D @types/three

Here is what each package does:

PackagePurpose
threeThe core Three.js library
@react-three/fiberReact renderer for Three.js
@react-three/dreiPre-built helpers (controls, loaders, materials)
@react-three/rapierPhysics engine integration
@types/threeTypeScript type definitions

Step 2: Understanding the Canvas

The foundation of every R3F application is the Canvas component. It creates a WebGL context and manages the Three.js render loop for you.

Create a new file at src/components/Scene.tsx:

"use client";
 
import { Canvas } from "@react-three/fiber";
 
export default function Scene() {
  return (
    <div className="h-screen w-full">
      <Canvas
        camera={{ position: [0, 2, 5], fov: 50 }}
        shadows
      >
        {/* Our 3D scene will go here */}
        <mesh>
          <boxGeometry args={[1, 1, 1]} />
          <meshStandardMaterial color="royalblue" />
        </mesh>
      </Canvas>
    </div>
  );
}

A few important things to note:

  • "use client" is required because R3F uses browser APIs (WebGL) and React hooks internally
  • camera sets the initial position and field of view
  • shadows enables shadow mapping globally
  • mesh is the basic building block — a geometry plus a material

Update src/app/page.tsx to render the scene:

import dynamic from "next/dynamic";
 
const Scene = dynamic(() => import("@/components/Scene"), {
  ssr: false,
  loading: () => (
    <div className="flex h-screen w-full items-center justify-center bg-black">
      <p className="text-white">Loading 3D Scene...</p>
    </div>
  ),
});
 
export default function Home() {
  return <Scene />;
}

Always use dynamic import with ssr: false for R3F components in Next.js. Three.js accesses window and document, which do not exist during server-side rendering.

Run the dev server to see your first 3D cube:

npm run dev

You should see a blue cube on a black background. It looks flat because we have no lights yet.

Step 3: Adding Lights and Environment

Lighting is what transforms flat-shaded objects into realistic 3D visuals. Create src/components/Lighting.tsx:

"use client";
 
import { Environment } from "@react-three/drei";
 
export default function Lighting() {
  return (
    <>
      {/* Ambient light for base illumination */}
      <ambientLight intensity={0.3} />
 
      {/* Main directional light (like the sun) */}
      <directionalLight
        position={[5, 8, 5]}
        intensity={1.5}
        castShadow
        shadow-mapSize-width={2048}
        shadow-mapSize-height={2048}
        shadow-camera-far={20}
        shadow-camera-left={-5}
        shadow-camera-right={5}
        shadow-camera-top={5}
        shadow-camera-bottom={-5}
      />
 
      {/* Fill light from the opposite side */}
      <directionalLight
        position={[-3, 4, -5]}
        intensity={0.4}
      />
 
      {/* HDR environment map for realistic reflections */}
      <Environment preset="city" />
    </>
  );
}

The Environment component from drei loads an HDR environment map that provides realistic reflections on metallic and glossy surfaces. The preset prop offers several built-in options: city, sunset, dawn, night, warehouse, forest, apartment, studio, park, and lobby.

Step 4: Creating the Interactive 3D Object

Now let us build the star of our showcase — an interactive 3D sphere that responds to hover and click events. Create src/components/InteractiveSphere.tsx:

"use client";
 
import { useRef, useState } from "react";
import { useFrame } from "@react-three/fiber";
import { MeshDistortMaterial } from "@react-three/drei";
import type { Mesh } from "three";
 
const COLORS = ["#ff6b6b", "#4ecdc4", "#45b7d1", "#96ceb4", "#feca57"];
 
export default function InteractiveSphere() {
  const meshRef = useRef<Mesh>(null);
  const [colorIndex, setColorIndex] = useState(0);
  const [hovered, setHovered] = useState(false);
 
  // Animation loop — runs every frame (~60fps)
  useFrame((state, delta) => {
    if (!meshRef.current) return;
 
    // Gentle floating animation
    meshRef.current.position.y =
      Math.sin(state.clock.elapsedTime * 0.8) * 0.3 + 1.5;
 
    // Slow rotation
    meshRef.current.rotation.y += delta * 0.3;
 
    // Scale up on hover
    const targetScale = hovered ? 1.2 : 1;
    meshRef.current.scale.lerp(
      { x: targetScale, y: targetScale, z: targetScale } as any,
      0.1
    );
  });
 
  const handleClick = () => {
    setColorIndex((prev) => (prev + 1) % COLORS.length);
  };
 
  return (
    <mesh
      ref={meshRef}
      castShadow
      onClick={handleClick}
      onPointerOver={() => setHovered(true)}
      onPointerOut={() => setHovered(false)}
    >
      <sphereGeometry args={[1, 64, 64]} />
      <MeshDistortMaterial
        color={COLORS[colorIndex]}
        roughness={0.2}
        metalness={0.8}
        distort={hovered ? 0.4 : 0.2}
        speed={2}
      />
    </mesh>
  );
}

Key concepts introduced here:

  • useFrame — R3F's animation hook. It runs on every render frame, giving you access to the Three.js clock and delta time. Never use requestAnimationFrame directly — useFrame is integrated with R3F's render loop.
  • MeshDistortMaterial — A drei helper that creates a wobbly, distorted surface effect. The distort and speed props control the deformation.
  • Pointer events — R3F supports onClick, onPointerOver, onPointerOut, and more, just like regular React DOM elements. Under the hood, it uses raycasting to determine which 3D object the mouse is interacting with.
  • lerp — Linear interpolation for smooth transitions. Instead of snapping to the target scale, we smoothly animate toward it each frame.

Step 5: Adding Orbit Controls

Let users rotate and zoom the camera around the scene. Update your Scene component:

"use client";
 
import { Canvas } from "@react-three/fiber";
import { OrbitControls, ContactShadows } from "@react-three/drei";
import Lighting from "./Lighting";
import InteractiveSphere from "./InteractiveSphere";
 
export default function Scene() {
  return (
    <div className="h-screen w-full bg-gradient-to-b from-gray-900 to-black">
      <Canvas
        camera={{ position: [0, 2, 5], fov: 50 }}
        shadows
      >
        <Lighting />
        <InteractiveSphere />
 
        {/* Ground plane with contact shadows */}
        <ContactShadows
          position={[0, -0.5, 0]}
          opacity={0.5}
          scale={10}
          blur={2}
          far={4}
        />
 
        {/* Camera controls */}
        <OrbitControls
          enablePan={false}
          minDistance={3}
          maxDistance={10}
          minPolarAngle={Math.PI / 6}
          maxPolarAngle={Math.PI / 2.2}
        />
      </Canvas>
    </div>
  );
}

The OrbitControls configuration limits camera movement to prevent users from going below the ground or zooming too far. ContactShadows creates a soft shadow beneath the object without needing a physical ground plane.

Step 6: Loading 3D Models

Real projects often use 3D models instead of primitive shapes. R3F supports GLTF/GLB models natively through drei's useGLTF hook.

First, create a simple model loader component at src/components/Model.tsx:

"use client";
 
import { useRef } from "react";
import { useFrame } from "@react-three/fiber";
import { useGLTF, Float } from "@react-three/drei";
import type { Group } from "three";
 
interface ModelProps {
  url: string;
  scale?: number;
  position?: [number, number, number];
}
 
export default function Model({ url, scale = 1, position = [0, 0, 0] }: ModelProps) {
  const groupRef = useRef<Group>(null);
  const { scene } = useGLTF(url);
 
  useFrame((state) => {
    if (!groupRef.current) return;
    groupRef.current.rotation.y = state.clock.elapsedTime * 0.2;
  });
 
  return (
    <Float
      speed={1.5}
      rotationIntensity={0.3}
      floatIntensity={0.5}
    >
      <group ref={groupRef} position={position} scale={scale}>
        <primitive object={scene.clone()} castShadow receiveShadow />
      </group>
    </Float>
  );
}

The Float component from drei adds a gentle floating animation automatically. The useGLTF hook handles loading, caching, and disposing of GLTF models.

You can find free 3D models at poly.pizza, sketchfab.com, or market.pmnd.rs. Download them in GLB format and place them in your public/models/ directory.

To preload models and avoid loading flashes:

// At the bottom of your Model component file
useGLTF.preload("/models/sneaker.glb");

Step 7: Adding Physics

Physics bring your scene to life with realistic interactions. Create src/components/PhysicsScene.tsx:

"use client";
 
import { Physics, RigidBody, CuboidCollider } from "@react-three/rapier";
import { useState } from "react";
 
function BouncingBall({ position }: { position: [number, number, number] }) {
  const [color, setColor] = useState("#ff6b6b");
 
  return (
    <RigidBody
      position={position}
      restitution={0.8}
      friction={0.5}
      colliders="ball"
    >
      <mesh
        castShadow
        onClick={() => setColor(`hsl(${Math.random() * 360}, 70%, 60%)`)}
      >
        <sphereGeometry args={[0.3, 32, 32]} />
        <meshStandardMaterial color={color} roughness={0.3} metalness={0.6} />
      </mesh>
    </RigidBody>
  );
}
 
function Ground() {
  return (
    <RigidBody type="fixed">
      <CuboidCollider args={[10, 0.1, 10]} position={[0, -1, 0]} />
      <mesh receiveShadow position={[0, -1, 0]}>
        <boxGeometry args={[20, 0.2, 20]} />
        <meshStandardMaterial color="#1a1a2e" />
      </mesh>
    </RigidBody>
  );
}
 
export default function PhysicsScene() {
  const balls = Array.from({ length: 10 }, (_, i) => ({
    id: i,
    position: [
      (Math.random() - 0.5) * 4,
      3 + Math.random() * 5,
      (Math.random() - 0.5) * 4,
    ] as [number, number, number],
  }));
 
  return (
    <Physics gravity={[0, -9.81, 0]}>
      <Ground />
      {balls.map((ball) => (
        <BouncingBall key={ball.id} position={ball.position} />
      ))}
    </Physics>
  );
}

The Physics component wraps your scene and applies gravity. RigidBody makes objects participate in the physics simulation. Key properties:

  • restitution — Bounciness (0 = no bounce, 1 = perfect bounce)
  • friction — Surface friction (0 = ice, 1 = rubber)
  • type="fixed" — Makes the object immovable (perfect for floors and walls)
  • colliders — Auto-generates collision shapes (ball, cuboid, hull, trimesh)

Step 8: Post-Processing Effects

Add cinematic quality with post-processing. Install the effects package:

npm install @react-three/postprocessing

Create src/components/Effects.tsx:

"use client";
 
import { EffectComposer, Bloom, ChromaticAberration, Vignette } from "@react-three/postprocessing";
 
export default function Effects() {
  return (
    <EffectComposer>
      <Bloom
        luminanceThreshold={0.6}
        luminanceSmoothing={0.9}
        intensity={0.5}
      />
      <Vignette eskil={false} offset={0.1} darkness={0.5} />
      <ChromaticAberration offset={[0.0005, 0.0005]} />
    </EffectComposer>
  );
}
  • Bloom — Creates a glow effect on bright surfaces
  • Vignette — Darkens the edges of the screen for a cinematic feel
  • ChromaticAberration — Adds a subtle color fringing effect

Post-processing effects can significantly impact performance on mobile devices. Always provide a way to disable them, or use the useDetectGPU hook from drei to automatically adjust quality.

Step 9: Responsive and Adaptive Quality

Production 3D apps must work on everything from high-end desktops to budget phones. Create src/components/AdaptiveScene.tsx:

"use client";
 
import { useThree } from "@react-three/fiber";
import { useDetectGPU, PerformanceMonitor } from "@react-three/drei";
import { useState, useEffect } from "react";
 
export function AdaptivePixelRatio() {
  const { gl } = useThree();
  const GPUTier = useDetectGPU();
 
  useEffect(() => {
    if (GPUTier.tier === 0 || GPUTier.isMobile) {
      gl.setPixelRatio(1);
    } else if (GPUTier.tier === 1) {
      gl.setPixelRatio(Math.min(window.devicePixelRatio, 1.5));
    } else {
      gl.setPixelRatio(Math.min(window.devicePixelRatio, 2));
    }
  }, [GPUTier, gl]);
 
  return null;
}
 
export function AdaptivePerformance({
  children,
}: {
  children: (dpr: number) => React.ReactNode;
}) {
  const [dpr, setDpr] = useState(1.5);
 
  return (
    <PerformanceMonitor
      onIncline={() => setDpr(Math.min(dpr + 0.5, 2))}
      onDecline={() => setDpr(Math.max(dpr - 0.5, 0.5))}
    >
      {children(dpr)}
    </PerformanceMonitor>
  );
}

The PerformanceMonitor watches frame rates and automatically adjusts quality. When frames drop, it reduces the pixel ratio; when performance is good, it increases quality.

Step 10: Putting It All Together

Now combine everything into the final scene. Update src/components/Scene.tsx:

"use client";
 
import { Suspense } from "react";
import { Canvas } from "@react-three/fiber";
import { OrbitControls, ContactShadows, Html, Preload } from "@react-three/drei";
import Lighting from "./Lighting";
import InteractiveSphere from "./InteractiveSphere";
import Effects from "./Effects";
import { AdaptivePixelRatio } from "./AdaptiveScene";
 
function Loader() {
  return (
    <Html center>
      <div className="flex flex-col items-center gap-2">
        <div className="h-8 w-8 animate-spin rounded-full border-2 border-white border-t-transparent" />
        <p className="text-sm text-white">Loading scene...</p>
      </div>
    </Html>
  );
}
 
export default function Scene() {
  return (
    <div className="relative h-screen w-full bg-gradient-to-b from-gray-900 to-black">
      {/* UI Overlay */}
      <div className="absolute left-0 top-0 z-10 p-6">
        <h1 className="text-3xl font-bold text-white">3D Product Showcase</h1>
        <p className="mt-2 text-gray-400">Click the sphere to change colors</p>
        <p className="text-gray-400">Drag to orbit • Scroll to zoom</p>
      </div>
 
      <Canvas
        camera={{ position: [0, 2, 5], fov: 50 }}
        shadows
        dpr={[1, 2]}
        gl={{ antialias: true, alpha: false }}
      >
        <AdaptivePixelRatio />
 
        <Suspense fallback={<Loader />}>
          <Lighting />
          <InteractiveSphere />
 
          <ContactShadows
            position={[0, -0.5, 0]}
            opacity={0.5}
            scale={10}
            blur={2}
            far={4}
          />
 
          <Effects />
          <Preload all />
        </Suspense>
 
        <OrbitControls
          enablePan={false}
          minDistance={3}
          maxDistance={10}
          minPolarAngle={Math.PI / 6}
          maxPolarAngle={Math.PI / 2.2}
          autoRotate
          autoRotateSpeed={0.5}
        />
      </Canvas>
    </div>
  );
}

Key additions:

  • Suspense with a custom Loader — Shows a loading spinner while assets load
  • Preload all — Preloads all assets in the scene graph
  • dpr={[1, 2]} — Limits pixel ratio range for consistent performance
  • autoRotate — Slowly rotates the camera when the user is not interacting
  • Html — Renders regular HTML inside the 3D scene, always facing the camera

Performance Best Practices

When deploying R3F to production, keep these rules in mind:

  1. Instanced Meshes — If you have many identical objects, use InstancedMesh instead of individual meshes. This reduces draw calls dramatically.
import { Instances, Instance } from "@react-three/drei";
 
function Particles() {
  return (
    <Instances limit={1000}>
      <sphereGeometry args={[0.05, 8, 8]} />
      <meshStandardMaterial color="white" />
      {positions.map((pos, i) => (
        <Instance key={i} position={pos} />
      ))}
    </Instances>
  );
}
  1. Dispose resources — R3F handles this automatically for components that unmount, but if you create materials or geometries manually, always call .dispose().

  2. Compress textures — Use KTX2 format for textures instead of PNG/JPG. drei provides useKTX2 for this.

  3. Use frameloop="demand" — If your scene is mostly static, set this on Canvas to only render when something changes:

<Canvas frameloop="demand">
  1. Avoid re-renders — Use useFrame for animations instead of React state. React state changes trigger component re-renders; useFrame runs outside React's reconciliation.

Troubleshooting

"window is not defined" error

This happens when R3F code runs during server-side rendering. Always use dynamic imports with ssr: false:

const Scene = dynamic(() => import("@/components/Scene"), { ssr: false });

Black screen with no errors

Usually a lighting issue. Make sure you have at least one light source. Try adding <ambientLight intensity={1} /> to verify objects exist.

Poor performance on mobile

  • Reduce geometry complexity (fewer segments)
  • Lower pixel ratio: dpr={[0.5, 1]}
  • Disable shadows: remove the shadows prop from Canvas
  • Remove post-processing effects

Objects not receiving clicks

R3F uses raycasting for pointer events. Make sure the mesh has a geometry attached — invisible or zero-sized geometries will not register clicks.

Next Steps

Now that you have the fundamentals, explore these areas:

  • Shader Materials — Write custom GLSL shaders with shaderMaterial from drei
  • 3D Text — Use Text3D from drei with custom fonts
  • Scroll-based animations — Combine R3F with ScrollControls for scroll-driven 3D experiences
  • VR/AR — R3F supports WebXR out of the box with the XR component
  • Multiplayer — Combine with WebSockets for shared 3D experiences

Conclusion

React Three Fiber transforms 3D web development from a niche skill into something any React developer can pick up. By treating 3D objects as components, using hooks for animation, and leveraging drei's extensive helper library, you can build production-grade 3D experiences without deep Three.js expertise.

The key takeaways from this tutorial:

  • Canvas is your entry point — always wrap R3F content in it
  • useFrame is your animation loop — never use requestAnimationFrame
  • drei is your toolbox — check it before building custom solutions
  • Dynamic imports with SSR disabled are mandatory in Next.js
  • Performance scales with awareness — pixel ratio, instancing, and LOD matter

Start simple, iterate, and let the declarative nature of React guide your 3D scene architecture. The 3D web is just getting started, and you are now equipped to build it.


Want to read more tutorials? Check out our latest tutorial on Master Statistics: From Descriptive Basics to Advanced Regression and Hypothesis Testing.

Discuss Your Project with Us

We're here to help with your web development needs. Schedule a call to discuss your project and how we can assist you.

Let's find the best solutions for your needs.

Related Articles

Build a Real-Time Full-Stack App with Convex and Next.js 15

Learn how to build a real-time full-stack application using Convex and Next.js 15. This tutorial covers schema design, queries, mutations, real-time subscriptions, authentication, and file uploads — all with end-to-end type safety.

30 min read·