Navigate

Search topics across all sections

GitHub
Production

Go Live

Your R3F app works beautifully on localhost. Now you need to ship it to real users on real devices over real networks. Deployment is not just "push to Vercel" -- it is the difference between a 2-second load and a 12-second load.

You deploy your R3F app to Vercel. It works on your laptop. Then your client opens it on their phone over 4G. The page takes 15 seconds to load, the 3D scene stutters at 8fps, and the browser crashes after 30 seconds. Your Lighthouse score is 23.

terminal
Deploy: Bundle size 2.4MB (uncompressed) | LCP: 8.2s | FPS on mobile: 8 | Memory: 450MB (phone limit: 512MB) | Lighthouse: 23/100

Real-world

Think of deployment like launching a rocket.

Pre-flight checks (build optimization) ensure nothing is broken. Fuel efficiency (bundle size) determines how far you can go. The launch pad (hosting platform) must match your rocket. And mission control (monitoring) tells you if something goes wrong in orbit.

One bad setting -- an uncompressed texture, an unoptimized import, a missing environment variable -- can abort the entire mission.

Build

next build

Analyze

Bundle size check

Optimize

Tree-shake, split

Deploy

Vercel / Netlify

Monitor

Lighthouse, errors

Step 1: Build & Bundle Optimization

The build step transforms your development code into optimized production assets. Every kilobyte matters -- especially for 3D apps where Three.js alone is substantial.

Tree-shake Three.js imports

TreeShake.tsTS
// Before: imports entire Three.js (~600KB)
import * as THREE from 'three'

// After: imports only what you use (~50KB typical)
import { Vector3, Color, MathUtils } from 'three'
import { ACESFilmicToneMapping, SRGBColorSpace } from 'three'

// R3F and drei already tree-shake internally,
// but YOUR code must use named imports too

Named imports let the bundler eliminate unused code. A typical R3F app only uses 10-15% of Three.js classes. Tree-shaking can save 200-400KB from your final bundle.

Dynamic imports for code splitting

CodeSplit.tsxTSX
import dynamic from 'next/dynamic'

// Three.js code is split into a separate chunk
const Scene3D = dynamic(
  () => import('@/features/scene/Scene3D'),
  {
    ssr: false,  // Required — Three.js needs WebGL
    loading: () => (
      <div className="h-screen bg-black animate-pulse" />
    ),
  }
)

// Users who never scroll to the 3D section
// never download Three.js at all

Dynamic imports create separate JavaScript chunks. For an R3F landing page where the 3D scene is below the fold, this means the critical path only includes your HTML and CSS. The 3D code loads on demand.

Analyze your bundle

bundle-analysisJS
# Install the analyzer
npm install @next/bundle-analyzer

# next.config.js
const withBundleAnalyzer = require('@next/bundle-analyzer')({
  enabled: process.env.ANALYZE === 'true',
})
module.exports = withBundleAnalyzer(nextConfig)

# Run the analysis
ANALYZE=true npm run build

# Look for:
# - three.js: should be <200KB gzipped
# - Duplicate dependencies
# - Unused packages

Bundle analysis shows you exactly what is in your production build. Common findings: duplicate three.js copies (from different versions), unused drei helpers, and development-only packages that snuck into production.

Step 2: Asset Optimization

3D assets (models, textures, HDR maps) are often larger than your entire JavaScript bundle. Compressing them is the highest impact optimization you can make.

Compress models and textures

asset-compressionJS
# Compress GLTF models with Draco (70-90% smaller)
npx gltf-pipeline -i model.glb -o model-draco.glb -d

# Compress textures to KTX2/Basis (4-6x smaller)
npx toktx --t2 --bcmp output.ktx2 input.png

# In your R3F code, use the Draco decoder
import { useGLTF } from '@react-three/drei'
// drei auto-detects Draco and loads the decoder

# Asset size targets:
# - GLTF models: < 1MB after Draco
# - Textures: < 500KB each (KTX2)
# - HDR maps: < 2MB (use compressed .hdr or .exr)
# - Total scene: < 5MB for fast mobile loading

Draco compression reduces GLTF geometry by 70-90%. KTX2/Basis textures are GPU-compressed, meaning they stay compressed even in video memory. Combine both for scenes that load fast and use less GPU RAM.

Serve assets from a CDN

CDN.tsxTSX
// Store heavy assets on a CDN, not in your repo
// Vercel, Cloudflare R2, or AWS S3 + CloudFront

const MODEL_URL = process.env.NEXT_PUBLIC_CDN_URL
  + '/models/car-draco.glb'

function CarModel() {
  const { scene } = useGLTF(MODEL_URL)
  return <primitive object={scene} />
}

// next.config.js — set proper cache headers
const nextConfig = {
  async headers() {
    return [{
      source: '/assets/:path*',
      headers: [
        { key: 'Cache-Control',
          value: 'public, max-age=31536000, immutable' }
      ],
    }]
  },
}

CDNs serve assets from the edge node closest to the user. A 2MB model that takes 4 seconds from your origin server might take 400ms from a CDN. Set immutable cache headers so returning visitors never re-download unchanged assets.

Step 3: Deployment

Vercel and Netlify are the most common hosts for Next.js + R3F apps. Both handle builds, CDN distribution, and HTTPS automatically.

Vercel deployment checklist

