Why This Guide Exists
If you read the Publisher walkthrough, you ended that piece with a published scene URL and an iframe snippet. The iframe is the fastest path to "live somewhere on the internet," and for marketing sites it is often the right answer.
But most of the people who reach for Vectreal next are React developers who want the scene inside their own app — sharing layout, state, theming, and routing with the rest of the product. That is what @vctrl/viewer is for. It is the same renderer the Publisher itself uses, packaged as a React component you can import, configure, and ship.
This guide takes you from a fresh React project to a viewer rendering in production. Every snippet is verified against @vctrl/viewer version 0.17.0. The package is pre-1.0 and under active development, so we will flag what is stable and what may move.
What You'll Build
A functioning React component that loads a published Vectreal scene and renders it inside your application — orbit controls, environment lighting, and screenshot capture wired up. The pattern works in any React 18 or 19 app, including SSR frameworks like React Router v7, Next.js, and Remix.
If you prefer to skim the result first, the working example sits at the bottom of this post. Otherwise, start at the top and build it with us.
Prerequisites
A short, honest list:
| Requirement | Version | Notes |
|---|---|---|
| Node.js | 20 or newer | Required by the underlying tooling |
| React | 18 or 19 | Declared as a peer dependency |
| Three.js | 0.177.x | Declared as a peer dependency — install alongside @vctrl/viewer |
| A published Vectreal scene | — | If you don't have one yet, the Publisher walkthrough gets you there in a few minutes |
The viewer ships its own CSS bundle, which you import once. There is no Tailwind dependency, no global stylesheet collision risk, and no required theme provider — though the viewer respects a light/dark/system theme prop if you want it to follow yours.
Step 1 — Install
In any React project:
npm install @vctrl/viewer @vctrl/hooks three
# or
pnpm add @vctrl/viewer @vctrl/hooks three@vctrl/hooks is the companion package that handles model loading, caching, and the React context that lets the viewer pull a model without you wiring it through props. You can use the viewer without it — and we will show that pattern too — but for most apps it is the cleaner starting point.
Step 2 — The Smallest Possible Viewer
Here is the minimum working example. It renders a scene from a model you have loaded into the @vctrl/hooks context.
import { ModelProvider, useLoadModel } from '@vctrl/hooks/use-load-model'
import { VectrealViewer } from '@vctrl/viewer'
import '@vctrl/viewer/css'
function Scene() {
const { file } = useLoadModel()
return <VectrealViewer model={file?.model} />
}
export default function App() {
return (
<ModelProvider>
<Scene />
</ModelProvider>
)
}Three things are doing the work:
ModelProviderwraps the part of the tree where the viewer lives. It owns the loaded model and exposes loading state via context.useLoadModel()reads from that context. Pullfile.modelout of the returned object and pass it to themodelprop — there is no automatic bridge between the hooks context and the viewer.import '@vctrl/viewer/css'is required. The viewer renders a Three.js canvas inside a positioned container; without the stylesheet, layout will collapse and you will see nothing.
That's the whole core. Everything that follows is layered on top.
Step 3 — Load a Published Scene
The minimum example assumed a model was already in context. In production you will more often load a published scene by URL — exactly what the Publisher gave you when you clicked Publish.
@vctrl/hooks exposes a useLoadModel hook that takes a URL and returns the parsed scene plus loading state. Pass it the GLB file URL associated with your published scene, and the viewer renders as soon as the model resolves.
import { useEffect } from 'react'
import { ModelProvider, useLoadModel } from '@vctrl/hooks/use-load-model'
import { VectrealViewer } from '@vctrl/viewer'
import '@vctrl/viewer/css'
const SCENE_URL = 'https://your-cdn.example.com/path/to/scene.glb'
function Scene() {
const { loadModel, file } = useLoadModel()
useEffect(() => {
loadModel(SCENE_URL)
}, [loadModel])
return <VectrealViewer model={file?.model} />
}
export default function App() {
return (
<ModelProvider>
<Scene />
</ModelProvider>
)
}If you want to embed a Vectreal-hosted published scene (with API key authentication, allowlist enforcement, and the full preview wrapper) the iframe path documented in the Publisher walkthrough is purpose-built for that. The component path documented here is what you reach for when you control the model file directly — your own assets, your own CDN, your own loading strategy.
Step 4 — The SSR-Safe Pattern
If you are building with React Router v7 in framework mode, Next.js, Remix, or any other SSR setup, this is the section that prevents three days of "why does my hydration warning never go away."
Three.js touches window, document, and a real DOM canvas. None of those exist on the server. Rendering <VectrealViewer /> directly in a server-rendered route will throw on the server or hydrate inconsistently on the client.
The fix is to defer the viewer to a client-only boundary. In React Router v7 the cleanest pattern looks like this:
// app/components/viewer-client.tsx
import { lazy, Suspense } from 'react'
const ViewerImpl = lazy(() => import('./viewer-impl'))
export function ViewerClient(props: { sceneUrl: string }) {
if (typeof window === 'undefined') return null
return (
<Suspense fallback={<div className="aspect-video animate-pulse bg-muted" />}>
<ViewerImpl sceneUrl={props.sceneUrl} />
</Suspense>
)
}// app/components/viewer-impl.tsx
import { ModelProvider, useLoadModel } from '@vctrl/hooks/use-load-model'
import { VectrealViewer } from '@vctrl/viewer'
import '@vctrl/viewer/css'
import { useEffect } from 'react'
function Scene({ sceneUrl }: { sceneUrl: string }) {
const { loadModel } = useLoadModel()
useEffect(() => {
loadModel(sceneUrl)
}, [loadModel, sceneUrl])
return <VectrealViewer />
}
export default function ViewerImpl({ sceneUrl }: { sceneUrl: string }) {
return (
<ModelProvider>
<Scene sceneUrl={sceneUrl} />
</ModelProvider>
)
}Why this works:
- The route imports
ViewerClient, which is safe to render anywhere — server or client. ViewerClientreturnsnullduring SSR, so no Three.js code touches the server bundle.- On the client,
lazy(() => import(...))creates a separate chunk that only downloads when the viewer is actually rendered. - The Suspense fallback preserves layout while the chunk loads, eliminating layout shift.
This pattern is what the Vectreal Platform itself uses internally to render the Publisher inside an SSR app. It is battle-tested at production scale.
Step 5 — The Five Props You'll Actually Use
<VectrealViewer /> has a deep configuration surface. Most of it you will leave at defaults. These five are what you'll reach for in real apps.
model
The Three.js scene to display, when you are not using the ModelProvider context. Pass an Object3D directly.
<VectrealViewer model={myObject3D} />theme
'light' | 'dark' | 'system'. Defaults to 'system'. Use this to make the viewer's chrome (loading state, control hints) follow your app's theme rather than the OS preference.
<VectrealViewer theme="dark" />cameraOptions
Define one or more named cameras. The viewer animates between them when you switch. For a single fixed starting view:
<VectrealViewer
cameraOptions={{
cameras: [
{
cameraId: 'default',
name: 'Default',
initial: true,
shouldAnimate: true,
animationConfig: { duration: 900 },
position: [0, 5, 8],
fov: 55
}
]
}}
/>This is the same surface a future post in this series will expand on — a dedicated camera-presets guide is on the way.
controlsOptions
Wraps Drei's OrbitControls. The most useful additions are auto-rotate behavior and the controlsTimeout value, which is a delay in milliseconds after the component mounts before OrbitControls become interactive. Set it to give the initial camera animation time to settle before the user can take control.
<VectrealViewer
controlsOptions={{
autoRotate: true,
maxPolarAngle: Math.PI / 2,
controlsTimeout: 2000
}}
/>envOptions
The environment preset, intensity, and background behavior. The preset list comes from @vctrl/core and includes options like studio-key, outdoor-noon, and night-city.
<VectrealViewer
envOptions={{
preset: 'studio-key',
environmentResolution: '1k',
background: false,
environmentIntensity: 1
}}
/>Choose '1k' for fast loads and '4k' for hero pages where visual fidelity matters more than initial paint.
Step 6 — Production Checklist
Before you ship:
Lazy-load the viewer chunk. The SSR pattern above already does this via lazy(). If you are not using SSR, still wrap the viewer in lazy() so the Three.js bundle does not block your initial page paint.
Wrap in an error boundary. The viewer can fail to load a model — wrong URL, malformed file, network drop. An error boundary keeps a single bad scene from crashing the surrounding route.
import { ErrorBoundary } from 'react-error-boundary'
<ErrorBoundary fallback={<div>Couldn't load this scene.</div>}>
<ViewerClient sceneUrl={SCENE_URL} />
</ErrorBoundary>Provide a fallback for enableViewportRendering. The viewer pauses rendering when scrolled out of view, which is the right default. If your viewer is never visible (e.g., inside a hidden tab) it will not waste GPU cycles. No action needed — just know this is happening.
Set loadingThumbnail for hero placements. Pass a low-resolution preview image so the viewer container does not look empty during the model fetch. The viewer blurs and crossfades it as the real scene loads.
<VectrealViewer
loadingThumbnail={{
src: '/scenes/hero-thumb.webp',
alt: 'Loading interactive 3D scene'
}}
/>Confirm AGPL-3.0 compatibility for your project. @vctrl/viewer is licensed under AGPL-3.0-only. If you are building a closed-source web service, review the implications with your legal team before shipping. If you are building open-source, you're already aligned.
The Whole Working Example
Pulling it all together — copy this file, swap the scene URL, and you have a production-ready viewer.
// app/components/viewer-client.tsx
import { lazy, Suspense } from 'react'
import { ErrorBoundary } from 'react-error-boundary'
const ViewerImpl = lazy(() => import('./viewer-impl'))
export function ViewerClient({ sceneUrl }: { sceneUrl: string }) {
if (typeof window === 'undefined') return null
return (
<ErrorBoundary fallback={<div>Couldn't load this scene.</div>}>
<Suspense fallback={<div className="aspect-video animate-pulse bg-muted" />}>
<ViewerImpl sceneUrl={sceneUrl} />
</Suspense>
</ErrorBoundary>
)
}// app/components/viewer-impl.tsx
import { useEffect } from 'react'
import { ModelProvider, useLoadModel } from '@vctrl/hooks/use-load-model'
import { VectrealViewer } from '@vctrl/viewer'
import '@vctrl/viewer/css'
function Scene({ sceneUrl }: { sceneUrl: string }) {
const { loadModel, file } = useLoadModel()
useEffect(() => {
loadModel(sceneUrl)
}, [loadModel, sceneUrl])
return (
<VectrealViewer
model={file?.model}
theme="system"
envOptions={{ preset: 'studio-key', environmentResolution: '1k' }}
controlsOptions={{ autoRotate: true, controlsTimeout: 2000 }}
cameraOptions={{
cameras: [
{
cameraId: 'default',
name: 'Default',
initial: true,
shouldAnimate: true,
animationConfig: { duration: 900 },
position: [0, 5, 8],
fov: 55
}
]
}}
loadingThumbnail={{ src: '/scenes/hero-thumb.webp', alt: 'Loading 3D scene' }}
/>
)
}
export default function ViewerImpl({ sceneUrl }: { sceneUrl: string }) {
return (
<ModelProvider>
<Scene sceneUrl={sceneUrl} />
</ModelProvider>
)
}Compatibility and Caveats
A short, honest list of what you should know before shipping:
| Topic | Status |
|---|---|
| React 18 / 19 | Both supported |
| Three.js peer dep | ^0.177.0 — install alongside @vctrl/viewer |
| SSR | Supported via the client-only pattern shown above |
| WebXR / AR | Three.js supports WebXR, but a first-class viewer prop is on the roadmap, not yet shipped |
| Grid configuration | Typed but not currently active in the render flow |
| Custom HDR files | Supported via envOptions.files |
| Screenshot capture | Supported via onScreenshot and onScreenshotCaptureReady callbacks |
| Versioning | Pre-1.0 — minor versions may include breaking changes; pin in production |
If you hit a behavior that is not documented or that surprises you, the source is on GitHub — issues are open and the maintainers respond.
What to Do Next
- Install it now:
npm install @vctrl/viewer @vctrl/hooks three. The minimum viewer in Step 2 is twelve lines. - Publish your first scene: if you don't have one yet, the Publisher walkthrough takes about ten minutes.
- Coming next in the series: API key scopes — when to use a personal key, when to use organization scope, and how the allowlist fits with the React integration you just built.
- Camera presets and visual quality: the
cameraOptionsandenvOptionssurface deserves its own deep dive, and a dedicated post is on the way. - Contribute or ask questions: the full source is on GitHub. Live conversation happens on Discord.
Connect with the community: vectreal.com · Discord · GitHub · npm · Reddit · Stack Overflow · X · Product Hunt