Loomguides

Next.js integration

Serve the gallery from a Next.js app with `withLoomGallery()` — proxied in dev, static at build.

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:

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 start—The 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 live inside the gallery’s Vite instance, and Next’s bundler config is untouched. That is why webpack and Turbopack behave identically.

1. Dependencies

Terminal window
npm i -D loom-dev

Nothing else to install — your app keeps whatever React and Next versions it 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 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 beforeFiles rewrites, which Next consults ahead of public/, so a stale static gallery in public/loom-preview cannot shadow the live one.
  • A gallery that fails to boot does not take next dev down. The error is logged, your own rewrites are kept, and /loom-preview 404s. 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.tsx cannot brick next build.
  • A real gallery build error fails the app build, so a broken /loom-preview does 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, 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

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 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:

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 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 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, 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 of 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, 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 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, 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/client and /@id/… arrive at the Vite server with a trailing slash appended, and miss.
  • basePath is not supported. The proxy routes correctly under the prefix, so /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, since public/ is copied into out/, and works at its full path of /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