Headless Integration Walkthrough

Build a configurator that lives on your own page. Simplio3D’s renderer draws the product exactly as it looks in Preview; every control around it is your markup, in your framework. No server, no build step, and no credential you have to keep secret.

Not sure this is the right route yet?Building a Custom UI compares the four ways to put a configurator on a custom page — including the ?ui=viewer embed, which keeps all of your project settings and the floating tools but cannot yet be driven by your own buttons. This page picks up where that one ends: you have chosen the package and want to ship it.

How much code this actually takes

The integration is one loop. Everything past it is styling — the same work you would do for any other component on your site.

5 lines

The product renders on your page with the project’s own lighting, camera and materials, and its authored default options applied. No controls yet.

~30 lines

A working configurator: buttons generated from the project’s own option blocks, conditional logic respected, the 3D updating on every click.

The rest

CSS, swatch thumbnails, grouping, pressed states, a mobile breakpoint, error handling. None of it touches the viewer API.

Before you start

You need two values, both from your project’s Share tab: the project ID (also in the editor URL) and the share token. Turn sharing on first. Both are public by design — they already appear in every share link you have sent. See Building a Custom UI for why an API token must never be used here instead.

You also need a static file server. That is the whole toolchain.

Step 1 — Get the viewer bundle

The package is not on npm yet. npm install @simplio3d/viewer and both CDN paths return 404 today. Do not spend time debugging that — it is not your setup. Until it ships, build the bundle and vendor it into your project; the import line is the only thing that changes afterwards.

npm run build --workspace @simplio3d/viewer mkdir -p vendor cp packages/viewer/dist/simplio3d-viewer.standalone.js vendor/

There are two builds, and picking the wrong one is a silent failure rather than an error:

BuildFilethree.jsChoose it when
Standalonedist/simplio3d-viewer.standalone.jsBundled (1.1 MB raw, ~268 kB gzipped)A plain HTML page with no bundler. three ships no global build, so a bare import of "three" cannot resolve on its own.
ESMdist/index.jsExternal (67 kB raw, ~17 kB gzipped)Your page already loads three, or you use a bundler. With no bundler, pair it with an import map.
Never end up with two copies of threeIf your page already loads three and you also use the standalone build, you get two instances: instanceof checks fail across the boundary, THREE.Cache splits, and disposal leaks. None of it throws. Use the ESM build plus an import map in that case.

Step 2 — Render the product

Note there is no API URL to configure: the production endpoint is compiled into the package. Pass apiUrl only if you are pointing at a staging or self-hosted deployment, and sharePassword only if the share link is password-protected.

<div id="stage" style="height: 70vh"></div> <script type="module"> import { createViewer } from './vendor/simplio3d-viewer.standalone.js'; await createViewer({ el: '#stage', projectId: 'YOUR_PROJECT_ID', shareToken: 'YOUR_SHARE_TOKEN' }); </script>

Step 3 — Build your controls

Read the project’s own option blocks and turn them into whatever markup you like. This is the entire integration — the three lines that matter are marked.

