Next.js owns its bundler and its dev server, so neither of loom’s other entry points fits. The
Vite plugin would hijack the app’s own React, and
loom-dev/embed hands you a
Connect middleware next dev has no hook to mount. With loom-dev/next, the gallery keeps a Vite
instance of its own and Next only forwards HTTP to it.
Verified end to end against Next 15.5 (pages router, webpack, React 18) and Next 16.2 (App Router, Turbopack, React 19, Fumadocs 16).
How it fits together
One wrapper, three behaviors — withLoomGallery returns Next’s function config form
((phase, ctx) => config), and the phase Next passes in decides everything:
| Command | Phase | Gallery comes from | What the wrapper injects |
|---|---|---|---|
next dev | phase-development-server | A loom Vite server on an ephemeral loopback port, booted lazily | beforeFiles rewrites proxying /loom-preview/* to it |
next build | phase-production-build | Freshly emitted into public/loom-preview before the app compiles | The same afterFiles rewrite next start uses |
next start | — | The static build sitting in public/loom-preview | An afterFiles rewrite mapping /loom-preview onto its index.html |
In dev, two servers run — but you only ever start one:
browser ──► next dev (:3000) ── beforeFiles rewrite ──► loom Vite server (127.0.0.1:<ephemeral>) │ │ └────────────── HMR WebSocket (direct, bypasses Next) ◄──┘Your app’s React never meets the gallery’s. The loom aliases live inside the gallery’s Vite instance, and Next’s bundler config is untouched. That is why webpack and Turbopack behave identically.
1. Dependencies
npm i -D loom-devNothing else to install — your app keeps whatever React and Next versions it has, and no
@loom-dev/* packages enter its dependency tree.
next start evaluates next.config too, so loom-dev must be resolvable wherever it runs — list it under dependencies if your deploy prunes devDependencies. Vite is loaded lazily either way, never under next start.
2. The config
import { withLoomGallery } from "loom-dev/next";
export default withLoomGallery( { reactStrictMode: true }, // your Next config { root: "../my-ui", // the roblox-ts project, relative to the app dir targets: "src/scenes", // same semantics as loom preview --targets },);That is the entire setup — no prebuild script, no manual loom build, no second terminal.
The wrapper returns Next’s function form, which object-shaped wrappers cannot wrap. So withLoomGallery goes outermost: withLoomGallery(withMDX(config), options).
3. What each command does
next dev
The first time Next resolves its rewrites, the wrapper boots the gallery behind a loopback HTTP
server on a free port. Booting inside rewrites() is what keeps it to one Vite instance: Next
evaluates next.config in several processes, but only the serving one resolves rewrites.
From there:
- Target discovery, per-scene HMR and the error panel work exactly as under
loom preview. The HMR WebSocket connects straight to Vite’s own port. - The proxy rules are
beforeFilesrewrites, which Next consults ahead ofpublic/, so a stale static gallery inpublic/loom-previewcannot shadow the live one. - A gallery that fails to boot does not take
next devdown. The error is logged, your own rewrites are kept, and/loom-preview404s. The boot is retried on the next config reload.
next build
The wrapper emits the static gallery into public/loom-preview, the identical bundle
loom build produces, then lets the app build proceed.
An environment marker keeps the workers Next spawns from rebuilding it. Two edge cases are handled
asymmetrically:
- Zero targets warns and skips, so adding the wrapper before writing your first
*.loom.tsxcannot bricknext build. - A real gallery build error fails the app build, so a broken
/loom-previewdoes not ship silently.
Add the output to .gitignore — it is a build artifact:
public/loom-preview/For a CI that runs loom build itself, pass staticBuild: false and the build phase touches
nothing.
next start
Nothing boots and nothing is built — the app serves the static gallery out of public/. The one
piece Next will not do on its own is serve an index.html for a directory. The wrapper’s
afterFiles rewrite maps the bare /loom-preview onto its index.html. Asset URLs are absolute
under the mount path, so deep links and iframes work identically to dev.
4. Options
| Option | Type | Default | Notes |
|---|---|---|---|
root | string | required | Project dir whose targets are served. Resolved against the app dir — where next dev / next build run. |
targets | string | string[] | true | true | A glob, a directory, a list of either, or true for the default **/*.loom.tsx. Same semantics as loom preview --targets. |
base | string | /loom-preview/ | Public path the gallery is mounted under. Normalized to the /…/ shape; also decides the build output dir (public/<base>). |
port | number | ephemeral | Pin the dev gallery’s port instead of picking a free one per boot. |
hmrPort | number | false | ephemeral | Vite’s HMR WebSocket port; false disables HMR (edits then need a frame reload). |
staticBuild | boolean | true | false skips the next build-time gallery emit entirely. |
shims | Record<string, string> | {} | Package redirects for roblox-ts packages loom can’t run. Paths are relative to root, not to the Next app, so dev and the static build agree. See Package compatibility. |
assets | boolean | true | false stops the next build gallery downloading the rbxassetid:// images it mentions (0.7.1). The dev gallery is unaffected — it has a server to resolve ids with. |
5. Embedding scenes in pages
The URL contract is identical against
the proxied dev gallery and the static build. target, chrome, theme, background and base
all work the same, so an iframe written once works in both:
<iframe src="/loom-preview/?chrome=none&target=src/scenes/Card.loom.tsx" style={{ width: "100%", height: 360, border: 0 }}/>target takes the scene’s discovery-relative path, chrome=none drops the sidebar, and
live theme switching via
postMessage works through the proxy unchanged. Query strings pass through Next rewrites untouched,
and the iframe is just an iframe — App Router, pages router, RSC or client component, none of it
matters.
6. Fumadocs
A Fumadocs site is a Next.js app, so the same wrapper applies — composed around createMDX, loom
outermost:
import { createMDX } from "fumadocs-mdx/next";import { withLoomGallery } from "loom-dev/next";
const withMDX = createMDX();
export default withLoomGallery(withMDX({ reactStrictMode: true }), { root: "../my-ui", targets: "src/scenes",});The iframe from the previous section drops straight into any MDX page under content/docs/:
## Live preview
<iframe src="/loom-preview/?chrome=none&target=src/scenes/Card.loom.tsx" style={{ width: "100%", height: 360, border: 0 }}/>Fumadocs’ markdown-negotiation proxy only rewrites /docs paths, so it never collides with the
gallery mount. Verified with React 19 in the host while the gallery ran its own React 18.
7. When you need the server yourself
startGalleryServer is the piece withLoomGallery builds on — the embed middleware wrapped in a
loopback HTTP server. Reach for it from a custom server, or any host that can only forward requests
to a URL:
import { startGalleryServer } from "loom-dev/next";
const gallery = await startGalleryServer({ root: "../my-ui", targets: "src/scenes", // base?, port?, hmrPort? — same meanings as the wrapper's options});| Field on the handle | Meaning |
|---|---|
origin | http://127.0.0.1:<port> — the proxy destination prefix. |
port | The bound port: the requested one, or the free pick. |
base | The normalized mount path the gallery answers under. |
close() | Shuts down the HTTP wrapper and the underlying Vite server. |
Requests outside base answer 404, so the server is safe to point a prefix-preserving proxy at.
The server binds to 127.0.0.1 on purpose: a proxy upstream for local development, not something to expose. Production serves the static build.
Routing details worth knowing
- The bare mount path is rewritten straight to the slashed upstream URL, rather than through the catch-all. This is loop avoidance, not pedantry. Next strips trailing slashes with a 308, while the gallery middleware adds them with a 301. Routing the bare path through the catch-all would bounce those two redirects against each other forever.
- Your own
rewrites()are preserved, whichever shape they use. A flat array keeps Next’s own meaning ofafterFiles. A{ beforeFiles, afterFiles, fallback }object is merged, with loom’s rules layered in front of the matching group andfallbackuntouched. next buildresolves rewrites too, since they are baked into the routes manifest. That is how the static-serve rule gets into a production deployment without the wrapper doing anything atnext starttime.
Known limitations
Measured, not guessed — each of these was tested against the harness apps:
trailingSlash: trueis not supported. The shell itself loads, since/loom-preview/resolves. But Next 308-redirects extensionless paths to their slashed form before the rewrites run, and the gallery’s module URLs are extensionless./@vite/clientand/@id/…arrive at the Vite server with a trailing slash appended, and miss.basePathis not supported. The proxy routes correctly under the prefix, so/site/loom-previewanswers. But the gallery HTML references its assets by the un-prefixed absolute path,/loom-preview/@vite/client, which the app then 404s. Mount the gallery on an app withoutbasePath, or serve it from its own origin viastartGalleryServer.output: "export"has no rewrites at runtime, so the bare-path mapping does not apply. The static gallery still lands in the export, sincepublic/is copied intoout/, and works at its full path of/loom-preview/index.html.output: "standalone"does not copypublic/— that is Next’s own documented behavior, and the auto-built gallery lives inpublic/. Copy it into the standalone output alongside your other public assets.
What “verified” means here
| next-demo | fumadocs-demo | |
|---|---|---|
| Next / bundler | 15.5, webpack, pages router | 16.2, Turbopack dev, App Router |
| Host React | 18.3 | 19.2 (gallery: its own 18.3) |
next dev | proxied shell, scene render (WASM layout), per-scene HMR without reload | same, embedded in a Fumadocs MDX page alongside proxy.ts |
next build | gallery emitted once into public/loom-preview | same, with 9 build workers re-evaluating the config |
next start | bare path, shell and assets all 200 | same, docs and gallery side by side |