Real-Time Visual Editing with the Storyblok Bridge
This guide implements Live Editing Integration Patterns for Storyblok, whose Visual Editor embeds your site in an iframe and drives it through the Storyblok Bridge, a small script that sends every keystroke to the page and makes each block clickable.
Storyblok’s model makes visual editing unusually direct. A story is a tree of bloks, each with a component name that maps to one frontend component, and every blok carries an _editable comment that identifies it. When the frontend renders a blok with the storyblokEditable helper, the Visual Editor can outline it, and a click opens that blok’s fields in the side panel. As the editor types, the Bridge emits input events carrying the whole updated story, so the page can re-render without fetching anything.
The Problem
A travel company builds landing pages in Storyblok with a Next.js frontend. The team added the Bridge script following a quick-start guide, and the Visual Editor showed the site, but typing did nothing until the editor pressed save and the iframe reloaded. Clicking blocks sometimes opened the wrong component, and nested relations, such as a “featured destinations” block that references destination stories, rendered as bare uuids in the editor while the live site showed full cards.
Each symptom has a specific cause. The page ignored input events and only refetched on reload. Some components rendered their outer wrapper without storyblokEditable, so clicks bubbled to the parent blok. And the relations were resolved with resolve_relations on the server fetch but not in the Bridge, so updated stories arrived with unresolved references.
How the Bridge Works
The Bridge is loaded only when the page runs inside the Visual Editor, which the frontend detects through the _storyblok query parameter that the editor adds to the preview URL. Once loaded, it exposes an event API:
inputfires on every change in the editor, before saving, with the full updated story object. Use it to replace the story in component state.changefires after the editor saves the story. Refetch the draft from the API if the page relies on data that input events do not carry, such as resolved relations computed elsewhere.publishedfires after publishing. Refetch, or trigger revalidation of the published page through your webhook path.
The Bridge also needs to know which relations to resolve in the stories it sends, through the same resolveRelations option the API fetch uses. When both agree, input events carry fully resolved relations, and nested cards update live.
Implementation
The example uses @storyblok/react in a Next.js App Router project. The server fetches the draft story when draft mode is on; a client component takes over in the Visual Editor, subscribes to Bridge events and re-renders from the updated story.
// lib/storyblok.ts
import { apiPlugin, storyblokInit } from "@storyblok/react/rsc";
import Hero from "@/components/bloks/Hero";
import DestinationGrid from "@/components/bloks/DestinationGrid";
import Cta from "@/components/bloks/Cta";
export const RESOLVE_RELATIONS = ["destination_grid.destinations"];
export const getStoryblokApi = storyblokInit({
accessToken: process.env.STORYBLOK_PREVIEW_TOKEN, // server-side draft token
use: [apiPlugin],
components: { hero: Hero, destination_grid: DestinationGrid, cta: Cta },
});
// app/[...slug]/page.tsx
import { draftMode } from "next/headers";
import { getStoryblokApi, RESOLVE_RELATIONS } from "@/lib/storyblok";
import { LiveStory } from "@/components/LiveStory";
export default async function Page({ params }: { params: Promise<{ slug: string[] }> }) {
const { slug } = await params;
const draft = (await draftMode()).isEnabled;
const { data } = await getStoryblokApi().get(`cdn/stories/${slug.join("/")}`, {
version: draft ? "draft" : "published",
resolve_relations: RESOLVE_RELATIONS.join(","),
}, { cache: draft ? "no-store" : "force-cache" });
return <LiveStory initial={data.story} draft={draft} />;
}
// components/LiveStory.tsx
"use client";
import { StoryblokComponent, storyblokEditable, useStoryblokState } from "@storyblok/react";
import type { ISbStoryData } from "@storyblok/react";
import { RESOLVE_RELATIONS } from "@/lib/storyblok-config";
export function LiveStory({ initial, draft }: { initial: ISbStoryData; draft: boolean }) {
// Subscribes to Bridge input events when running inside the Visual Editor.
const story = useStoryblokState(initial, { resolveRelations: RESOLVE_RELATIONS }, draft);
if (!story) return null;
return (
<main {...storyblokEditable(story.content)}>
{story.content.body?.map((blok: { _uid: string; component: string }) => (
<StoryblokComponent blok={blok} key={blok._uid} />
))}
</main>
);
}
// components/bloks/Hero.tsx
import { storyblokEditable } from "@storyblok/react";
import type { SbBlokData } from "@storyblok/react";
interface HeroBlok extends SbBlokData {
headline: string;
subline?: string;
}
export default function Hero({ blok }: { blok: HeroBlok }) {
return (
<section {...storyblokEditable(blok)} className="hero">
<h1>{blok.headline}</h1>
{blok.subline && <p>{blok.subline}</p>}
</section>
);
}
useStoryblokState loads the Bridge only inside the editor and replaces the story on every input event, passing the relation list so the Bridge resolves the same relations as the API call. Every blok component spreads storyblokEditable(blok) on its outermost element, which is what makes selection precise. The relation list lives in a small shared module (storyblok-config) so server and client import the same constant.
Refetching on change and published events
Input events are enough for fields that live in the story itself. Some pages also show data the story only points to, such as the latest three blog posts in a teaser block, which are fetched separately by query rather than resolved as relations. For those, subscribe to the Bridge’s change event and refetch the dependent data after each save, and on published trigger the same revalidation the public site uses, so the editor sees exactly what readers will see. Keep these refetches in the client component that owns the teaser block, so the rest of the page keeps updating from input events without network requests.
Configuration Reference
| Setting | Where | Value |
|---|---|---|
| Preview URL | Storyblok space settings, visual editor | https://www.example.com/api/draft?secret=…&slug= |
| Token | server env | Preview token (draft access), never in client code |
version |
API fetch | draft in draft mode, published otherwise |
resolve_relations |
API fetch and Bridge | Same list on both sides |
storyblokEditable(blok) |
every blok component | On the outermost element |
frame-ancestors |
CSP on preview | https://app.storyblok.com |
Gotchas & Edge Cases
- Tokens in the browser. Many Storyblok examples initialize the SDK in the browser with the preview token. That exposes draft access to anyone who opens the preview. Fetch drafts on the server and let the client only apply Bridge events, which carry data the editor already sees.
- Missing editable wrappers. A component that renders a fragment or several root elements cannot receive the editable attributes on one node. Wrap it in a single element, or selection jumps to the parent blok.
- Relation lists drifting. Adding a relation field in Storyblok without adding it to
RESOLVE_RELATIONSmakes that block show uuids during live editing. Keep the list next to the component mapping and review both together. - Rich text blok content. Richtext fields can embed bloks. Render them with the SDK’s rich text renderer and a resolver map, or embedded bloks disappear in preview.
- Published event and caching. The
publishedevent reaches only the editor’s iframe. The public site still needs a webhook to revalidate, as described in webhook-triggered rebuilds.
Worked Example
The travel company fixed its preview in an afternoon. Moving the preview token to the server and fetching drafts in the page removed the token from the client bundle. Adding useStoryblokState with the shared relation list made typing appear instantly, including inside the destination cards. Wrapping three components that rendered fragments in section elements fixed selection. Editors, who had been saving every few seconds to see changes, stopped saving until they were done, which also cut the number of draft versions in the story history by more than half.
Rollout Checklist
- Fetch drafts on the server with the preview token; never initialize the SDK with it in the browser.
- Map every Storyblok component to a React component and spread
storyblokEditableon its root. - Share one
RESOLVE_RELATIONSlist between the API fetch and the Bridge. - Allow framing by the Storyblok app and send
no-storeandnoindexon preview responses. - Connect Storyblok’s publish webhook to your revalidation route for the public site.
Frequently Asked Questions
Do I need draft mode if the Bridge already sends the story?
Yes. The Bridge only sends updates after the page has loaded; the first render must already show the draft, or editors see published content until they type. Draft mode ensures the server fetches version=draft.
Can the Visual Editor preview pages that are not stories?
It previews whatever URL you configure, but click-to-edit only works on elements rendered from bloks with editable attributes. Pages built purely in code can still be shown for context.
How do I handle multiple languages?
Storyblok serves translations through field-level translation or folder-level language structures. Include the language in the preview URL and in the API fetch, and use the same language setting when resolving relations, so live updates arrive for the language being edited.
Why do some blocks flicker while typing?
Components that derive state from props in effects, or that use random keys, remount on every input event. Use the blok’s _uid as the key and compute derived values during render, so updates patch the existing elements instead of replacing them.
Is the Bridge script loaded on the public site?
Not with useStoryblokState and similar helpers: they load it only when the page runs inside the Visual Editor. Check the network panel on a published page to confirm.