Deploy a three.js site without a 404: open the built folder before you upload it

Why a three.js page that works on localhost shows nothing once deployed, a .glb or three.js itself answering 404, and how a coding agent checks the built folder first with the paprika3d plugin's open tool. Covers an import map into node_modules, a model under src/ named by a string, the public/ folder and Vite's base path on a subfolder host.

What the host has that your machine does not

A host serves the files you upload, from the address you upload them to, and nothing else. On your machine the dev server serves the whole project folder, node_modules included, from the root of localhost. Three differences between the two account for most "works locally, blank once deployed" threads:

Each one is visible before you upload. paprika3d is a Claude Code plugin and MCP server that opens a three.js page in its own headless Chrome; its open tool serves a folder (url, relative to cwd) from the root of a local server or runs a command, and its reply lists the asset files the page fetched (Loaded), each that failed with its status (Failed to load) and the console errors. Point it at the folder you are about to upload, served the way the host will serve it, and a 404 shows up in that reply instead of on the live site.

The guide "three.js glTF model failed to load?" covers reading a load failure itself (a wrong path, a missing DRACOLoader, a missing texture, and vite preview answering a missing model with index.html). This one is about the step between a working dev server and a working host.

Install

paprika3d needs Google Chrome and Node 20.11 or later, and the Claude Code plugin needs Claude Code 2.1.118 or later.

claude plugin marketplace add paprika3d/paprika3d
claude plugin install paprika3d@paprika3d
npm install three

A page with no build step

This page imports three through an import map into node_modules, as many three.js examples do. Its model is a small box GLB inlined as a data URL, so the one thing it fetches is three itself.

<!-- verify -->
<!doctype html>
<html><head><meta charset="utf-8"><title>Crate viewer</title>
<style>html,body{margin:0;overflow:hidden}canvas{display:block}</style></head>
<body>
<!-- three comes from node_modules: fine on your machine, a 404 on a host you upload this folder to without it -->
<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';

// A 1 × 1 × 1 box GLB, inlined so the page is one file and the only thing it fetches is three.
const MODEL = 'data:model/gltf-binary;base64,Z2xURgIAAAD0BQAAUAMAAEpTT057ImFzc2V0Ijp7ImdlbmVyYXRvciI6ImdsVEYtVHJhbnNmb3JtIHY0LjUuMSIsInZlcnNpb24iOiIyLjAifSwiYWNjZXNzb3JzIjpbeyJ0eXBlIjoiVkVDMyIsImNvbXBvbmVudFR5cGUiOjUxMjYsImNvdW50IjoyNCwibWF4IjpbMC41LDAuNSwwLjVdLCJtaW4iOlstMC41LC0wLjUsLTAuNV0sImJ1ZmZlclZpZXciOjAsImJ5dGVPZmZzZXQiOjB9LHsidHlwZSI6IlZFQzMiLCJjb21wb25lbnRUeXBlIjo1MTI2LCJjb3VudCI6MjQsImJ1ZmZlclZpZXciOjAsImJ5dGVPZmZzZXQiOjEyfSx7InR5cGUiOiJTQ0FMQVIiLCJjb21wb25lbnRUeXBlIjo1MTIzLCJjb3VudCI6MzYsImJ1ZmZlclZpZXciOjEsImJ5dGVPZmZzZXQiOjB9XSwiYnVmZmVyVmlld3MiOlt7ImJ1ZmZlciI6MCwiYnl0ZU9mZnNldCI6MCwiYnl0ZUxlbmd0aCI6NTc2LCJieXRlU3RyaWRlIjoyNCwidGFyZ2V0IjozNDk2Mn0seyJidWZmZXIiOjAsImJ5dGVPZmZzZXQiOjU3NiwiYnl0ZUxlbmd0aCI6NzIsInRhcmdldCI6MzQ5NjN9XSwiYnVmZmVycyI6W3siYnl0ZUxlbmd0aCI6NjQ4fV0sIm1hdGVyaWFscyI6W3sibmFtZSI6ImNsYXkiLCJwYnJNZXRhbGxpY1JvdWdobmVzcyI6eyJiYXNlQ29sb3JGYWN0b3IiOlswLjgsMC40NSwwLjMsMV0sInJvdWdobmVzc0ZhY3RvciI6MC42LCJtZXRhbGxpY0ZhY3RvciI6MH19XSwibWVzaGVzIjpbeyJuYW1lIjoiYm94IiwicHJpbWl0aXZlcyI6W3siYXR0cmlidXRlcyI6eyJQT1NJVElPTiI6MCwiTk9STUFMIjoxfSwibW9kZSI6NCwibWF0ZXJpYWwiOjAsImluZGljZXMiOjJ9XX1dLCJub2RlcyI6W3sibmFtZSI6ImJveCIsIm1lc2giOjB9XSwic2NlbmVzIjpbeyJub2RlcyI6WzBdfV19IIgCAABCSU4AAAAAPwAAAD8AAAA/AACAPwAAAAAAAAAAAAAAPwAAAD8AAAC/AACAPwAAAAAAAAAAAAAAPwAAAL8AAAA/AACAPwAAAAAAAAAAAAAAPwAAAL8AAAC/AACAPwAAAAAAAAAAAAAAvwAAAD8AAAC/AACAvwAAAAAAAAAAAAAAvwAAAD8AAAA/AACAvwAAAAAAAAAAAAAAvwAAAL8AAAC/AACAvwAAAAAAAAAAAAAAvwAAAL8AAAA/AACAvwAAAAAAAAAAAAAAvwAAAD8AAAC/AAAAAAAAgD8AAAAAAAAAPwAAAD8AAAC/AAAAAAAAgD8AAAAAAAAAvwAAAD8AAAA/AAAAAAAAgD8AAAAAAAAAPwAAAD8AAAA/AAAAAAAAgD8AAAAAAAAAvwAAAL8AAAA/AAAAAAAAgL8AAAAAAAAAPwAAAL8AAAA/AAAAAAAAgL8AAAAAAAAAvwAAAL8AAAC/AAAAAAAAgL8AAAAAAAAAPwAAAL8AAAC/AAAAAAAAgL8AAAAAAAAAvwAAAD8AAAA/AAAAAAAAAAAAAIA/AAAAPwAAAD8AAAA/AAAAAAAAAAAAAIA/AAAAvwAAAL8AAAA/AAAAAAAAAAAAAIA/AAAAPwAAAL8AAAA/AAAAAAAAAAAAAIA/AAAAPwAAAD8AAAC/AAAAAAAAAAAAAIC/AAAAvwAAAD8AAAC/AAAAAAAAAAAAAIC/AAAAPwAAAL8AAAC/AAAAAAAAAAAAAIC/AAAAvwAAAL8AAAC/AAAAAAAAAAAAAIC/AAACAAEAAgADAAEABAAGAAUABgAHAAUACAAKAAkACgALAAkADAAOAA0ADgAPAA0AEAASABEAEgATABEAFAAWABUAFgAXABUA';

