AETumi — AI-native 3D web platform for Three.js, WebGL and interactive websites
News & Guides

Three.js + Next.js SSR: Add WebGL Without Breaking Server Rendering

September 8, 2026 · AETumi

Key answer: Three.js runs only in the browser because it needs the DOM, a canvas, and a WebGL context — none of which exist during server rendering. In Next.js you keep SSR healthy by isolating every Three.js call inside a client component, importing that component with next/dynamic and { ssr: false }, and rendering your real headings and copy as normal server HTML around (not inside) the canvas.

Table of contents

Why WebGL is client-only

Next.js renders components on the server first, then hydrates them in the browser. Three.js cannot participate in the server pass. It reaches for window, document, HTMLCanvasElement, and a live WebGL context — objects that only exist once a real browser is running the page. If a Three.js module runs during server rendering, the build throws errors like ReferenceError: window is not defined or document is not defined.

How the pieces connectAETumi technical diagram — How the pieces connectNext.jsR3FThree.jsWebGLGPU
How the pieces connect
Elliptical Galaxy
Elliptical Galaxy — live preview from the AETumi library

There is also no visual payoff to rendering WebGL on the server. A GPU-drawn canvas is pixels, not markup. Search engines and social scrapers cannot read inside it, and there is nothing meaningful to serialize into HTML. So the correct mental model is simple: the scene is a client-only island, and everything a crawler needs to understand the page lives in ordinary server-rendered HTML beside it.

The dynamic import pattern

The clean boundary has two files. First, a client component that owns all Three.js code. It carries the "use client" directive and sets up the renderer inside useEffect, which never runs on the server.

Flying Dust
Flying Dust — live preview from the AETumi library
// components/Scene.jsx
"use client";
import { useEffect, useRef } from "react";
import * as THREE from "three";

export default function Scene() {
  const mountRef = useRef(null);

  useEffect(() => {
    const mount = mountRef.current;
    const scene = new THREE.Scene();
    const camera = new THREE.PerspectiveCamera(
      60, mount.clientWidth / mount.clientHeight, 0.1, 100
    );
    camera.position.z = 4;

    const renderer = new THREE.WebGLRenderer({ antialias: true });
    renderer.setSize(mount.clientWidth, mount.clientHeight);
    renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
    mount.appendChild(renderer.domElement);

    const mesh = new THREE.Mesh(
      new THREE.IcosahedronGeometry(1, 0),
      new THREE.MeshStandardMaterial({ color: 0x6699ff, roughness: 0.35 })
    );
    scene.add(mesh);
    scene.add(new THREE.HemisphereLight(0xffffff, 0x223344, 1.1));

    let raf;
    const animate = () => {
      mesh.rotation.y += 0.005;
      renderer.render(scene, camera);
      raf = requestAnimationFrame(animate);
    };
    animate();

    return () => {
      cancelAnimationFrame(raf);
      renderer.dispose();
      mesh.geometry.dispose();
      mesh.material.dispose();
      mount.removeChild(renderer.domElement);
    };
  }, []);

  return <div ref={mountRef} style={{ width: "100%", height: "100%" }} />;
}
Core capabilitiesAETumi technical diagram — Core capabilitiesNext.js SSRReact ThreeFiberThree.js /WebGLGSAP motionKTX2 assets
Core capabilities

Second, the page or wrapper that pulls the scene in with next/dynamic and disables server rendering for it:

// app/page.jsx  (a Server Component)
import dynamic from "next/dynamic";

const Scene = dynamic(() => import("../components/Scene"), {
  ssr: false,
  loading: () => <div aria-hidden="true" className="scene-placeholder" />,
});

export default function Page() {
  return (
    <main>
      <h1>Interactive 3D product configurator</h1>
      <p>Rotate, inspect, and customize the model in real time.</p>

      <div style={{ height: "70vh" }}>
        <Scene />
      </div>

      <section>
        <h2>How it works</h2>
        <p>All the descriptive, indexable content lives here in the HTML.</p>
      </section>
    </main>
  );
}

The ssr: false flag tells Next.js to skip this component during server rendering entirely, so no Three.js code executes in Node. The loading fallback reserves layout space, which matters for avoiding layout shift when the canvas mounts.

