Loomguides

Next.js integration

Serve the loom gallery from a Next.js app with withLoomGallery() — a live proxied gallery under next dev, an automatic static build under next build, and scene iframes in any page, Fumadocs included.

Next.js owns its bundler and its dev server, so neither of loom’s other entry points fits: the Vite plugin rewrites react and @rbxts/* for the whole config it lives in — dropped into Next’s webpack or Turbopack config it would hijack the app’s own React — and loom-dev/embed hands you a Connect middleware that next dev has no hook to mount. loom-dev/next closes the gap with the same isolation the embed API is built on: the gallery keeps a Vite instance of its own, and Next only ever forwards HTTP to it.

Everything below was verified end to end against two apps: Next 15.5 (pages router, webpack, React 18) and Next 16.2 (App Router, Turbopack, React 19) — the second running 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:

CommandPhaseGallery comes fromWhat the wrapper injects
next devphase-development-serverA loom Vite server on an ephemeral loopback port, booted lazilybeforeFiles rewrites proxying /loom-preview/* to it
next buildphase-production-buildFreshly emitted into public/loom-preview before the app compilesThe same afterFiles rewrite next start uses
next startThe static build sitting in public/loom-previewAn 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 (react pinned to 18, @rbxts/* → the loom adapters) live entirely inside the gallery’s Vite instance; Next’s own bundler config is untouched, which is why webpack and Turbopack behave identically — the integration sits at Next’s routing layer, not its bundler.

1. Dependencies

Terminal window
npm i -D loom-dev

withLoomGallery ships in loom-dev 0.4.0 and newer. Nothing else to install: your app keeps whatever React and Next versions it already has, and no @loom-dev/* packages enter its dependency tree.

2. The config

next.config.ts
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.

3. What each command does

next dev

The first time Next resolves its rewrites, the wrapper boots the gallery: the embed API’s middleware behind a loopback HTTP server on an OS-assigned free port. Booting inside the rewrites() call is deliberate — Next evaluates next.config in more than one process, but only the process that actually serves resolves rewrites, so exactly one Vite instance starts however many times the config file is loaded.

From there:

  • Target discovery, per-scene HMR, and the error panel all work exactly as under loom preview. The HMR WebSocket connects straight to Vite’s own port and never passes through Next.
  • The proxy rules are beforeFiles rewrites, which Next consults ahead of public/ — so a stale static gallery committed to public/loom-preview can never shadow the live one during development.
  • A gallery that fails to boot does not take next dev down. The error is logged (loom: gallery dev server failed to start), your own rewrites are kept, the proxy rules are skipped — /loom-preview 404s — and the boot is retried the next time Next asks for the rewrites (a config reload) rather than the failure being cached forever.

next build

The wrapper emits the static gallery into public/loom-preview — the identical bundle loom build produces, per-scene async chunks, wasm-opt-processed binary and all — and then lets the app build proceed.

Next re-evaluates the config in the worker processes it spawns during a build; an environment marker (LOOM_NEXT_GALLERY_BUILT, inherited by child processes) keeps them from rebuilding the gallery, so it is emitted exactly once per build. Two edge cases are handled asymmetrically, on purpose:

  • Zero targets warns and skips — adding the wrapper before writing your first *.loom.tsx must not brick next build.
  • A real gallery build error fails the app build, the same contract an Astro integration’s build hook has. A broken /loom-preview should not ship silently.

Add the output to .gitignore — it is a build artifact:

.gitignore
public/loom-preview/

For a CI that runs loom build itself (or a repo that commits the static gallery), 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 won’t do on its own is serve an index.html for a directory under public/, so the wrapper’s afterFiles rewrite maps the bare /loom-preview onto /loom-preview/index.html. Asset URLs inside the bundle are absolute under the mount path (the gallery is built with --base /loom-preview/), so deep links and iframes work identically to dev.

4. Options

OptionTypeDefaultNotes
rootstringrequiredProject dir whose targets are served. Resolved against the app dir — where next dev / next build run.
targetsstring | string[] | truetrueA glob, a directory, a list of either, or true for the default **/*.loom.tsx. Same semantics as loom preview --targets.
basestring/loom-preview/Public path the gallery is mounted under. Normalized to the /…/ shape; also decides the build output dir (public/<base>).
portnumberephemeralPin the dev gallery’s port instead of picking a free one per boot.
hmrPortnumber | falseephemeralVite’s HMR WebSocket port; false disables HMR (edits then need a frame reload).
staticBuildbooleantruefalse skips the next build-time gallery emit entirely.
shimsRecord<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.
assetsbooleantruefalse 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 contracttarget, chrome, theme, background, base — is identical against the proxied dev gallery and the static build, 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 discovery-relative path of the scene, chrome=none drops the sidebar for a single-scene embed, theme=light|dark seeds the stage and background= overrides just its backdrop — 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:

next.config.ts
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/:

content/docs/components/card.mdx
## 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 (proxy.ts) only rewrites /docs paths, so it never collides with the gallery mount. Verified against Fumadocs 16 on Next 16 — App Router, Turbopack dev, React 19 in the host while the gallery runs its own React 18, which is the isolation doing its job.

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 rather than mount a middleware:

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 handleMeaning
originhttp://127.0.0.1:<port> — the proxy destination prefix.
portThe bound port: the requested one, or the free pick.
baseThe 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.

Routing details worth knowing

  • The bare mount path is rewritten straight to the slashed upstream URL/loom-preview → http://127.0.0.1:<port>/loom-preview/ — 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, and 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 (afterFiles); a { beforeFiles, afterFiles, fallback } object is merged with loom’s rules layered in front of the matching group and fallback untouched.
  • next build resolves rewrites too (they are baked into the routes manifest) — that is how the static-serve rule gets into a production deployment without the wrapper doing anything at next start time.

Known limitations

Measured, not guessed — each of these was tested against the harness apps:

  • trailingSlash: true is not supported. The shell itself loads (/loom-preview/ resolves), but Next 308-redirects extensionless paths to their slashed form before the rewrites run, and the gallery’s module URLs (/@vite/client, /@id/…) are extensionless — they arrive at the Vite server with a trailing slash appended and miss.
  • basePath is not supported. The proxy routes correctly under the prefix (/site/loom-preview answers), 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 without basePath, or serve it from its own origin via startGalleryServer.
  • output: "export" has no rewrites at runtime, so the bare-path mapping does not apply — the static gallery still lands in the export (public/ is copied into out/) and works at its full path, /loom-preview/index.html.
  • output: "standalone" does not copy public/ — that is Next’s own documented behavior, and the auto-built gallery lives in public/. Copy it into the standalone output alongside your other public assets.

What “verified” means here

next-demofumadocs-demo
Next / bundler15.5, webpack, pages router16.2, Turbopack dev, App Router
Host React18.319.2 (gallery: its own 18.3)
next devproxied shell, scene render (WASM layout), per-scene HMR without reloadsame, embedded in a Fumadocs MDX page alongside proxy.ts
next buildgallery emitted once into public/loom-previewsame, with 9 build workers re-evaluating the config
next startbare path, shell and assets all 200same, docs and gallery side by side