const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(innerWidth, innerHeight);
document.body.appendChild(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x30343c);
const camera = new THREE.PerspectiveCamera(45, innerWidth / innerHeight, 0.1, 100);
camera.position.set(2, 1.6, 3);
camera.lookAt(0, 0, 0);
scene.add(new THREE.HemisphereLight(0xffffff, 0x444444, 2.5));

new GLTFLoader().load(MODEL, (gltf) => {
  gltf.scene.name = 'crate';   // a name, so the replies can point at it
  scene.add(gltf.scene);
}, undefined, (err) => console.error('crate did not load:', err));

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

The calls in this guide were made on 2026-10-28 with three.js 0.186 and Vite 7, on a Linux server with no GPU (Chrome drew in software, SwiftShader, which is what the GPU: line says; nothing here depends on it). Paths are shortened to <project>, and replies after the first are cut to the lines this guide discusses, with nothing reworded.

In the project folder, where node_modules is, the page works:

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

Opened http://127.0.0.1:34949/index.html in 0.2 s, paused at frame 0.
GPU: ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero) (0x0000C0DE)), SwiftShader driver). A software renderer (no GPU): frame times here say nothing of a real GPU's.
Page text outside the canvas: none
{"three":"186","scenes":1,"entities":["crate"]}

three.js left out of the upload

Copy the files you will upload into a folder of their own (here upload/, holding index.html alone, which is what a push of the repository with node_modules ignored sends) and open that folder:

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

game not ready after 10 s: no window.__game, three.js renderer or WebGL canvas on http://127.0.0.1:34949/index.html
Failed to load resource: the server responded with a status of 404 (Not Found) node_modules/three/build/three.module.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/jsm/loaders/GLTFLoader.js (run bun add three in cwd)

The page never made a renderer, because the module that would make it never arrived. The hint at the end of each line is for a project folder; for a folder you upload, the fix is one of these:

<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>
open {"url": "index.html", "cwd": "<project>/upload-cdn"}

Opened http://127.0.0.1:34949/index.html in 0.2 s, paused at frame 0.
Page text outside the canvas: none
{"three":"186","scenes":1,"entities":["crate"]}

A model the Vite build left out

In this Vite project the model is src/models/crate.glb, and src/main.js names it by a string. The rest of main.js is the scene of the page above.

// The model sits in src/models/ and is named by a string: the dev server serves it, vite build does not copy it.
new GLTFLoader().load('/src/models/crate.glb', (gltf) => {
  gltf.scene.name = 'crate';
  scene.add(gltf.scene);
}, undefined, (err) => console.error('crate did not load:', err));

open with a command runs the dev server, and the model loads:

