A 3D website can be fast and fully indexable if the 3D scene is treated as a layer on top of pre-rendered HTML, not as the page itself. On bitnoda.team, every route is pre-rendered to static HTML with vite-ssg, and three.js loads only after the page is visible, and only on devices that can run it. Measured on the production build on 29 September 2026, the home page’s first load needs about 92 KB of compressed JavaScript; the roughly 228 KB of 3D code comes later.
The problem with 3D websites
Many 3D websites draw all of their content into a canvas from JavaScript. On a powerful laptop that looks great. To a crawler, the page is almost empty: no headings, no text, no links.
That matters more now than a few years ago, because many AI assistants’ crawlers do not run JavaScript at all. What is not in the HTML does not exist for them. And on everyday phones, a site that downloads the whole 3D engine up front loads slowly and drains the battery.
Step 1: the content is HTML first
Every page of bitnoda.team is pre-rendered at build time with vite-ssg, in both languages: the title, the H1, the text, the links and the JSON-LD structured data are already in the HTML the server sends. The 3D scene carries no fact that is not also in the text.
On the server, the scene’s place is held by a poster made of CSS and SVG. The page is complete and useful even if the 3D never starts.
Step 2: the 3D loads last
The 3D code (three.js, the scene director, the model loader) is imported dynamically, and only after three conditions: the load event, the first paint and an idle moment in the browser. On phones we also wait for the visitor’s first real interaction, so nobody pays for three.js just to read.
One detail that is easy to miss: the bundler adds modulepreload hints for dynamically imported chunks too, so the browser would download them up front anyway. This site’s Vite config strips those preloads at build time for chunks that are only reached through a dynamic import.
Step 3: decide per device
A small module with no three.js inside estimates the device tier (high, medium, low) before anything heavy is downloaded. Low-tier devices stay on the static version. The others get a rendering resolution capped per tier, and the scene pauses when it scrolls out of view.
Because phones vary wildly, the scene director measures every frame and steps quality down when frames get slow, instead of stuttering. If the browser loses the WebGL context, the page falls back to the static version.
Step 4: one clock, short tasks
Smooth scrolling (Lenis), the scroll sync, the animation timeline, the characters and the rendering all run from one clock, the GSAP ticker, rather than from separate loops that step on each other.
Building the scene is split into pieces: between steps, the code hands control back to the browser (scheduler.yield where it exists). The goal is that no task runs past about 50 ms, the point at which browsers call it a “long task” and the page feels frozen.
The numbers
Measured on the bitnoda.team production build on 29 September 2026, with gzip compression:
- First load of the home page: 10 JavaScript files, about 92 KB compressed.
- 3D code kept out of the first load: the three.js chunk about 167 KB, the scene director about 48 KB and the glTF loader about 13 KB, roughly 228 KB in total.
- The character model: a meshopt-compressed glTF file of about 157 KB.
What this means for your project
We apply the same rules when we build 3D websites and 3D product configurators: content in HTML, 3D as a layer, per-device decisions and performance budgets tracked throughout the project. The stack is the one from our Nuxt and Vue development.