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

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-showcaseNow install React Three Fiber and its ecosystem packages:
npm install three @react-three/fiber @react-three/drei @react-three/rapier
npm install -D @types/threeHere is what each package does:
| Package | Purpose |
|---|---|
three | The core Three.js library |
@react-three/fiber | React renderer for Three.js |
@react-three/drei | Pre-built helpers (controls, loaders, materials) |
@react-three/rapier | Physics engine integration |
@types/three | TypeScript 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 internallycamerasets the initial position and field of viewshadowsenables shadow mapping globallymeshis 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 devYou 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 userequestAnimationFramedirectly —useFrameis integrated with R3F's render loop.MeshDistortMaterial— A drei helper that creates a wobbly, distorted surface effect. Thedistortandspeedprops 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/postprocessingCreate 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:
Suspensewith a customLoader— Shows a loading spinner while assets loadPreload all— Preloads all assets in the scene graphdpr={[1, 2]}— Limits pixel ratio range for consistent performanceautoRotate— Slowly rotates the camera when the user is not interactingHtml— Renders regular HTML inside the 3D scene, always facing the camera
Performance Best Practices
When deploying R3F to production, keep these rules in mind:
- Instanced Meshes — If you have many identical objects, use
InstancedMeshinstead 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>
);
}-
Dispose resources — R3F handles this automatically for components that unmount, but if you create materials or geometries manually, always call
.dispose(). -
Compress textures — Use KTX2 format for textures instead of PNG/JPG. drei provides
useKTX2for this. -
Use
frameloop="demand"— If your scene is mostly static, set this on Canvas to only render when something changes:
<Canvas frameloop="demand">- Avoid re-renders — Use
useFramefor animations instead of React state. React state changes trigger component re-renders;useFrameruns 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
shadowsprop 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
shaderMaterialfrom drei - 3D Text — Use
Text3Dfrom drei with custom fonts - Scroll-based animations — Combine R3F with
ScrollControlsfor scroll-driven 3D experiences - VR/AR — R3F supports WebXR out of the box with the
XRcomponent - 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.
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.

Build a Full-Stack App with Firebase and Next.js 15: Auth, Firestore & Real-Time
Learn how to build a full-stack app with Next.js 15 and Firebase. This guide covers authentication, Firestore, real-time updates, Server Actions, and deployment to Vercel.

Motion for React: Build Production-Grade Animations, Gestures, and Transitions
Master the Motion animation library (formerly Framer Motion) for React. Learn to build smooth animations, interactive gestures, layout transitions, scroll-driven effects, and exit animations with TypeScript.