open {"command": "./node_modules/.bin/vite --port 5401 --strictPort", "cwd": "<project>"}

Opened http://localhost:5401/ in 0.3 s, paused at frame 0.
Page text outside the canvas: none
{"three":"186","scenes":1,"entities":["crate"]}
Loaded: crate.glb 0.0 MB

After vite build, open dist/ as a host serves a site at its root, with cwd set to the built folder:

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

Opened http://127.0.0.1:39299/index.html in 0.4 s, paused at frame 0.
Page text outside the canvas: none
{"three":"186","scenes":1,"entities":[]}
Errors so far:
Failed to load resource: the server responded with a status of 404 (Not Found) src/models/crate.glb
crate did not load: ed: fetch for "http://127.0.0.1:39299/src/models/crate.glb" responded with 404: Not Found
Failed to load: crate.glb (404)

Failed to load names the file and entities is empty. (ed is three's HttpError class, renamed by the minifier.) dist/ has index.html and one script in assets/, and no model. The fix is to let the build see the file: new URL with import.meta.url is a pattern Vite rewrites at build time, copying the model into dist/assets/ under a hashed name.

// new URL(..., import.meta.url) tells vite build to copy the model into dist/assets/ and rewrite the URL to it.
const CRATE = new URL('./models/crate.glb', import.meta.url).href;
new GLTFLoader().load(CRATE, (gltf) => {
  gltf.scene.name = 'crate';
  scene.add(gltf.scene);
}, undefined, (err) => console.error('crate did not load:', err));
open {"url": "index.html", "cwd": "<project>/dist"}

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

import crateUrl from './models/crate.glb?url' does the same. In a Create React App project, a model under src/ named by a string does not load even on the dev server, which serves public/ alone; the fix is the same, import it or move it to public/.

A site in a subfolder: Vite's base

A GitHub Pages project site, or any host that serves the site under a path, puts index.html at /<repo>/. Opening dist/ from the project folder (url set to dist/) serves it the same way, at /dist/:

open {"url": "dist/", "cwd": "<project>"}

game not ready after 10 s: no window.__game, three.js renderer or WebGL canvas on http://127.0.0.1:39299/dist/
Failed to load resource: the server responded with a status of 404 (Not Found) http://127.0.0.1:39299/assets/index-BJguXUCv.js

The built index.html loads its script with src="/assets/index-BJguXUCv.js", from the host's root, so the whole app is missing, the model with it. Vite's base option sets the path the build writes; './' makes every URL relative to the page, which works at the root and in any folder ('/<repo>/' works too, for that one folder):

// vite.config.js
export default { base: './' };
// assetsInlineLimit is 4096 by default: a file smaller than that is inlined into the script as a data URL, not copied.
open {"url": "dist/", "cwd": "<project>"}

Opened http://127.0.0.1:39299/dist/ in 0.3 s, paused at frame 0.
Page text outside the canvas: none
{"three":"186","scenes":1,"entities":["crate"]}
Loaded: crate-BtTwrOnR.glb 0.0 MB

The public folder and an absolute path

The other usual fix for the left-out model is public/models/crate.glb, which Vite copies into dist/models/ as is. With base: './' set and the model named '/models/crate.glb', the subfolder open gives:

open {"url": "dist/", "cwd": "<project>"}

Opened http://127.0.0.1:39299/dist/ in 0.3 s, paused at frame 0.
Page text outside the canvas: none
{"three":"186","scenes":1,"entities":[]}
Errors so far:
Failed to load resource: the server responded with a status of 404 (Not Found) http://127.0.0.1:39299/models/crate.glb
crate did not load: ed: fetch for "http://127.0.0.1:39299/models/crate.glb" responded with 404: Not Found
Failed to load: crate.glb (404)

The file is in dist/models/, but a string in your code is not rewritten by base: a slash at the start still means the host's root. Build the URL from import.meta.env.BASE_URL, which is the base the build used:

// import.meta.env.BASE_URL is vite's base ('./' here), so the URL works from any folder.
new GLTFLoader().load(import.meta.env.BASE_URL + 'models/crate.glb', (gltf) => {
open {"url": "dist/", "cwd": "<project>"}

Opened http://127.0.0.1:39299/dist/ in 0.3 s, paused at frame 0.
Page text outside the canvas: none
{"three":"186","scenes":1,"entities":["crate"]}
Loaded: crate.glb 0.0 MB

look {}

On screen (1):
crate 9.9% [455, 237, 812, 602]
Never visible: none

What to read in the replies

What paprika3d does not do

Pitfalls

Without paprika3d

Run npx vite build && npx vite preview and watch the browser's network panel for red rows. A static server started inside dist/ (npx serve dist or python3 -m http.server) answers a missing file with a plain 404, closer to most static hosts than vite preview, which answers it with index.html. For the upload, git ls-files lists what a push sends, and three.js's own manual has an installation page that compares the import map, CDN and build setups.