Skip to content

[Bug]: @storyblok/react/rsc barrel pulls live-editing bridge + ProseMirror runtime into client bundles #583

@kile-lindgren

Description

@kile-lindgren

Package

@storyblok/react

Bug Description

Summary

Any value import from @storyblok/react/rsc — including helpers that have no runtime client requirement, such as storyblokEditable — causes Next.js (App Router, Turbopack) to ship a ~155 KB gzipped client chunk to the route. That chunk contains the live-editing bridge loader (storyblok-v2-latest URL constant, storyblok-javascript-bridge script element id) and the entire ProseMirror runtime that backs TipTap.

The leak persists when @storyblok/react/rsc is used exactly per the official Next.js docs (storyblokInit + apiPlugin + <StoryblokProvider> + <StoryblokStory> + real cdn/stories fetch). It is intrinsic to the package's barrel structure, not to user setup.

Environment

Reproduction

Test harness: https://github.com/kile-lindgren/storyblok-bundle-leak

git clone https://github.com/kile-lindgren/storyblok-bundle-leak
cd storyblok-bundle-leak
pnpm install
cp .env.example .env.local        # paste any Storyblok delivery token (eu region) for a demo space
pnpm test                          # = next build && node scripts/measure.mjs

The harness contains seven routes, each isolating one import pattern from @storyblok/react/rsc, plus an /official route that mirrors the docs end-to-end. The measurement script walks .next/server/app/<route>/page_client-reference-manifest.js for each route, sums the entryJSFiles chunks, and searches them for minification-safe marker strings. Results are written to results/run-<timestamp>.md.

Latest measurement run on main (commit 018ed0f):
https://github.com/kile-lindgren/storyblok-bundle-leak/blob/018ed0f/results/run-2026-04-22T06-25-12-061Z.md

Measured impact

Route Imports from /rsc Chunks added Raw Gz Bridge URL Bridge script-id ProseMirror Schema+NodeType
(framework baseline) n/a 9 542.9 KB 165.5 KB no no no no
/control nothing 0 0 B 0 B no no no no
/type-only import type { SbBlokData } 0 0 B 0 B no no no no
/editable-only storyblokEditable (value) 2 764.5 KB 154.6 KB YES YES YES YES
/server-component-only setComponents, StoryblokServerComponent 2 764.5 KB 154.6 KB YES YES YES YES
/richtext StoryblokServerRichText + 1 TipTap extension 2 764.5 KB 154.6 KB YES YES YES YES
/preview StoryblokStory 2 764.5 KB 154.6 KB YES YES YES YES
/official docs-faithful (storyblokInit + apiPlugin + <StoryblokProvider> + cdn/stories fetch + <StoryblokStory>) 2 764.5 KB 154.6 KB YES YES YES YES

Numbers are bytes added on top of the framework baseline, not totals. Every leaky route references the same two chunks (4ee6a58cbb237f3d.js + 1386ec3cd8a4bd63.js); Turbopack collapses the entire /rsc import surface into one shared chunk and ships it to every route that touches it.

Markers:

  • Bridge URL = literal storyblok-v2-latest
  • Bridge script-id = literal storyblok-javascript-bridge (the <script id="..."> the bridge loader injects)
  • ProseMirror = literal ProseMirror (CSS class names like ProseMirror-hideselection)
  • Schema+NodeType = both Schema and NodeType substrings present in the same chunk

Root cause

Two static import lines in the published @storyblok/[email protected] package:

  1. dist/rsc.mjs line 6:

    import { default as b } from "./rsc/live-editing.mjs";

    The /rsc barrel itself imports the 'use client' module at the top.

  2. dist/rsc/live-editing.mjs line 1:

    "use client";
    import { loadStoryblokBridge, registerStoryblokBridge } from "@storyblok/js";

Next's RSC bundler walks any 'use client' module reachable from a server barrel into the client graph, then walks all of its static imports too. So live-editing.mjs drags @storyblok/js (a single ~1.1 MB monolith bundle that inlines TipTap + ProseMirror + storyblok-js-client) into the client graph for every route that imports anything from /rsc.

