Vite integration covers the setup that works. This page covers the three ways a working setup stops working: importing the plugin the wrong way, pointing it at an unbuilt checkout, and sharing a Vite config with an app that has its own React.
Always import the plugin by its bare specifier
loomPreview() locates the modules it aliases — the @rbxts/react-roblox client shim, the
@rbxts/services shim, the globals installer, the React shim, the gallery shell — relative to its
own file, as ../src from the module that is executing. That is what lets it hand Vite absolute
paths that work whether loom is installed in your project, linked from a checkout, or pointed at a
completely different workspace by the CLI.
It also means the import specifier you write is load-bearing. Import
@loom-dev/preview/vite and nothing else:
// correctimport { loomPreview } from "@loom-dev/preview/vite";
// broken — do not do thisimport { loomPreview } from "../loom/packages/preview/src/vite.ts";A path import lets Vite’s config bundler inline the plugin into a temporary file inside your project, which moves the anchor. The config then loads fine and the failure surfaces much later, during the build, as something that looks unrelated:
[commonjs--resolver] Failed to resolve entry for package "@loom-dev/layout".The package may have incorrect main/module/exports specified in its package.json.Consuming an unbuilt source checkout
If you link: a loom checkout and have not run pnpm build:packages there, the package’s exports
point at dist/ files that do not exist yet, and Vite’s default config loader — which externalizes
the package and hands it to Node — cannot load the plugin at all:
Cannot find module '.../packages/preview/dist/vite.js'Building the checkout is the real fix:
pnpm build:packagesFailing that, vite --configLoader runner (and vite build --configLoader runner) processes the
config through Vite’s module runner instead of pre-bundling it, which can resolve TypeScript sources
directly. It is marked experimental in Vite’s CLI help but has been reliable across dev and build.
See Installation for what a checkout needs in the first place — it adds a Rust toolchain to the requirements.
Don’t merge this into an app that also uses React
loomPreview() aliases bare react, react/jsx-runtime and react/jsx-dev-runtime to loom’s own
React 18 copy, and sets esbuild.jsx: "automatic" globally. Those are not scoped to Roblox files —
they apply to every module Vite processes in that config.
So if you drop loomPreview() into the Vite config of an existing React web app, that app’s React
gets replaced with loom’s React 18 too. If the app is on React 19, it breaks. If it also runs
@vitejs/plugin-react, you now have two JSX transforms fighting.
Keep the preview in its own Vite project. Three arrangements work well:
- A dedicated preview app in your workspace (
apps/preview/) with its ownvite.config.tsand its own dependencies — avite.config.tsholding nothing butloomPreview()is enough. This is what loom’sapps/interactiveandapps/gallery-demoare. - No Vite project at all — use
loom previewandloom build, pointed at your source directory. The CLI runs withconfigFile: false, so it cannot collide with your project’s own Vite config even if one exists. - Mounted inside your app’s dev server, via
loom-dev/embed. The gallery still gets its own Vite instance — your app just forwards requests under a base path to it, so the two module graphs never meet.
The third is what these docs themselves use: an Astro integration mounts the gallery in dev and emits the static bundle at build, and each scene is iframed per preview. See Static builds and embedding.
loomPreview() in this site’s astro.config.mjs would hijack the docs’ own React 19. The gallery
runs in a separate Vite instance for exactly that reason.
Where to go when something breaks
Every error string above, plus the ones that come from the runtime rather than the config, is in Troubleshooting with the reasoning behind it.