Skip to content

feat(live-preview): add initial live preview package#441

Open
dipankarmaikap wants to merge 8 commits intomainfrom
WDX-292-live-preview
Open

feat(live-preview): add initial live preview package#441
dipankarmaikap wants to merge 8 commits intomainfrom
WDX-292-live-preview

Conversation

@dipankarmaikap
Copy link
Contributor

@dipankarmaikap dipankarmaikap commented Feb 18, 2026

@storyblok/live-preview (initial version)

This PR introduces a new Live Preview package for Storyblok with a small, framework-agnostic API for handling preview updates on the client.

What’s included

1. High-level editor event helper (recommended)

onStoryblokEditorEvent covers the most common live preview use cases with minimal setup.

import { onStoryblokEditorEvent } from '@storyblok/live-preview';

onStoryblokEditorEvent((story) => {
  // update your page state with the latest story data
});

Behavior:

  • Calls the callback on input events with the updated story
  • Automatically reloads the page on change and published events
  • Safely runs only inside the Storyblok editor

2. Low-level Preview Bridge access

For advanced use cases where full control is needed, the Preview Bridge can be accessed directly.

import { loadStoryblokBridge } from '@storyblok/live-preview';

const bridge = await loadStoryblokBridge(bridgeOptions);

bridge.on(['input', 'change', 'published'], (event) => {
  // custom preview handling
});

Notes:

  • The Preview Bridge is enforced as a singleton per page
  • Configuration is init-only and cannot be changed at runtime
  • Prevents duplicated events and inconsistent preview behaviour

3. Editor detection utility

An isInEditor helper is also exposed for cases where conditional editor-only logic is required. This requires a URL.

@dipankarmaikap dipankarmaikap changed the title chore: live-preview package initial commit feat(live-preview): add initial live preview package Feb 19, 2026
@dipankarmaikap dipankarmaikap marked this pull request as ready for review February 19, 2026 13:21
@cursor
Copy link

cursor bot commented Feb 19, 2026

You have run out of free Bugbot PR reviews for this billing cycle. This will reset on March 1.

To receive reviews on all of your PRs, visit the Cursor dashboard to activate Pro and start your 14-day free trial.

export { listenToStoryblokPreview } from './listenToStoryblokPreview';
export { isInEditor } from './utils/isInEditor';

// type exports
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick: the comments do not add value IMO

* });
* ```
*/
export const subscribeToStoryblokPreview = async <T extends ISbComponentType<string> = any,>(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: what is the benefit of having subscribeToStoryblokPreview and listenToStoryblokPreview?

I would recommend either removing this one completely because users can just add if (eventStoryId !== storyId) return; in their own callback, or make it one function and use function overloading.

export async function subscribeToStoryblokPreview<T extends ISbComponentType<string> = any>(
  storyId: number,
  callback: (newStory: ISbStoryData<T>) => void,
  bridgeOptions?: BridgeParams,
): Promise<void>;

export async function subscribeToStoryblokPreview<T extends ISbComponentType<string> = any>(
  callback: (newStory: ISbStoryData<T>) => void,
  bridgeOptions?: BridgeParams,
): Promise<void>;

export async function subscribeToStoryblokPreview<T extends ISbComponentType<string> = any>(
  storyIdOrCallback: number | ((newStory: ISbStoryData<T>) => void),
  callbackOrBridgeOptions?: ((newStory: ISbStoryData<T>) => void) | BridgeParams,
  maybeBridgeOptions?: BridgeParams,
): Promise<void> {
  const storyId = typeof storyIdOrCallback === 'number' ? storyIdOrCallback : undefined;
  const callback = typeof storyIdOrCallback === 'function' ? storyIdOrCallback : callbackOrBridgeOptions as (newStory: ISbStoryData<T>) => void;
  const bridgeOptions = typeof storyIdOrCallback === 'function' ? callbackOrBridgeOptions as BridgeParams : maybeBridgeOptions;

  if (!canUseStoryblokBridge()) return;

  const bridge = await loadStoryblokBridge(bridgeOptions);

  bridge.on(['input', 'change', 'published'], (event) => {
    if (!event) return;

    if (event.action === 'input') {
      if (storyId !== undefined && event.story.id !== storyId) return;
      callback(event.story as ISbStoryData<T>);
      return;
    }

    if (event.action === 'change' || event.action === 'published') {
      if (storyId !== undefined && event.storyId !== storyId) return;
      window.location.reload();
    }
  });
}

// @ts-expect-error – intentional environment simulation
delete globalThis.window
expect(isBrowser()).toBe(false)
})
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

opinion: Considering how simple those functions are, I don't think those tests are worth it.

"type": "module",
"version": "0.0.0",
"private": false,
"packageManager": "pnpm@10.30.0",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick: I've noticed that we have different values for packageManager throughout monoblok. My gut feeling tells me that we can/should remove this from individual packages and only add it to the root level package.json, or use the same version for all of them.

"prepublishOnly": "pnpm run build"
},
"devDependencies": {
"@types/node": "^25.2.3",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We specify 24.12.0 in .nvmrc, so we should use the matching types for version 24. Maybe you can fix this in all the other packages, too 🙏

export const subscribeToStoryblokPreview = async <T extends ISbComponentType<string> = any,>(
storyId: number,
callback: (newStory: ISbStoryData<T>) => void,
bridgeOptions: BridgeParams,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you decide to keep this function, I think bridgeOptions should be optional.

* });
* ```
*/
export const listenToStoryblokPreview = async <T extends ISbComponentType<string> = any,>(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not 100% happy with the naming. In the JS world, we have either addEventListener, when using listener wording, onX, or subscribeX if a cleanup/unsubscribe function is returned.

A couple of possible variants: onStoryblokPreviewEvent, onStoryblokEditorEvent.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, not quite sure about the general concept: personally, I'd prefer onStoryblokEditorEvent('input', callback) and no default actions bound to change and publish.

If this is such a common case, then we should choose a very different naming like initLivePreview(onInput, { reloadOnSave: true }): Promise<StoryblokBridge>.

But I want to question the usefulness of reloading on change and published as a default. If your setup can handle live updating, then why not call callback on change and published, too? And if not (100% SSR) you probably don't need the input callback but only the reload on change and published.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

on publish and change you don't get the story. you get something like

  reload: true
  action: 'change'
  slug: string
  storyId: Story['id']

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see.. I think most user friendly option would probably to have it all:

onStoryblokEditorEvent('input|change|publish', callback) and initLivePreview(onInput) (if we have both, I don't think reloadOnSave option is necessary).

But it's your decision, you're deeper into it. My statements are just gut feelings.

describe('loadStoryblokBridge', () => {
beforeEach(() => {
vi.resetModules()
vi.clearAllMocks()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick: formatting (is ESLint configured?)

*/
export function loadStoryblokBridge(config?: BridgeParams) {
if (bridgePromise) {
if (config && !Object.is(config, storedConfig)) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This would also throw an error for Object.is({ a: 'a' }, { a: 'a' }), which is not what we want. See packages/cli/src/api.ts#L21 (configsAreEqual).

Copy link
Contributor

@alexjoverm alexjoverm left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking great from my side! Let's wait to figure out the License thing in the internal discussion before publishing

import { isBrowser } from './isBrowser';
import { isInEditor } from './isInEditor';

export function canUseStoryblokBridge(): boolean {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

praise: nice helper, I've seen this logic used often!

* console.log('Live updated story:', story)
* })
* ```
*/
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

praise: great name, way more self-descriptive than the current one we have in the SDKs

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.

3 participants