Making a WebGL portfolio readable by search engines and AI agents
· 4 min read · seo, three.js, webgl, ai-agents, accessibility
My portfolio is a single WebGL scene. That is great for visitors with a GPU and invisible to almost everything else: the HTML my server sent for the home page was a <div id="root"></div> and a title tag. Every project description, my bio and every link only existed inside a JavaScript bundle and, on desktop, inside the canvas itself.
This post is about fixing that without giving up the 3D experience and without moving to another framework.
What crawlers and agents actually see #
A canvas fails three kinds of readers, each in its own way:
- Crawlers that don't run JavaScript. According to Vercel's analysis of AI crawler traffic, GPTBot, ClaudeBot and PerplexityBot download JavaScript files but don't execute them. They get the empty
<div>and nothing else. - Googlebot. It renders JavaScript, but Google's JavaScript SEO documentation recommends server-side or static rendering for pages that rely on features its renderer lacks, and a WebGL app is that kind of page. When the WebGL context could not be created, my React tree used to unmount and leave a blank page.
- Agentic browsers. They mostly read the accessibility tree, sometimes screenshots (Google's guide to agent-friendly sites describes the modes). A
<canvas>has no accessibility tree, so text drawn into it, like my project titles, simply is not there.
One URL, two layers #
The fix is not to make the canvas readable. It is to ship the same content as plain HTML at every URL and let the 3D app enhance it.
Each route now has its own HTML file, generated at build time. Next to the app root it carries a static text layer:
<body>
<div id="root"></div> <!-- the WebGL app -->
<div id="seo-layer"> <!-- real content: h1, text, links -->
<main>…</main>
</div>
</body>
The layer lives outside #root on purpose. React's createRoot replaces whatever is inside its container, and if WebGL fails the app unmounts, but the text must survive that.

Pre-rendering without a framework #
My content already lives in plain data modules (projects, bio), so the build script just imports them. It takes the file Vite produced as a template and fills two placeholders per route:
const html = template
.replace(HEAD_BLOCK, head) // title, description, canonical, JSON-LD
.replace(LAYER_BLOCK, layer); // the semantic text layer
await fs.writeFile(`dist/${route.file}`, html);
Only pure modules are imported; the app's state store touches localStorage at import time and can't run in Node. A verification step then fails the build when a page has a duplicate title, two <h1> elements, a relative asset URL, a broken internal link or JSON-LD that references something undeclared.
Hidden from sight, never from assistive tech #
When the app runs, the layer is visually hidden. When it can't (no JavaScript, no WebGL2, a crash), it becomes a normal readable page:
html.js:not(.no-webgl) #seo-layer {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
}
It is clipped, never display: none: hiding it from the accessibility tree would hide it from exactly the agents it is for. While a panel is open the panel is the accessible content, so the layer becomes inert to avoid reading everything twice.
Giving panels addresses #
The app is one scene with overlay panels (About, project list, project details). I wanted /projects/forge-awakens to open that panel, so I added a small History API router. The URL is a projection of the app state:
- opening a panel pushes an entry, closing it goes back to where you came from;
- the landing entry is replaced instead of pushed, because Back would leave the site;
- scrolling the carousel never creates history entries;
- a deep link to a featured project also scrolls the carousel to its card.
Plain text for agents #
Once every address is real HTML, a few cheap extras help the tools that read text instead of rendering pages:
- A Markdown twin of every page.
/projects/forge-awakens.mdis the same project as Markdown, starting with a link to its canonical page. The twins are markednoindex, so they never compete with the real page in search results. llms.txtandllms-full.txt. An index of the site, and the whole site in one file, in the llms.txt format.projects.json. Every project as structured data: name, tagline, year, technologies, description and links.- WebMCP tools. WebMCP is a draft browser API that lets a page register tools for an agent built into the browser. Mine are read-only (search the projects, read one, get my contact details). They live in a separate 2 KB chunk that only browsers exposing the API ever download.
Keep your expectations modest. Google says its search ignores llms.txt (Search Engine Land), and Ahrefs found that 97% of the files on 137,000 sites got no traffic in a month (Ahrefs). These files cost nothing at runtime, which is why they stay. What reaches crawlers and agents reliably is the HTML at every address.
What I checked #
Everything the site does, as described above, is covered by a test. Playwright drives Chromium with software WebGL to cover the four modes (no JavaScript, WebGL, no WebGL2, a failing texture), deep links, Back and Forward, that every asset resolves from nested URLs and that every link in llms.txt answers. Real Chrome only has WebMCP behind a flag or an origin trial, so the tools are tested against a stand-in that follows the spec. The static checks run on all pages on every build.
Takeaways #
- Crawlers and agents read HTML and the accessibility tree. Give them both for every URL.
- Pre-rendering a small semantic layer is cheaper than it sounds when your content is already data.
- Hide the layer visually, not semantically, and keep it outside the root your framework owns.