vercel-deployJS
# 1. Environment variables
# Set in Vercel dashboard, NOT in .env files
NEXT_PUBLIC_CDN_URL=https://assets.example.com

# 2. Build command (automatic for Next.js)
next build

# 3. Common gotchas:
# - ssr: false on all dynamic Three.js imports
# - process.env.NODE_ENV auto-set to 'production'
# - Remove r3f-perf and Leva in production:
{process.env.NODE_ENV === 'development' && <Perf />}

# 4. Verify output
# - Check Functions tab for serverless function size
# - Check Edge Network for static asset caching
# - Run Lighthouse on the deployed URL

Vercel auto-detects Next.js and handles the build. The main things to verify: environment variables are set, Three.js components use ssr: false, and development tools like r3f-perf are conditionally excluded from production bundles.

Production environment checks

ProdChecks.tsxTSX
// Remove debug tools in production
const isDev = process.env.NODE_ENV === 'development'

function Scene() {
  return (
    <Canvas>
      {isDev && <Perf position="top-left" />}
      {isDev && <axesHelper args={[5]} />}
      {isDev && <gridHelper args={[10, 10]} />}
      <MyScene />
    </Canvas>
  )
}

// Leva panels — hide in production
<Leva hidden={!isDev} />

Development tools add overhead: r3f-perf polls performance stats every frame, Leva creates DOM elements, and helpers add draw calls. Gate them behind NODE_ENV checks so they are tree-shaken out of the production build entirely.

What you just learned

Named imports from 'three' enable tree-shaking — saving 200-400KB vs import * as THREE.

Dynamic imports with ssr: false code-split Three.js so it only loads when needed.

Draco-compressed GLTF models are 70-90% smaller. KTX2 textures are 4-6x smaller.

CDN hosting with immutable cache headers eliminates re-downloads for returning visitors.

Gate development tools (r3f-perf, Leva, helpers) behind NODE_ENV so they are removed from production builds.

Question

Your R3F app has a Lighthouse performance score of 45. The main issues are: LCP of 6.2 seconds and Total Blocking Time of 1.8 seconds. The 3D scene is below the fold. What is the single highest-impact fix?

Think about it...

You deploy an R3F app. Bundle analysis shows three.js appearing twice in your build — once at 580KB and once at 320KB. What is the most likely cause, and how do you fix it?

Hint: What happens when two npm packages list different versions of the same dependency?

These are the patterns that trip up developers most often. Switch between Wrong and Fixed to compare the code side by side.

1
Importing all of Three.js
Your bundle includes 600KB of unused geometry types
Don't do this
TreeShaking.tsxTSX
import * as THREE from 'three'

// Only using Vector3 and Color, but shipping
// the entire Three.js library to every user
const pos = new THREE.Vector3()
const col = new THREE.Color('#ff0000')
import * as THREE pulls in the entire Three.js module (~600KB unminified). Modern bundlers like webpack and esbuild can tree-shake named imports, including only the classes you actually use. For a typical R3F app, this can save 200-400KB from the bundle. Some Three.js classes pull in dependencies (like loaders), so check your bundle analyzer output.
2
Not using dynamic imports for heavy scenes
Initial page load includes 3D code the user may never see
Don't do this
DynamicImport.tsxTSX
// page.tsx — this loads immediately even if user
// never scrolls to the 3D section
import { HeavyScene } from './HeavyScene'

export default function Page() {
  return (
    <div>
      <Hero />
      <Features />
      <HeavyScene />  {/* 500KB component */}
    </div>
  )
}
Dynamic imports create a separate JavaScript chunk that only loads when the component is needed. For R3F, this is critical because Three.js, your 3D code, and any loaders can easily be 500KB+. Users who never scroll to the 3D section never download it. Always set ssr: false for Three.js components -- they require the browser's WebGL context.
3
Deploying without testing on mobile
Works on desktop, crashes on phones with 2GB RAM
Don't do this
AdaptiveRendering.tsxTSX
// No device testing — assuming desktop performance
<Canvas>
  <Model />  {/* 50MB uncompressed GLTF */}
  <Shadows />
  <PostProcessing />
  <Particles count={100000} />
</Canvas>
Mobile GPUs have a fraction of the power of desktop GPUs. A scene that runs at 60fps on a MacBook may crash an Android phone. Use @react-three/drei's useDetectGPU to check the GPU tier at runtime and adjust quality: lower pixel ratio, disable shadows, reduce particle count, and skip post-processing on low-tier devices.

Best Practices

Run Lighthouse Before Shipping

Run Lighthouse on your deployed URL, not localhost. Aim for Performance above 70, LCP under 2.5 seconds, and TBT under 200ms. R3F apps rarely hit 100 due to WebGL overhead, but 70+ is achievable with proper optimization.

Test on Real Devices

Chrome DevTools device simulation does not emulate GPU limitations. Test on an actual mid-range Android phone (Samsung A-series or Pixel 6a). If it runs smoothly there, it will run smoothly everywhere.

Set Up Error Monitoring

WebGL context loss, shader compilation failures, and out-of-memory crashes happen in production. Use Sentry or a similar service with a custom error boundary around your Canvas to catch and report these issues.

Add a WebGL Fallback

Not all browsers support WebGL2. Wrap your Canvas in an error boundary that shows a static image or message when WebGL is unavailable. This prevents blank pages on older devices and in corporate environments with GPU restrictions.