A secondary leak point exists for richtext consumers via dist/core/richtext-hoc.mjs line 2 (import { ComponentBlok, richTextResolver } from "@storyblok/js"), but the live-editing trapdoor in the barrel alone is sufficient to produce the table above.

Why this can't be worked around on the consumer side

The 'use client' static edge from the /rsc barrel to live-editing.mjs is walked by Next's RSC bundler regardless of whether StoryblokLiveEditing is ever referenced. Tree-shaking does not cross 'use client' boundaries. So no application-level mitigation (route-group splits, dynamic imports, custom shims) can prevent the bridge from being added to the client graph for any route that imports any value from /rsc.

The /type-only route in the harness confirms type-only imports are erased correctly, so import type is the only currently-safe import shape from @storyblok/react/rsc.

Steps to Reproduce

Test harness: https://github.com/kile-lindgren/storyblok-bundle-leak

  1. git clone https://github.com/kile-lindgren/storyblok-bundle-leak
  2. cd storyblok-bundle-leak && pnpm install
  3. cp .env.example .env.local and paste a Storyblok delivery token from any eu space that contains at least one published draft story
  4. pnpm test (runs next build followed by node scripts/measure.mjs)
  5. Open results/run-<timestamp>.md (also printed to stdout) and inspect the Summary table

The harness contains seven routes, each isolating one import pattern from @storyblok/react/rsc, plus an /official route that mirrors the docs end-to-end. The measurement script walks .next/server/app/<route>/page_client-reference-manifest.js for each route, sums the entryJSFiles chunks, and searches them for minification-safe marker strings.

Pre-captured run on main (commit 018ed0f) for inspection without running the harness:
https://github.com/kile-lindgren/storyblok-bundle-leak/blob/018ed0f/results/run-2026-04-22T06-25-12-061Z.md

Simplest example is in the /src/app/editable-only/page.tsx. Which just imports storyblokEditable
import { storyblokEditable } from '@storyblok/react/rsc';. If you look at the resulting client bundle for this route, you will see the bridge and prosemirror client bundles are being included.

Reproduction URL

https://github.com/kile-lindgren/storyblok-bundle-leak

Expected Behavior

A route that imports only storyblokEditable from @storyblok/react/rsc should add zero client bytes beyond the framework baseline. storyblokEditable is a pure data-formatting helper that returns HTML attributes (data-blok-c, data-blok-uid); it has no runtime requirement on the live-editing bridge, on storyblok-js-client, or on TipTap/ProseMirror. Its measurement should match /control and /type-only (0 B added).

More broadly: only routes that explicitly opt into live editing (StoryblokStory / StoryblokLiveEditing) should ship the bridge loader. Routes that renderRichText on the server should NEVER ship the TipTap/ProseMirror bundles (I don't need the Emoji map ever in my public facing code)

Actual Behavior

any component importing anything from @storyblok/react/rsc, will trigger an automatic inclusion of the client bundles, even if the code is pure RSC and doesn't need the bundles.

Environment

System:
    OS: Windows 10 10.0.19045
    CPU: (16) x64 AMD Ryzen 7 PRO 6850U with Radeon Graphics       
    Memory: 4.20 GB / 30.77 GB
  Binaries:
    Node: 22.14.0 - C:\Program Files\nodejs\node.EXE
    npm: 10.9.2 - C:\Program Files\nodejs\npm.CMD
    pnpm: 10.33.0 - C:\Program Files\nodejs\pnpm.CMD
  Browsers:
    Chrome: 147.0.7727.102
    Edge: Chromium (140.0.3485.54)
    Firefox: 149.0.2 - C:\Program Files\Mozilla Firefox\firefox.exe
    Internet Explorer: 11.0.19041.5794
  npmPackages:
    @storyblok/react: 6.1.2 => 6.1.2
    next: 16.0.10 => 16.0.10
    react: 19.2.3 => 19.2.3

Error Logs

Additional Context

No response

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions