Article / Three.js

Making a Three.js Portfolio Indexable: SEO for WebGL Sites

Canvas-first sites are invisible to most crawlers. A practical walkthrough of the build-time prerendering setup that makes a fully WebGL portfolio readable to Google, Bing, and AI crawlers, without giving up any of the creative side.

My portfolio is one canvas element. Particles, ribbons, shader transitions, all of it drawn by WebGL, with the copy injected by JavaScript once a preloader finishes. I love it. Crawlers do not.

If you build immersive websites, yours probably looks similar under the hood. Which means the people searching for someone who does exactly what you do may never find you, because the page you're proudest of reads as empty to most of the machines deciding what to show them. This post is about how I fixed that on this site without changing anything about how it feels.

What a crawler actually sees

Open your site and view the page source. Not the element inspector, the actual source. On a typical Vite or webpack single page app you'll find a title, a script tag, and a div with nothing in it. That's the whole document. Every heading, every case study, every word about what you do arrives later, after your bundle runs.

curl -s https://your-site.com | grep "<h1>"
# nothing? that's what most crawlers see

A human with a browser never notices any of this. A crawler that skips JavaScript sees a blank page with a nice favicon.

Google renders JavaScript, with an asterisk

Google is the good news. Googlebot has run an evergreen build of Chromium since 2019, so modern syntax is fine and your bundle will execute. The catch is when. Indexing happens in two waves: the raw HTML gets processed right away, while JavaScript rendering goes into a queue. Google doesn't publish numbers for that queue, and in practice it ranges from hours to weeks, with small and new domains waiting the longest. A personal portfolio is pretty much the definition of a small, new domain.

So relying on JS rendering doesn't mean your content gets ignored. It means it gets indexed late, refreshed less often, and depends on a rendering pipeline you have zero visibility into. If your bundle throws in their environment or times out behind a slow asset, you might simply never appear, and nothing will tell you why.

The newer crawlers don't run your code at all

The bigger shift is everything that is not Google. GPTBot, ClaudeBot and PerplexityBot read raw HTML only. No rendering, no waiting for a preloader, no canvas. The same goes for the bots that build link previews on LinkedIn or Slack. Vercel measured this across their network and the conclusion was blunt: AI crawlers fetch enormous numbers of pages and execute none of the JavaScript on them.

"Who can build a 3D product configurator for us" is exactly the kind of question people now type into a chatbot instead of Google. If your case studies only exist after hydration, you are not part of that answer. For working creative developers I would argue this already matters more than classic rankings.

Treat static HTML as another render target

My fix was not a framework migration. The site stays a plain Vite single page app with vanilla Three.js. I just stopped treating the built index.html as the only document, and added a small Node script that runs after the build:

npm run build
  1. tsc            typecheck
  2. vite build     dist/index.html + bundles
  3. generate-seo   one real HTML file per route

dist/
  index.html
  projects/index.html
  projects/void-atlas/index.html
  blog/making-a-threejs-portfolio-indexable/index.html
  sitemap.xml
  robots.txt

The script imports the same content files the app renders from. All my copy lives in typed TypeScript objects, there is no CMS, so the projects, sections and posts are just imports away. For every route it writes a complete document: real headings, real paragraphs, real anchor tags, plus a unique title, meta description, canonical URL and JSON-LD for that specific page. The sitemap and robots.txt come out of the same pass, so they can never drift out of sync with the actual routes.

When the WebGL experience boots, it wipes the container and renders its interactive version in its place. Crawlers and visitors get the same content from the same source of truth, which is why this is not cloaking. It is progressive enhancement, the boring old idea, applied to a canvas-first site.

The noscript detail that took me longest

One subtle problem nearly slipped through. The app hides the copy container behind a preloader class and reveals it when the experience is ready. Without JavaScript that class never clears, so my carefully prerendered content was sitting in the HTML while being invisible in an actual no-JS browser. The fix is almost embarrassing:

<noscript>
  <style>
    html, body { overflow: auto !important; height: auto !important; }
    .webgl { display: none !important; }
    .is-preloading .portfolio-copy { opacity: 1 !important; }
  </style>
</noscript>

If JavaScript runs, this block does nothing. If it does not, the canvas disappears, scrolling comes back, and the page becomes a plain readable document. Ten lines for an entire fallback experience.

Structured data is cheap authority

Every page also carries a JSON-LD graph. A Person node with my name variants and profile links, a WebSite node, and then whatever the page actually is: CreativeWork for case studies, BlogPosting for articles like this one, BreadcrumbList so the hierarchy is explicit. The nodes reference each other by @id, so a search engine can merge everything into one entity instead of guessing whether the person on the homepage and the author of this post are the same human.

None of that ranks you by itself. What it does is remove ambiguity, and ambiguity is the default state of a personal site where the same person shows up as Alex, Aleksandr and Alexander depending on the document.

The checklist

  • View source on every route and confirm the real copy is in the HTML, not only in the DOM after load.
  • One static file per route, each with a unique title, meta description and canonical URL.
  • Internal links as real anchor tags with href, even when JavaScript intercepts them for transitions.
  • A noscript path that actually renders content, not a "please enable JavaScript" apology.
  • JSON-LD wired into a single graph and checked with the Rich Results Test.
  • sitemap.xml and robots.txt generated by the build, never maintained by hand.
  • After deploying, inspect a few URLs in Search Console and read the rendered HTML yourself.

None of this touched the creative side. The particles still do their thing, the shaders still ship, the site feels exactly like it did before. The only difference is that a version of the work now exists that machines can read. If you have spent years getting good at the canvas, this is a weekend of work that makes all of it findable. Do the weekend.