Uncaught TypeError: THREE.GLTFLoader is not a constructor

Why a three.js page that loads GLTFLoader or OrbitControls with a script tag from a CDN throws "THREE.GLTFLoader is not a constructor", the examples/js folder that was removed in r148, the import map that loads the addons as modules, and what the paprika3d plugin's open reply names on such a page.

The browser throws this when code calls new THREE.GLTFLoader() and THREE has no GLTFLoader on it. three.js itself prints nothing: the loader was never attached to THREE, so the name is undefined and new fails. The same error names OrbitControls, OBJLoader, FBXLoader and every other addon when the page loads them the old way. The old way is a plain <script> tag for a file in examples/js/, and that folder is not in three.js any more.

Where the name used to come from

Up to r147, three.js shipped each addon twice: as an ES module in examples/jsm/ and as a classic script in examples/js/. The classic script wrapped the class in a function and wrote it onto the global THREE that build/three.min.js had created. These are the first and last lines of examples/js/loaders/GLTFLoader.js in three r147:

( function () {

	class GLTFLoader extends THREE.Loader {

	// ... the loader ...

	THREE.GLTFLoader = GLTFLoader;

} )();

r148 deleted examples/js/, and r186 ships no build/three.min.js either. So a tag for examples/js/loaders/GLTFLoader.js, from a CDN or from node_modules, answers 404 on any current release, and nothing writes THREE.GLTFLoader. When three itself comes in as a module (import * as THREE from 'three'), there is also no global THREE for an old script to write to: a module namespace cannot take new properties.

Why the code still asks for it

A page that reproduces it

Three as a module from jsDelivr, and the two addons as script tags from the same release:

<script type="importmap">{"imports":{"three":"https://cdn.jsdelivr.net/npm/three@0.186.1/build/three.module.js"}}</script>
<script src="https://cdn.jsdelivr.net/npm/three@0.186.1/examples/js/loaders/GLTFLoader.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.186.1/examples/js/controls/OrbitControls.js"></script>
<script type="module">
import * as THREE from 'three';
// ... renderer, scene, camera, a hemisphere light ...
const loader = new THREE.GLTFLoader();
loader.load('models/toolbox.glb', (gltf) => { gltf.scene.name = 'toolbox'; scene.add(gltf.scene); });
const controls = new THREE.OrbitControls(camera, renderer.domElement);
</script>

paprika3d is a Claude Code plugin and MCP server that opens a three.js page in a headless Chrome of its own, paused at frame 0. These replies are from the stable build on 2026-10-28, on a host with no GPU; the run folder shows as <project>, and the replies are cut to the lines this page explains, none reworded.

open {"url":"index.html","cwd":"<project>"}

Opened http://127.0.0.1:42673/index.html in 3.3 s, paused at frame 0.
index.html:6 uses examples/js/, removed in r148: use the ES modules in three/addons/ (examples/jsm/)
index.html:7 uses examples/js/, removed in r148: use the ES modules in three/addons/ (examples/jsm/)
Page text outside the canvas: none
{"three":"186","scenes":1,"entities":[]}
Errors so far:
THREE.GLTFLoader is not a constructor (index.html:22:16)

eval {"code":"return { GLTFLoader: typeof THREE.GLTFLoader, globalTHREE: typeof window.THREE, canAddToTHREE: Object.isExtensible(THREE) }"}

{"GLTFLoader":"undefined","globalTHREE":"undefined","canAddToTHREE":false}

The two uses examples/js/ lines come from open reading the project's source before the page runs, so they name the tags to change by file and line even before an error shows. The error line gives the line of new THREE.GLTFLoader(); the page stops there, which is why OrbitControls on the next line never gets its own error, and why the scene has no entities. The eval shows the three facts above: no GLTFLoader, no global THREE, and a namespace nothing can add to.

The two 404s are not in that reply: the scripts came from another origin, and open lists failed fetches of models, textures and sounds, not of scripts. The same page with the tags pointing into node_modules gets both 404s in the reply:

Errors so far:
Failed to load resource: the server responded with a status of 404 (Not Found) node_modules/three/examples/js/loaders/GLTFLoader.js (run bun add three in cwd)
Failed to load resource: the server responded with a status of 404 (Not Found) node_modules/three/examples/js/controls/OrbitControls.js (run bun add three in cwd)
THREE.GLTFLoader is not a constructor (index.html:22:16)

The run bun add three hint in those lines does not fit this case: three was installed, and reinstalling it brings no examples/js/ folder back. The stale-API lines above them are the ones to act on.

The fix

Load each addon as a module from three/addons/, which maps to examples/jsm/, and import the class by name instead of reading it off THREE. The import map needs both entries, three and three/addons/, because every addon module itself imports three. From a CDN the two entries point at the same release:

<script type="importmap">{"imports":{"three":"https://cdn.jsdelivr.net/npm/three@0.186.1/build/three.module.js","three/addons/":"https://cdn.jsdelivr.net/npm/three@0.186.1/examples/jsm/"}}</script>
<script type="module">
import * as THREE from 'three';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';

const loader = new GLTFLoader();
const controls = new OrbitControls(camera, renderer.domElement);
</script>

That page, with the rest unchanged, opened clean and drew the model:

open {"url":"index.html","cwd":"<project>"}

Opened http://127.0.0.1:39111/index.html in 0.5 s, paused at frame 0.
Page text outside the canvas: none
{"three":"186","scenes":1,"entities":["toolbox"]}
Loaded: toolbox.glb 0.0 MB

look {}

On screen (3):
case 11% [375, 180, 909, 497]
lid 5.2% [361, 97, 922, 241] 2.05 × 1.05 units
badge 0.95% [512, 270, 645, 367] 0.6 × 0.3 units, across the view
Never visible: none

The whole page, here with three from node_modules (npm install three) and the model inlined so it runs on its own:

<!-- verify -->
<!doctype html>
<html><head><meta charset="utf-8"><title>Toolbox</title>
<style>html,body{margin:0;overflow:hidden}canvas{display:block}</style></head>
<body>
<script type="importmap">{"imports":{"three":"./node_modules/three/build/three.module.js","three/addons/":"./node_modules/three/examples/jsm/"}}</script>
<script type="module">
import * as THREE from 'three';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';

// In a project this is a file, 'models/toolbox.glb'. Inlined so the page runs on its own.
const MODEL = 'data:model/gltf-binary;base64,Z2xURgIAAADQBwAALAUAAEpTT057ImFzc2V0Ijp7ImdlbmVyYXRvciI6ImdsVEYtVHJhbnNmb3JtIHY0LjUuMSIsInZlcnNpb24iOiIyLjAifSwiYWNjZXNzb3JzIjpbeyJ0eXBlIjoiVkVDMyIsImNvbXBvbmVudFR5cGUiOjUxMjYsImNvdW50IjoyNCwibWF4IjpbMC41LDAuNSwwLjVdLCJtaW4iOlstMC41LC0wLjUsLTAuNV0sImJ1ZmZlclZpZXciOjAsImJ5dGVPZmZzZXQiOjB9LHsidHlwZSI6IlZFQzMiLCJjb21wb25lbnRUeXBlIjo1MTI2LCJjb3VudCI6MjQsImJ1ZmZlclZpZXciOjAsImJ5dGVPZmZzZXQiOjEyfSx7InR5cGUiOiJTQ0FMQVIiLCJjb21wb25lbnRUeXBlIjo1MTIzLCJjb3VudCI6MzYsImJ1ZmZlclZpZXciOjEsImJ5dGVPZmZzZXQiOjB9XSwiYnVmZmVyVmlld3MiOlt7ImJ1ZmZlciI6MCwiYnl0ZU9mZnNldCI6MCwiYnl0ZUxlbmd0aCI6NTc2LCJieXRlU3RyaWRlIjoyNCwidGFyZ2V0IjozNDk2Mn0seyJidWZmZXIiOjAsImJ5dGVPZmZzZXQiOjU3NiwiYnl0ZUxlbmd0aCI6NzIsInRhcmdldCI6MzQ5NjN9XSwiYnVmZmVycyI6W3siYnl0ZUxlbmd0aCI6NjQ4fV0sIm1hdGVyaWFscyI6W3sibmFtZSI6ImVuYW1lbCIsInBick1ldGFsbGljUm91Z2huZXNzIjp7ImJhc2VDb2xvckZhY3RvciI6WzAuMTgsMC4zMiwwLjU1LDFdLCJyb3VnaG5lc3NGYWN0b3IiOjAuNSwibWV0YWxsaWNGYWN0b3IiOjB9fSx7Im5hbWUiOiJwYWludCIsInBick1ldGFsbGljUm91Z2huZXNzIjp7ImJhc2VDb2xvckZhY3RvciI6WzAuODUsMC43NSwwLjIsMV0sInJvdWdobmVzc0ZhY3RvciI6MC42LCJtZXRhbGxpY0ZhY3RvciI6MH19XSwibWVzaGVzIjpbeyJuYW1lIjoiY2FzZSIsInByaW1pdGl2ZXMiOlt7ImF0dHJpYnV0ZXMiOnsiUE9TSVRJT04iOjAsIk5PUk1BTCI6MX0sIm1vZGUiOjQsIm1hdGVyaWFsIjowLCJpbmRpY2VzIjoyfV19LHsibmFtZSI6ImJhZGdlIiwicHJpbWl0aXZlcyI6W3siYXR0cmlidXRlcyI6eyJQT1NJVElPTiI6MCwiTk9STUFMIjoxfSwibW9kZSI6NCwibWF0ZXJpYWwiOjEsImluZGljZXMiOjJ9XX1dLCJub2RlcyI6W3sibmFtZSI6ImNhc2UiLCJ0cmFuc2xhdGlvbiI6WzAsMC41LDBdLCJzY2FsZSI6WzIsMSwxXSwibWVzaCI6MH0seyJuYW1lIjoibGlkIiwidHJhbnNsYXRpb24iOlswLDEuMSwwXSwic2NhbGUiOlsyLjA1LDAuMiwxLjA1XSwibWVzaCI6MH0seyJuYW1lIjoiYmFkZ2UiLCJ0cmFuc2xhdGlvbiI6WzAsMC41NSwwLjUxXSwic2NhbGUiOlswLjYsMC4zLDAuMDJdLCJtZXNoIjoxfSx7Im5hbWUiOiJ0b29sYm94IiwiY2hpbGRyZW4iOlswLDEsMl19XSwic2NlbmVzIjpbeyJuYW1lIjoiU2NlbmUiLCJub2RlcyI6WzNdfV19ICAgiAIAAEJJTgAAAAA/AAAAPwAAAD8AAIA/AAAAAAAAAAAAAAA/AAAAPwAAAL8AAIA/AAAAAAAAAAAAAAA/AAAAvwAAAD8AAIA/AAAAAAAAAAAAAAA/AAAAvwAAAL8AAIA/AAAAAAAAAAAAAAC/AAAAPwAAAL8AAIC/AAAAAAAAAAAAAAC/AAAAPwAAAD8AAIC/AAAAAAAAAAAAAAC/AAAAvwAAAL8AAIC/AAAAAAAAAAAAAAC/AAAAvwAAAD8AAIC/AAAAAAAAAAAAAAC/AAAAPwAAAL8AAAAAAACAPwAAAAAAAAA/AAAAPwAAAL8AAAAAAACAPwAAAAAAAAC/AAAAPwAAAD8AAAAAAACAPwAAAAAAAAA/AAAAPwAAAD8AAAAAAACAPwAAAAAAAAC/AAAAvwAAAD8AAAAAAACAvwAAAAAAAAA/AAAAvwAAAD8AAAAAAACAvwAAAAAAAAC/AAAAvwAAAL8AAAAAAACAvwAAAAAAAAA/AAAAvwAAAL8AAAAAAACAvwAAAAAAAAC/AAAAPwAAAD8AAAAAAAAAAAAAgD8AAAA/AAAAPwAAAD8AAAAAAAAAAAAAgD8AAAC/AAAAvwAAAD8AAAAAAAAAAAAAgD8AAAA/AAAAvwAAAD8AAAAAAAAAAAAAgD8AAAA/AAAAPwAAAL8AAAAAAAAAAAAAgL8AAAC/AAAAPwAAAL8AAAAAAAAAAAAAgL8AAAA/AAAAvwAAAL8AAAAAAAAAAAAAgL8AAAC/AAAAvwAAAL8AAAAAAAAAAAAAgL8AAAIAAQACAAMAAQAEAAYABQAGAAcABQAIAAoACQAKAAsACQAMAA4ADQAOAA8ADQAQABIAEQASABMAEQAUABYAFQAWABcAFQA=';

const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(innerWidth, innerHeight);
renderer.toneMapping = THREE.ACESFilmicToneMapping;
document.body.appendChild(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x1d2128);
scene.add(new THREE.HemisphereLight(0xffffff, 0x334455, 3));
const camera = new THREE.PerspectiveCamera(40, innerWidth / innerHeight, 0.1, 100);
camera.position.set(2.2, 2, 4);
camera.lookAt(0, 0.6, 0);

const loader = new GLTFLoader();
loader.load(MODEL, (gltf) => { gltf.scene.name = 'toolbox'; scene.add(gltf.scene); }, undefined, (err) => console.error('toolbox did not load:', err));
const controls = new OrbitControls(camera, renderer.domElement);

addEventListener('resize', () => {
  renderer.setSize(innerWidth, innerHeight);
  camera.aspect = innerWidth / innerHeight;
  camera.updateProjectionMatrix();
});
renderer.setAnimationLoop(() => { controls.update(); renderer.render(scene, camera); });
</script>
</body></html>

With a bundler such as Vite there is no import map: npm install three and the same two import lines, with three/addons/ resolved through the package's exports. Keep one version: an addon from one release next to a core from another is the setup behind Multiple instances of Three.js being imported. A page that must stay on script tags can pin three@0.147.0 for both files, which keeps every API change since then out of reach; moving to modules is the lasting fix. paprika3d does not rewrite the page: open names the lines, and the agent edits them.

Where people ask about it

These threads quote the message or ask the question; each count was read on 2026-10-28.