One caveat worth knowing: in the App Router, next/dynamic with ssr: false cannot be called from inside a Server Component in some Next.js versions. If you hit that restriction, move the dynamic import into a thin "use client" wrapper component and render that wrapper from your server page. AETumi's Next.js templates ship this wrapper by default, which is why they drop into the App Router without the hydration errors most 3D starters produce.

Keeping SEO content server-rendered

The most common SEO mistake with 3D sites is putting the headline, product description, or navigation inside the WebGL scene as 3D text. Crawlers cannot read pixels. Anything you need indexed — H1, body copy, links, structured data — must be real HTML rendered on the server.

Hourglass Galaxy
Hourglass Galaxy — live preview from the AETumi library

Structure the page so the canvas is decorative and the surrounding markup carries meaning:

  • Server-render the H1, the intro paragraph, and section headings as normal JSX in a Server Component.
  • Treat the <Scene /> island as a visual layer, not a content layer.
  • Give the canvas container an accessible label or aria-hidden if it is purely decorative, and provide a text equivalent nearby.
  • Keep metadata (title, description, Open Graph) in the route's metadata export so it is emitted server-side.

Done this way, view-source shows a fully populated, crawlable document, and the 3D experience layers on top after hydration. This is the pattern AETumi bakes into every Next.js template so the canvas is an island and the copy stays indexable. For a deeper end-to-end walkthrough, see the Three.js hub and the companion Next.js + Three.js tutorial.

Common mistakes

  • Importing three at module top-level in a Server Component. Even an unused import can trigger window is not defined. Keep Three.js imports inside the client component only.
  • Forgetting cleanup. Without disposing the renderer, geometries, and materials — and cancelling the animation loop — client-side navigation leaks GPU memory and stacks multiple render loops.
  • Rendering text as 3D geometry for SEO reasons. It looks impressive and indexes as nothing. Keep readable content in HTML.
  • No reserved height for the canvas. A zero-height container that grows on mount causes cumulative layout shift. Set an explicit height on the wrapper.
  • Assuming useEffect runs on the server. It does not, which is exactly why it is the right place for setup — but do guard any code paths that might run before mount.
The AETumi system at a glanceAETumi technical diagram — The AETumi system at a glanceNext.jsR3FThree.jsWebGLGSAPAssets3D webstack
The AETumi system at a glance

FAQ

Why does Three.js throw "window is not defined" in Next.js? Because the module executed during server rendering, where window does not exist. Move all Three.js code into a client component and load it with dynamic(..., { ssr: false }) so it never runs on the server.

Does ssr: false hurt my SEO? No, as long as your indexable content is server-rendered HTML outside the canvas. The 3D scene has nothing crawlable to render on the server anyway, so disabling SSR only skips code that could not run there.

Should I use React Three Fiber instead of vanilla Three.js? Both work in Next.js and both follow the same client-boundary rule. React Three Fiber gives you a declarative, component-based API; vanilla Three.js gives you direct control. The choice is about developer ergonomics, not SSR behavior.

Can I server-render a static image of the 3D scene for previews? Not from the client scene itself. A common approach is to generate a static poster image at build time and use it as the Open Graph image and an initial placeholder, then hydrate the live canvas on top.

Is there a ready-made SSR-safe starting point? Yes. AETumi's Next.js and Three.js templates ship the client-boundary pattern above already wired, so you inherit an SSR-safe structure instead of debugging window is not defined yourself.

Conclusion

Three.js and Next.js SSR coexist cleanly once you accept that WebGL is a browser-only island. Wall off every Three.js call inside a "use client" component, load it with dynamic and ssr: false, dispose resources on unmount, and keep your headings, copy, metadata, and links as server-rendered HTML around the canvas. That gives you a fully crawlable page and a rich 3D layer without the two fighting each other.

If you would rather start from a working baseline than wire this up from scratch, the nextjs-threejs-starter repo shows the client-boundary pattern in place — and AETumi, an AI-native 3D web platform, ships production-ready Three.js and Next.js components built around exactly this SSR-safe structure, so the 3D is beautiful and the HTML still gets indexed. Because every AETumi template is editable source you own for life, your AI assistant can extend the scene without breaking the server boundary. See the plans at aetumi.app/pricing.

More from the AETumi library

Real, production-ready assets — preview the motion, grab the source.

Browse all Three.js assets →