Skip to content

feat: keep "use client" off the builder so RSC can call it - #156

Merged
abernier merged 1 commit into
mainfrom
feat/server-safe-builder
Jul 31, 2026
Merged

feat: keep "use client" off the builder so RSC can call it#156
abernier merged 1 commit into
mainfrom
feat/server-safe-builder

Conversation

@abernier

Copy link
Copy Markdown
Owner

Stacked on #155.

dist/index.js carries a "use client" banner over the whole bundle, so every export is a client module — builder included. Calling it from a React Server Component throws:

Attempted to call builder() from the server but builder is on the client.
It's not possible to invoke a client function from the server, it can only be
rendered as a Component or passed to props of a Client Component.

Which means an app that wants the CSS in its server-rendered HTML can't just do builder(source).toCss() in a layout — it has to shell out to the CLI in a prebuild step. The banner is there for <Mcu>, and builder is caught in the blast radius.

The change

src/react.ts becomes the React entry — Mcu, useMcu, ExportButton — and is built as its own bundle, which is the only one that gets the banner. src/index.ts keeps ./react.js external so esbuild leaves the import alone instead of inlining the module and dropping its directive.

dist/index.js   33 KB, no directive   builder + re-export of ./react.js
dist/react.js  138 KB, "use client"   Mcu, useMcu, ExportButton
dist/cli.js                           unchanged

The public API doesn't move. builder, Mcu, useMcu and ExportButton are still all imported from material-theme-builder; no new subpath to learn, nothing to migrate. Importing Mcu into a server component keeps working too — index.js re-exports a module that carries the directive, so the framework resolves it to a client reference exactly as before.

Verified against a real Next build

Minimal App Router app, output: "export", builder() called at module scope in app/layout.tsx — i.e. a server component — and <Mcu> rendered from that same server component:

import { builder, Mcu } from "material-theme-builder";

const mcuCss = builder("#5de4c7", { scheme: "tonalSpot" }).toCss();

export default function RootLayout({ children }) {
  return (
    <html lang="en">
      <body>
        <style id="ssr-mcu" dangerouslySetInnerHTML={{ __html: mcuCss }} />
        <Mcu source="#5de4c7">{children}</Mcu>
      </body>
    </html>
  );
}
  • on main: Attempted to call builder() from the server, build fails at Collecting page data
  • on this branch: builds, exports, and both style tags come out of out/index.html populated — 19 398 chars each, --md-sys-color-primary and the .dark half present in both. (The #mcu-styles one being non-empty is fix: render <style> so the colors are in the server HTML #155; the #ssr-mcu one is this PR.)

pnpm run lgtm clean — attw still 🟢 on all four resolution modes, 40 tests pass.

README

A note under Programmatic API saying builder is server-safe, since that's the whole point.

🤖 Generated with Claude Code

@abernier
abernier changed the base branch from fix/ssr-style-tag to main July 31, 2026 11:25
The banner was applied to the whole `src/index.ts` bundle, making every
export a client module. `builder()` from a server component threw
`Attempted to call builder() from the server`, which left generating the
CSS at build time to the CLI.

The React surface moves to its own `src/react.ts` bundle, which is where
the banner lands; `dist/index.js` keeps `./react.js` external and
re-exports it, so esbuild leaves the directive alone instead of inlining
the module. The public API is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@abernier
abernier force-pushed the feat/server-safe-builder branch from 30b62ee to 067d655 Compare July 31, 2026 11:26
@abernier
abernier merged commit 74aec64 into main Jul 31, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant