Key answer: To ship Next.js Three.js in production, mount the scene as a client island — an isolated "use client" component loaded through a dynamic() import with ssr: false — while every SEO-critical heading, paragraph, and link stays server-rendered HTML outside the canvas. WebGL exists only in the browser, so the discipline is to keep the <Canvas> off the server render, code-split the 3D bundle so it never weighs down pages that have no 3D, adapt device pixel ratio to the device, and dispose GPU resources when the route changes. Get those four decisions right and Next.js Three.js is stable, fast, and indexable. Everything else is detail on top of them.
Table of contents
- What you'll learn
- Why Three.js needs an architecture, not just a snippet
- The system model
- The client-island pattern
- Keeping SEO-critical HTML on the server
- Lazy-loading the 3D bundle
- DPR and quality adaptation
- Disposing resources on route change
- React Three Fiber vs vanilla Three.js in Next.js
- Performance budget
- Accessibility
- Production trade-offs
- When to use Next.js + Three.js
- When NOT to use it
- Decision matrix
- Common mistakes
- Expert notes
- How AETumi approaches it
- GitHub proof
- FAQ
- Related resources
- Conclusion
What you'll learn
- The four decisions that make a Next.js + Three.js app production-ready: client island, server-rendered content, code splitting, and GPU disposal.
- The exact three-file client-island pattern — scene, no-SSR loader, server page — with working code.
- Why
ssr: falseis required on top of"use client", and how to stopwindow is not definedand hydration mismatches. - How to lazy-load the 3D bundle so routes with no 3D stay light, and how to dispose geometries, materials, and the renderer on route change.
- When React Three Fiber is the lower-maintenance choice and when vanilla Three.js earns its extra code.
- Clear tables for when a Three.js scene is worth it and when a lighter medium wins.
Why Three.js needs an architecture, not just a snippet
Next.js renders on the server by default — Server Components in the App Router, SSR in the Pages Router. Three.js draws to a WebGL context that exists only in a browser: no canvas, no window, no GPU on the server. Render a <Canvas> during the server pass and you get window is not defined or a hydration mismatch.
A one-file demo can paper over this. A production site cannot, because it also has to answer: does the 3D bundle slow down pages that show no 3D? Does GPU memory get released when a user navigates away? Is the page still indexable? Those are architecture questions, and they have consistent answers. This guide covers them for Three.js in Next.js; for a worked build of a full page, see building a Next.js 3D website. They are also the exact decisions AETumi's Next.js Three.js templates make for you, so the four questions above are already answered in the source you start from.
The system model
Think of the page as two layers with a hard boundary between them. The document layer is server-rendered HTML — headings, copy, links, structured data — the thing a crawler and a first paint see immediately. The scene layer is a browser-only WebGL canvas that mounts on top after the document is interactive. The boundary between them is a single "use client" component loaded with ssr: false; nothing crosses it except props flowing inward.
That mental model resolves every downstream decision. SEO content belongs in the document layer. The Three.js dependency belongs behind the boundary, code-split so it downloads only where the scene layer appears. GPU lifecycle belongs to the scene layer, torn down when its component unmounts. Hold the boundary and Next.js Three.js stays predictable; blur it — a heading inside the canvas, a "use client" on the layout — and the problems this guide fixes all return at once.
The client-island pattern
The reliable structure is three files: a client scene, a client-only loader that disables SSR, and a normal server page. The scene is an island of interactivity in an otherwise server-rendered document.
components/Scene.jsx — the 3D, marked as a client component:
"use client";
import { Canvas, useFrame } from "@react-three/fiber";
import { OrbitControls } from "@react-three/drei";
import { useRef } from "react";
function Cube() {
const ref = useRef();
useFrame((_, delta) => {
ref.current.rotation.x += delta * 0.5;
ref.current.rotation.y += delta * 0.5;
});
return (
<mesh ref={ref}>
<boxGeometry args={[1, 1, 1]} />
<meshStandardMaterial color="#8a5cff" />
</mesh>
);
}
export default function Scene() {
return (
<Canvas camera={{ position: [3, 3, 3], fov: 50 }} dpr={[1, 2]}>
<ambientLight intensity={0.5} />
<directionalLight position={[5, 5, 5]} intensity={1} />
<Cube />
<OrbitControls enablePan={false} />
</Canvas>
);
}
components/SceneLoader.jsx — a client wrapper that disables SSR:
"use client";
import dynamic from "next/dynamic";
const Scene = dynamic(() => import("./Scene"), {
ssr: false,
loading: () => <div style={{ height: 480 }} aria-hidden="true" />,
});
export default function SceneLoader() {
return <Scene />;
}
app/page.jsx — a normal Server Component:
import SceneLoader from "@/components/SceneLoader";
export default function Page() {
return (
<main>
<h1>Next.js + Three.js</h1>
<p>Server-rendered copy that search engines read normally.</p>
<div style={{ height: 480 }}>
<SceneLoader />
</div>
</main>
);
}
Why the extra SceneLoader? In the App Router, dynamic(..., { ssr: false }) must be called inside a Client Component — calling it directly in a Server Component throws. The "use client" wrapper is the current, reliable pattern. Expected behavior: the <h1> and copy are in the server HTML; the cube appears once the split chunk loads in the browser. Trade-off: the extra loader file is boilerplate you repeat per scene — worth encapsulating into a shared component once you have more than one.
Keeping SEO-critical HTML on the server
ssr: false skips the server render for the canvas, and that is correct — a canvas holds no text to index. The mistake is letting it pull your content off the server too. Keep the <h1>, body copy, links, and structured data as server-rendered HTML in the page, outside the island. The 3D becomes a visual layer over indexable text, not a replacement for it.
Concretely: never put headings or paragraphs inside the WebGL scene, and never gate meaningful content behind the client-only import. The document that reaches a crawler should read complete with the canvas removed. This is the single most important SEO rule for Next.js Three.js — the framework's server rendering is your indexing advantage, and the canvas must never take it away.
Lazy-loading the 3D bundle
Three.js, R3F, and drei are heavy. If they load on every route, pages with no 3D pay for it in bundle size and parse time. The dynamic() import already code-splits the scene into its own chunk — so make sure only the routes that render SceneLoader import it. Do not re-export the scene from a shared barrel file that non-3D pages also pull from, or the splitter merges it back in.
For a scene below the fold, defer the import until it is near the viewport:
"use client";
import { useEffect, useRef, useState } from "react";
import dynamic from "next/dynamic";
const Scene = dynamic(() => import("./Scene"), { ssr: false });
export default function LazyScene() {
const ref = useRef(null);
const [show, setShow] = useState(false);
useEffect(() => {
const io = new IntersectionObserver(([e]) => {
if (e.isIntersecting) { setShow(true); io.disconnect(); }
}, { rootMargin: "200px" });
if (ref.current) io.observe(ref.current);
return () => io.disconnect();
}, []);
return <div ref={ref} style={{ height: 480 }}>{show && <Scene />}</div>;
}
Expected behavior: the 3D chunk downloads only when the user is about to see it, and never on routes that don't use this component. Trade-off: the observer adds a small amount of client code and a one-frame delay before the scene appears; for an above-the-fold hero you would skip the observer and load eagerly-but-split instead.
DPR and quality adaptation
A 3x display renders nine times the pixels of a 1x one. Uncapped, that alone can halve the frame rate on a phone. In R3F, dpr={[1, 2]} on the <Canvas> clamps the resolution — the renderer picks within that range and never exceeds 2x. In vanilla Three.js the equivalent is:
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
Expected behavior: a Retina phone renders at most 2x instead of 3x, cutting fragment work by roughly a third. Trade-off: capping DPR slightly softens edges on the sharpest displays — an acceptable exchange for a stable frame rate. Adapt further when needed: R3F's performance scaling can drop DPR under load and restore it when the scene is idle, trading a little sharpness for a stable frame rate on weak GPUs. Start conservative — capped DPR and antialias off — and add quality only where the budget allows.
Disposing resources on route change
WebGL resources are not garbage-collected the way plain objects are. Geometries, materials, textures, and the renderer hold GPU memory that must be released explicitly, or navigating between routes leaks it until the context is lost.
React Three Fiber disposes objects it created when they unmount, which covers most declarative scenes. You are responsible for anything you made imperatively — a manually loaded texture, a render target, a custom material. Dispose those in a cleanup function:
useEffect(() => {
const texture = new THREE.TextureLoader().load("/hero.jpg");
return () => texture.dispose();
}, []);
Expected behavior: the texture's GPU memory is freed the moment the component unmounts. Trade-off: you must track every imperatively created resource yourself; miss one and it leaks silently.
In vanilla Three.js inside Next.js, the whole context is yours to tear down when the client component unmounts on a route change:
useEffect(() => {
// ...create renderer, scene, geometry, material...
return () => {
cancelAnimationFrame(raf);
geometry.dispose();
material.dispose();
renderer.dispose();
renderer.forceContextLoss();
};
}, []);
Because the island unmounts on navigation, this cleanup runs on every route change — which is exactly what keeps GPU memory flat across a browsing session. Expected behavior: memory usage returns to baseline after each navigation. Trade-off: forceContextLoss() is aggressive — necessary for vanilla teardown, but if you remount the same context often, prefer disposing individual resources over destroying the whole context each time.
React Three Fiber vs vanilla Three.js in Next.js
Both work; the client-island rules are identical for each. The trade-off is lifecycle management.
- React Three Fiber expresses the scene as components and disposes what it created on unmount, so the route-change cleanup above is mostly automatic. In a React app this is the lower-maintenance default, and it composes with drei's loaders and controls.
- Vanilla Three.js gives you direct control and a smaller dependency surface, at the cost of writing the
useEffectsetup and the full disposal path yourself. Choose it when you want precise control over the render loop or are porting existing imperative Three.js code.
Either way, the scene lives behind "use client" and a dynamic() no-SSR import. For the R3F-specific ergonomics — hooks, drei helpers, and declarative loaders — see React Three Fiber.
Performance budget
Architecture keeps Next.js Three.js from breaking your app; a performance budget keeps it fast. Set one before you build: a target for the 3D chunk size (inspect it with next build), a triangle-and-draw-call ceiling for the scene, and a floor frame rate on a mid-range phone. Then hold the line — every added light, shadow map, and post-processing pass spends against that budget.
Practical levers, cheapest first: cap DPR (done above), disable antialiasing and use a cheaper post-process FXAA if you need edge smoothing, keep materials shared rather than cloned per mesh, and pause the requestAnimationFrame loop when the canvas is off-screen or the tab is hidden. Load large glTF models and textures at runtime, compressed with Draco or KTX2, rather than importing them into the bundle. The goal is that the document layer is fast regardless, and the scene layer holds its frame rate on the slowest device you support.
Accessibility
A <canvas> is a black box to assistive technology. Treat a decorative scene as decorative: mark its container aria-hidden="true" and make sure the page is fully usable and meaningful with the canvas removed — which the client-island pattern already guarantees, since the content lives in the server-rendered document layer. Honor prefers-reduced-motion by pausing auto-rotation, orbit drift, and scroll-scrubbing for users who ask for it, and show a still frame instead. If the scene is functional — a configurator or a data view — expose the same capability through standard DOM controls so keyboard and screen-reader users are not locked out. Accessibility is not a competing concern here; the server-first architecture is what makes it straightforward.
Production trade-offs
- Weight. Even split and deferred, Three.js plus R3F plus drei is a real dependency. A brand hero justifies it; a decorative flourish rarely does.
- Maintenance. Client islands, no-SSR loaders, and disposal paths are more moving parts than an image tag. Encapsulate them so the guardrails are hard to skip.
- Device spread. Test the slow end. A scene that flies on your workstation may crawl on a budget phone.
- When simpler tech is better. A looping video with a poster image, or a CSS/SVG animation, often communicates the same idea at a fraction of the cost and with none of the WebGL lifecycle burden. Reach for real-time 3D only when interaction or scroll-driven motion is the actual requirement.
When to use Next.js + Three.js
| Situation | Use it? | Why |
|---|---|---|
| Interactive product configurator | Yes | Real-time 3D interaction is the feature |
| Scroll-scrubbed 3D hero, brand-central | Yes | Motion is the message |
| Spatial data that needs real depth | Yes | No 2D medium conveys it |
| Marketing page with a decorative accent | Maybe | Only if a video/CSS won't do |
| Static product beauty shot | No | An optimized image is faster and sharper |
| Docs, blog, or content-heavy route | No | 3D adds weight with no reader payoff |
When NOT to use it
| Anti-pattern | Problem | Do this instead |
|---|---|---|
<Canvas> in the server render | window is not defined, hydration mismatch | dynamic(..., { ssr: false }) |
"use client" alone, no ssr:false | Canvas still renders initial HTML on server | Add the no-SSR dynamic import |
| Scene re-exported via shared barrel | Three.js bundled on every route | Import only from 3D routes |
| No disposal on unmount | GPU memory leaks across navigation | Dispose resources; vanilla also renderer.dispose() |
| Headings/copy inside the canvas | Content is not indexable | Keep SEO HTML in the server document |
| No fixed-height container | Layout shift when the canvas mounts | Reserve height or aspect ratio |
Decision matrix
| Choice | Bundle cost | Disposal burden | SEO/a11y | Pick when |
|---|---|---|---|---|
| Static image | Minimal | None | Excellent | A still visual is enough |
| Looping video | Low | None | Good (with poster) | Ambient motion, no interaction |
| CSS/SVG animation | Minimal | None | Excellent | Subtle, non-3D motion |
| R3F client island | Moderate (isolated) | Mostly automatic | Good if HTML stays server-side | Declarative scenes in a React app |
| Vanilla Three.js island | Moderate (isolated) | Manual, full control | Good if HTML stays server-side | Precise render-loop control, porting code |
The matrix reflects the rule that runs through this guide: use the lightest medium that meets the requirement, and when 3D is genuinely required, isolate it behind the client-island boundary.
Common mistakes
window is not defined. A browser API ran during the server render. Keep that code inside the client scene and load it withssr: false.- Relying on
"use client"alone. A client component still renders its initial HTML on the server unless you also gate it withssr: false. WebGL needs both. - Leaking GPU memory. No disposal on unmount means every route change strands geometries, materials, and textures. Return cleanup that disposes them and, for vanilla, the renderer.
- Bundling 3D everywhere. Re-exporting the scene through a shared module drags Three.js into pages that show none. Keep the dynamic import isolated to 3D routes.
- Layout shift. Give the canvas container a fixed height or aspect ratio so the page does not reflow when the scene mounts.
Expert notes
Expert Note — Encapsulate the no-SSR loader once. Repeating the SceneLoader wrapper for every scene invites drift — one file forgets the height placeholder, another forgets aria-hidden. Build a single generic <ClientScene import={...} height={...} /> that owns the dynamic(..., { ssr: false }), the reserved-height loading box, and the aria-hidden default. Every scene then goes through the same audited boundary, and the four production rules become impossible to skip by accident.
Expert Note — Verify the split with next build, not by eye. It is easy to believe the 3D is code-split and be wrong — a stray import in a shared layout or a barrel re-export quietly merges Three.js back into the main chunk. After wiring the dynamic import, run next build and read the per-route chunk sizes. A non-3D route that suddenly carries a few hundred kilobytes is Three.js leaking in. Catching it here is trivial; catching it from production analytics is not.
Expert Note — Pause the loop when the canvas isn't visible. A render loop that keeps drawing while the scene is scrolled off-screen or the tab is backgrounded wastes GPU, battery, and main-thread time the rest of the page could use. Gate requestAnimationFrame on an IntersectionObserver and the Page Visibility API. For a page with a scene far down the route, this is often the biggest single win for battery and perceived smoothness — and it costs a few lines.
How AETumi approaches it
AETumi is an AI-native 3D web platform, and its Next.js Three.js templates make the four production decisions for you: the client island behind "use client", the dynamic(..., { ssr: false }) loader, server-rendered content in the document layer, a capped-DPR canvas, and disposal wired into unmount so imperatively created resources are torn down without you tracking each one. AETumi ships both flavors — React Three Fiber and vanilla Three.js building blocks — so you can match each to the part of the build that fits it, and the AETumi Three.js components carry the aria-hidden and reduced-motion defaults described above. Because you get editable full source, the DPR range, the lazy-mount buffer, and the disposal path are all yours to tune. Pricing is buy-once-own-for-life — $19 / $39 / $99 / $129 — and the $129 Full Stack tier bundles the AETumi MCP so an assistant can pull these tuned components straight into a project.
GitHub proof
The starter at github.com/AETumiApp/nextjs-threejs-starter is a working baseline for exactly this architecture: it shows the three-file client-island pattern, the no-SSR dynamic import, a capped-DPR <Canvas>, and a disposal path on unmount, in a minimal App Router project. It targets Three.js r160 with ES-module imports (no UMD build) and leaves creative content and asset optimization — Draco/KTX2 compression, texture sizing — to you. It is a reference, not a framework: treat the sample scene as a starting point that still needs your content and a review before it ships. The scaffolding — server/client separation, splitting, disposal — is what it proves out.
FAQ
Do I have to use React Three Fiber, or can I use raw Three.js in Next.js? Either. Initialize raw Three.js inside a client component's useEffect and dispose on unmount, or express the scene declaratively with React Three Fiber. R3F is the lower-maintenance choice in a React app because it handles most disposal for you and composes with drei's helpers. Vanilla Three.js gives you a smaller dependency surface and precise render-loop control at the cost of writing the setup and full teardown yourself. The client-island rules are identical for both.
Why ssr: false instead of just "use client"? "use client" still renders the component's initial HTML on the server. For WebGL that means the server tries to touch window/canvas and throws or mismatches on hydration. ssr: false skips the server render entirely for that import, which is what WebGL requires. You need both directives together: "use client" to make it a client component, and dynamic(..., { ssr: false }) to keep it out of the server pass.
Does disabling SSR for the canvas hurt SEO? No — a canvas has no text to index. Keep headings, copy, links, and structured data as server-rendered HTML around the scene and indexing is unaffected. The document a crawler receives should read complete with the canvas removed. Deferring the WebGL also improves loading metrics, which helps technical SEO rather than hurting it.
How do I stop the 3D bundle from slowing my other pages? The dynamic() import code-splits the scene into its own chunk. Import it only from 3D routes, avoid re-exporting it through a shared barrel that non-3D pages pull from, and optionally defer it with an IntersectionObserver until it nears the viewport. Confirm the split with next build — a non-3D route carrying Three.js weight means an import is leaking in.
How do I avoid GPU leaks between routes? Dispose imperatively created geometries, materials, and textures in a cleanup function; in vanilla Three.js also call renderer.dispose() and forceContextLoss(). Because the island unmounts on navigation, that cleanup runs on every route change, keeping GPU memory flat across a session. AETumi's templates wire this cleanup in so it is not something you can forget.
Related resources
- Three.js hub — rendering fundamentals behind this architecture.
- How to build a Next.js 3D website — a full worked page build.
- React Three Fiber — hooks, drei helpers, and declarative loaders.
- nextjs-threejs-starter on GitHub — a working baseline for the client-island pattern.
Conclusion
A production Next.js Three.js app rests on four decisions: isolate WebGL to a client island, keep SEO-critical HTML server-rendered around it, code-split the 3D bundle so non-3D pages stay light, and dispose GPU resources on route change with DPR adapted to the device. None of it is exotic — it is the same discipline applied consistently, with a performance budget and accessible defaults layered on top.
Next step: take one page, confirm its scene sits behind a no-SSR client island, and run next build to prove the 3D chunk is isolated. For these patterns already assembled — client/server separation, disposal, and quality adaptation wired in — explore the templates from AETumi, an AI-native 3D web platform: its Three.js collection and the starter at github.com/AETumiApp/nextjs-threejs-starter. Editable full source and the AETumi MCP ship in the Full Stack plan; see aetumi.app/pricing. Build once, own for life.
More from the AETumi library
Real, production-ready assets — preview the motion, grab the source.

