# 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.

Source: https://docs.astra-void.xyz/loom/guides/nextjs-integration/

Next.js owns its bundler and its dev server, so neither of loom's other entry points fits: the
[Vite plugin](https://docs.astra-void.xyz/loom/guides/vite-integration.md) 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`](https://docs.astra-void.xyz/loom/guides/static-builds-and-embedding.md#a-working-embed-pipeline) 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:

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

```text
 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

```bash
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.

> **If you prune devDependencies in production**
>
> `next start` evaluates `next.config` too, and the config imports `loom-dev/next` — so `loom-dev`
> must be resolvable wherever `next start` runs. If your deploy prunes devDependencies, list
> `loom-dev` under `dependencies` instead. The heavy part stays cheap either way: Vite is loaded
> lazily, only when the dev server boots or the build-time gallery is emitted — never under
> `next start`.

## 2. The config

```ts title="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.

> **Compose it outermost**
>
> The wrapper accepts *your* config as an object or a function, but what it returns is always the
> function form — object-shaped wrappers can't wrap that. So when composing, `withLoomGallery` goes
> on the outside: `withLoomGallery(withMDX(config), options)`, not the other way around.

## 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`](https://docs.astra-void.xyz/loom/guides/static-builds-and-embedding.md) 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:

```text title=".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

| 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](https://docs.astra-void.xyz/loom/guides/package-compatibility.md#shims). |
| `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](https://docs.astra-void.xyz/loom/guides/static-builds-and-embedding.md#the-url-contract) — `target`, `chrome`,
`theme`, `background`, `base` — is identical against the proxied dev gallery and the static build, so
an iframe written once works in both:

```tsx
<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](https://docs.astra-void.xyz/loom/guides/static-builds-and-embedding.md#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:

```ts title="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/`:

```mdx title="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:

```ts
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.

> **Dev upstream only**
>
> The server binds to `127.0.0.1` on purpose. It is 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** —
  `/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-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 |