<div id="stage" style="height: 70vh"></div> <div id="ui"></div> <script type="module"> import { createViewer } from './vendor/simplio3d-viewer.standalone.js'; const viewer = await createViewer({ el: '#stage', projectId: 'YOUR_PROJECT_ID', shareToken: 'YOUR_SHARE_TOKEN', }); const ui = document.getElementById('ui'); function render() { ui.innerHTML = ''; const state = viewer.getState(); // 1. Authored order. Never sort this. for (const block of viewer.blocks) { // 2. Conditional logic already ran. Respect it. if (viewer.visibility[block.id] === false) continue; for (const variant of block.dropdownVariants ?? []) { const button = document.createElement('button'); button.type = 'button'; button.textContent = variant.label ?? variant.value; button.setAttribute('aria-pressed', String(state[block.id] === variant.value)); // 3. One call drives the 3D. button.onclick = () => viewer.select(block.id, variant.value); ui.append(button); } } } render(); viewer.on('applied', render); // a selection landed, or a rule showed/hid a block </script>

Re-read getState() inside applied rather than tracking selections yourself. When a rule reveals a block, its default option is applied automatically — with no click and no user action — so any state you kept in parallel drifts.

Step 4 — Serve it over HTTP

python3 -m http.server 5173 # then open http://localhost:5173
Do not open the file by double-clicking itOn file:// the browser blocks ES module imports and fetch. The page loads, sits on a blank stage forever, and gives you no useful reason why. This is the most common way to conclude the integration is broken when it is fine. Any static server works.

Step 5 — Check the payload if something looks wrong

The endpoint behind all of this needs no credential, so you can call it directly and see exactly what the viewer sees:

curl -i "https://YOUR-PROJECT-REF.supabase.co/functions/v1/make-server-0532dd87/share/PROJECT_ID/SHARE_TOKEN"
StatusWhat it meansWhat to do
200Sharing is on and the token matches.Nothing — the payload is in the body.
401The share link is password-protected.Pass sharePassword to createViewer.
402The project owner's subscription has lapsed. Every share link and embed is offline at the same time.Show a neutral "unavailable" message. Do not retry, and never surface billing detail to a shopper.
403The token does not match this project (or a domain restriction rejected the request).Re-copy the share token from the Share tab.
404Sharing is switched off for the project, or the project no longer exists.Enable sharing in the Share tab.
429Rate limited — 120 share loads per 5 minutes per IP.Back off. There is no Retry-After header to read.

A wrong token is 403, not 404 — that distinction saves a lot of time. 404 means the project is not shared at all.

The API you will actually use

Reading the project

PropertyWhat it gives you
viewer.blocksThe option blocks in authored order. Build your UI from this and never sort it — array order is render order, and the stored order value is not reliably unique on older projects.
viewer.visibilityA block-id to boolean map, after conditional logic has been evaluated. Use this, not block.visible.
viewer.getState()A copy of the current selections, keyed by block id.
viewer.projectThe normalised project — models, camera, projectSettings, materialDictionary. The only route to the settings.
viewer.pricingBlocks / viewer.formFieldsThe pricing and form definitions, passed through untouched, if you want to build those surfaces yourself.

blocks, pricingBlocks, formFields and project are live references, not copies — mutating them mutates the viewer. Only visibility and getState() hand you a copy.

Driving it

MethodUse
viewer.select(blockId, value)One selection, from a click.
viewer.setState(state)Replaces the whole selection map in one atomic apply. Use it to restore a saved configuration — see the traps below, it replaces rather than merges.
viewer.reset()Back to the project's authored defaults.
viewer.camera.reset() / .frame() / .set()Restore the saved camera, re-frame the product, or place the camera absolutely.
viewer.snapshot()A high-resolution, transparent PNG as a Blob. Useful for attaching the configuration to your own quote.
viewer.dispose()Releases the WebGL context. Call it when your component unmounts.
viewer.threeThe live scene, camera, renderer, controls, and a name-to-meshes index — for a floor, your own lights, or post-processing, without forking the package.

Selection shapes

Block typeShapeExample
dropdown, select-material, thumbnail-selector, toggle-switch, carouselThe variant's value (a string)'walnut'
checkboxAn array of the chosen values['armrests', 'headrest']
number-inputA map keyed by numeral-variant id{ width: 120, height: 40 }

A number-input is a map, not a number: one block can expose several numeric parameters, and a conditional rule targets one of them specifically. Set it through setStateselect() cannot express a map.

Events

EventWhen it fires
ready, progressDuring load — before createViewer resolves. Only reachable through the onReady / onProgress options.
appliedA selection landed, or a rule showed or hid a block. Rebuild your controls here.
option.changedA selection changed, including on block types that paint nothing.
visibility.changedA block became visible again. It never fires on the first apply.
errorA model or texture failed to load. The payload is a plain Error.

Handling failure properly

try { const viewer = await createViewer({ el: '#stage', projectId, shareToken, onProgress: ({ loaded, total }) => showBar(loaded, total), onError: (error) => console.warn('asset failed', error.message), }); } catch (error) { status.textContent = error?.isUnavailable ? 'This configurator is currently unavailable.' : 'Could not load the configurator: ' + error.message; }

isUnavailable is true only for the 402 case. Treat it as a neutral, permanent-for-now state: no retry loop, and no billing detail on the page — that is the merchant’s private business state, not the shopper’s.

Traps that fail silently

Each of these produces no error. The page simply looks wrong — or looks right and is wrong.

1. Never feed the payload’s renderQuality into quality

quality defaults to 'high'. Leave it there. The payload’s renderQuality is the editor viewport’s setting, and both the Share view and the Preview modal ignore it in favour of high. Passing it through switches off filmic tone mapping and disables shadows entirely, changing the whole look of the product relative to what was approved in the dashboard.

2. setState replaces, it does not merge

It clears the map first, so setState({ blockA: 'x' }) drops every other block’s selection. To change one block, spread the current state in: setState({ ...viewer.getState(), blockA: 'x' }). Do still use it — and not a loop of select() calls — when restoring a saved configuration: each select re-applies materials, and where two blocks target the same mesh the last one applied wins, so a loop is order-dependent.

3. A hidden block can still paint

A fixed-material block marked not visible gates the UI, not the material — it is a normal way to paint a fixed part of the product, such as metal legs or hardware. Skip hidden blocks when drawing controls; never skip them when reasoning about what is being rendered.

4. Load-time events fire before you can subscribe

progress, model-load errors, the first applied and ready all fire before the promise resolves, so viewer.on() can never observe them — there is no viewer to subscribe to yet. A loading bar wired to viewer.on('progress') never moves. Pass the onProgress, onReady, onApplied and onError options instead.

5. Variant values are only unique inside their block

Two blocks routinely both use material-1, option-1 or on/off, because values are minted per block. Key your own UI state by block id. Keying by variant value alone cross-wires two controls that look unrelated.

6. A colour alone is not a usable swatch

Texture-driven materials carry a neutral base colour, so a colour-chip UI renders several indistinguishable squares. Use the platform’s own fallback chain, in order: variant.thumbnailUrl (the merchant’s uploaded thumbnail — do not skip this rung) → variant.materialData.textureMap → a flat colour → variant.materialData.thumbnail → a neutral placeholder. Cache broken URLs so a dead link falls through to the next rung.

7. Call dispose, or the page eventually stops rendering

Browsers allow only a limited number of live WebGL contexts (commonly around sixteen, and it varies). Call viewer.dispose() when your component unmounts — releasing the renderer alone is not enough to free the GPU context. Skip it and repeated mount/unmount cycles exhaust the pool, after which a canvas on the page goes blank with no error.

8. Select Material blocks in “From Category” mode ship no variants

A Select Material block authored in From Category mode stores an empty variant list — the hosted viewer fetches the category’s materials separately at runtime. The package does not make that request, so such a block yields no buttons and paints nothing. Use Manual mode for a headless build, or fetch /share/:projectId/:token/category-materials yourself and build those controls by hand.

Verify in a browser, not by reading codeWebGL failures are silent — a material that never applies, a texture that 404s, a mesh the selector missed. None of them throw. After any change to the 3D, load the page and look at it.

Going further

Showing a price

There is no anonymous pricing endpoint: the calculate route requires a credential that must never reach a browser, and browsers cannot send it anyway. Your options are to evaluate viewer.pricingBlocks and viewer.pricingFormula in the browser (both ship in the payload — this is what the hosted share view does), or to proxy the calculation through your own backend. Either way, treat a displayed price as an estimate: the server recomputes the total at checkout and that figure is the authoritative one. See API → Pricing & CPQ.

Capturing a lead

Post to /share/:projectId/:token/submit — anonymous, returns a requestId, and triggers the owner’s email, PDF and webhooks. Attach viewer.snapshot() as the screenshot. Submissions arrive in Requests.

Do not use the quote-submissions endpoint for thisIt answers 200 with a full submission object, but the row never reaches the Requests inbox and triggers no email, PDF or webhook. The submit path above is the one that works.

Saved and shareable configurations

On Enterprise, the save-config and saved-configuration endpoints are anonymous too, so a shopper can email themselves a permalink. Restore it with viewer.setState(). Note the permalink the server generates is derived from the calling page’s own origin, so on a headless site you will usually want to build the link yourself from the returned id.

Checkout

Anonymous routes exist for Shopify cart permalinks, SKU-matched multi-line carts and WooCommerce checkout URLs, so a custom UI can hand off to a real basket. See Shopify and WooCommerce.

What the package deliberately does not do

It appends a canvas and nothing else. There is no UI and no viewport chrome — no floating tools, watermark, brand logo, custom loading animation or dimension overlay. It reads about sixteen of the project’s ~168 settings (lighting, exposure, camera, background, auto-rotate, shadows, zoom and pan limits), and a newly added platform setting does not reach it automatically. There is no pricing, no forms, no AR, and no modular drag-and-drop placement — modular projects render their base scene only. Selecting on a text-input, file-upload, design-canvas, pattern-designer or hotspot block stores the state and emits an event but paints nothing.

If you want the merchant’s complete experience instead, with every setting and the localised floating tools, the ?ui=viewer embed is the better trade — see Building a Custom UI.

Next steps

Every option, method and signature is in SDK → Headless Renderer. If you are considering writing your own renderer instead, API → 3D Viewer lists the rendering rules the payload does not describe — the reason this package exists. For a native app, see Mobile & Native Apps.

More in Integrations