Skip to content
agiledrop logo
    • Agencies
    • Organizations
    • Product teams
    • E-learning
    • Media & publishing
    • Staff augmentation

    • Dedicated teams

    • Turn-key projects

    • Drupal

    • Laravel

    • Moodle

    • Storyblok

    Front-end

    • React
    • Next.js
    • Vue
    • Nuxt.js
    • Angular

    Back-end

    • PHP
    • Laravel
    • Symfony
    • Node.js
    • Company
    • History
    • Team
    • Careers
    • Slovenia
    • Blog
    • Podcast
Get developers
Footer Agiledrop logo
Agiledrop Ltd.Stegne 11aSI-1000 LjubljanaSlovenia, EUEU flag
gold creditworthiness
Services
  • Support & maintenance
  • Drupal 7 upgrade
  • PHP staffing
  • JavaScript staffing
  • Legacy PHP development
About
  • Company
  • History
  • Team
  • Careers
  • Slovenia
  • Brand materials
Contact us
  • Email:
    [email protected]
  • Phone:
    +386 590 18180
© 2013-2023 AGILEDROP Ltd
  • Privacy policy
  • Terms of service
  • Cookie policy

Storyblok live editing with NextJS React Server Components

Benjamin

Posted on26 Jun 2025in

Development,Experience

Introduction

Storyblok provides a live editing feature for editors so that they can instantly see the content changes in a preview window. Our website uses NextJS with app router and react server components. The issue we were facing is that live preview only worked after the editor either saved or published the content, however the changes should be visible as soon as the content is changed.

 

Initial implementation

The initial implementation for live preview was using an API endpoint for live preview URL by combining the official NextJS Visual Preview guide and the NextJS draft mode feature.

This implementation was not good for a couple of reasons:

  • As mentioned in the introduction – live preview would only update after the editor saved the content.
  • If the editor visited the page outside the live preview as a regular visitor, the editor would not see the published content but content saved in preview due to the draft mode cookie set.
  • The editor has to manually switch out of live preview mode.

So I started looking into alternatives on how to solve these issues.

 

Searching through the web for solutions

After a Google search about this issue I came upon a blog post by Daniël Krux on solving the live preview with react server components. The solution uses StoryblokBridge class that is attached to the browser’s window object and executes server actions to cache the modified story as the editors are typing, invalidating the slug path then serving the cached story (if it exists) in the page component.

It looked promising and I started implementing it into our existing setup.

After implementation was done the live preview seemed to work perfectly. Content was immediately updated as the editor was typing into the fields.

However, this produced even more issues than before:

  • If the editor modified the page in the live preview and refreshed the page (without saving), after reload, the live preview would still show modified content.
  • Another editor that visited the same page in live preview would see the modified and unsaved content by the first editor. This is a major issue if multiple editors are editing the same page – they would essentially overwrite each other's changes on every input.
  • As the solution uses revalidatePath() after editor inputs, the published live page path cache would also get unnecessarily invalidated.
  • The last one and a critical one: regular visitors would also see modified unsaved content.

 

Figuring out a solution

Most of these issues come from the fact that the cache key is only the story slug.

This is the line that caches the story in server action:

global.storyblokCache.set(story.slug, JSON.stringify(story));

There is no cache variation of the story depending on the editor so editors receive the story data of whoever made the last input into the live preview fields.

So the cache key should consist of some sort of user id and story slug. However, this user id should also change when refreshing the Storyblok live preview page; otherwise unsaved content modifications would still be seen after refresh.

My initial idea was to generate a uuid when registering storyblok bridge. This uuid would change when refreshing the live preview page, but stay the same as the editor is changing the content. It seemed like a good solution but I noticed that in live preview mode, Storyblok sends extra query parameters to our page so we can securely validate if the live preview requests are actually coming from Storyblok.

Most notably the request includes a parameter _storybloktk[token] which can be used instead of manually generating uuid. This token does not change when the editor is changing the content, but it does change if the editor refreshes the page which is exactly what I needed. An added bonus is that this token can be verified and deny preview requests from unauthorized sources. The process of verification is explained in the Storyblok documentation.

With this final piece I was ready to build the complete solution.

 

Solution implementation

First install and register a persistent key/value store in your Next app in the instrumentation.ts file.

The library/implementation choice is up to you, the main thing to take care of is that the store does not grow too large. Preview tokens last for 1 hour so there is no need to store cached values after that period.

export async function register() {

if (process.env.NEXT_RUNTIME === 'nodejs') {

const NodeCache = (await import('node-cache')).default;

const config = {

stdTTL: 3600,

};

global.storyblokCache = new NodeCache(config);

}

}

Then create a preview bridge:

import type { ISbStoryData } from 'storyblok-js-client';

type RegisterBridgeParams = {

onInput: (params: ISbStoryData) => void;

};

export const registerStoryblokBridge = ({ onInput }: RegisterBridgeParams) => {

const isServer = typeof window === 'undefined';

const isBridgeLoaded =

!isServer && typeof window.storyblokRegisterEvent !== 'undefined';

if (!isBridgeLoaded) {

return;

}

window.storyblokRegisterEvent(() => {

const sbBridge = new window.StoryblokBridge();

sbBridge.on(['input'], (event) => {

if (!event?.story) {

return;

}

onInput(event.story);

});

});

};

So far everything is pretty much the same as in Daniël Krux’s blog solution.

After that, create a preview token validator:

import crypto from 'crypto';

export const verifyPreviewToken = (token: string, timestamp: string) => {

const validationString = ${process.env.STORYBLOK_SPACE_ID}:${process.env.STORYBLOK_DRAFT_MODE_TOKEN}:${timestamp};

const validationToken = crypto

.createHash('sha1')

.update(validationString)

.digest('hex');

return (

token === validationToken &&

parseInt(timestamp, 10) > Math.floor(Date.now() / 1000) - 3600

);

};

An important thing to note is that the live preview token will be based on a PREVIEW type of access token configurable from your Storyblok workspace settings. Without a PREVIEW token set, the live preview page will send an empty _storybloktk[token] query parameter, so make sure you have a PREVIEW token configured.

Additionally, in case you have multiple PREVIEW tokens configured, the live preview token will be based on the last PREVIEW token, so make sure that your STORYBLOK_DRAFT_MODE_TOKEN environment variable matches the last PREVIEW token in your access tokens list.

Now create a server action that will store the preview story into the cache:

'use server';

import type { ISbStoryData } from '@storyblok/js';

import { revalidatePath } from 'next/cache';

type Args = {

story: ISbStoryData;

pathToRevalidate: string;

previewToken: string;

};

export async function previewUpdateAction({

story,

pathToRevalidate,

previewToken,

}: Args) {

if (!story) {

console.error('No story provided');

return;

}

try {

if (!pathToRevalidate || !previewToken) {

return;

}

global.storyblokCache?.set(`${previewToken}_${pathToRevalidate}`, {

story,

});

revalidatePath(pathToRevalidate);

} catch (error) {

console.log(error);

}

}

The server action only checks that the preview token exists – this could be improved by also validating the preview token. If we have a preview token, store the preview story into the cache with a previewToken_slug as key and revalidate the path.

Now create a client component that registers the bridge and calls the server action:

'use client';

import { previewUpdateAction } from '@actions/previewUpdateAction';

import type { ISbStoryData } from '@storyblok/js';

import { loadStoryblokBridge } from '@storyblok/js';

import { registerStoryblokBridge } from '@utils/preview';

import type { FC } from 'react';

import { startTransition, useEffect } from 'react';

type Props = {

pathToRevalidate: string;

previewToken: string;

};

export const StoryblokPreviewSyncer: FC<Props> = ({

pathToRevalidate,

previewToken,

}) => {

function handleInput(story: ISbStoryData) {

startTransition(() => {

void previewUpdateAction({

story,

pathToRevalidate,

previewToken,

});

});

}

useEffect(() => {

(async () => {

await loadStoryblokBridge();

registerStoryblokBridge({

onInput: handleInput,

});

})();

}, []);

return null;

};

The preview syncer component triggers a server action on preview update with the story data, slug and preview token.

And the last part is to create a preview page. Create a page in src/app/preview/[...slug]/page.tsx :

import StoryblokComponent from '@components/Storyblok/StoryblokComponent';

import { StoryblokPreviewSyncer } from '@components/Storyblok/StoryblokPreviewSyncer';

import type { SbBlokData } from '@storyblok/js';

import { fetchPreviewData } from '@utils/fetchPageData';

import { verifyPreviewToken } from '@utils/previewToken';

import { notFound } from 'next/navigation';

import type { FC } from 'react';

type CachedStory = {

story: {

content: SbBlokData;

};

};

type Props = {

params: Promise<{

slug: string[];

}>;

searchParams: Promise<{

'_storyblok_tk[token]': string;

'_storyblok_tk[timestamp]': string;

}>;

};

const PreviewPage: FC<Props> = async ({ params, searchParams }) => {

const { slug } = await params;

const {

'_storyblok_tk[token]': previewToken,

'_storyblok_tk[timestamp]': timestamp,

} = await searchParams;

if (!verifyPreviewToken(previewToken, timestamp)) {

notFound();

}

const currentRoute = preview/${slug.join('/')};

const cacheKey = ${previewToken}_${currentRoute};

let data = global.storyblokCache?.get<CachedStory>(cacheKey);

if (!data) {

const response = await fetchPreviewData<CachedStory>(`${slug.join('/')}`);

if (!response?.data) {

notFound();

}

data = response.data;

}

return (

<>

<StoryblokPreviewSyncer

pathToRevalidate={currentRoute}

previewToken={previewToken}

/>

<StoryblokComponent

blok={data.story.content}

/>

</>

);

};

export default PreviewPage;

We use a customized story data fetch function and Storyblok component in our project, but the main point of the page is to verify that the preview token exists and is valid, then try to get a cached story based on the preview token and slug if it exists. If not, fetch the latest draft version of the story.

A separate preview page is used so that the revalidatePath calls in the server action do not invalidate real pages served to the users.

Now all that is left is to configure the URL for the Visual editor in the Storyblok settings to point at https://YOUR_DOMAIN/preview/

Happy editing!

Related blog posts

Blog post card background image.

Storyblok live editing with NextJS React Server Components

Published On 26 Jun 2025  in Development, Experience 
Blog post card background image.

How we support the local development community

Published On 16 Jun 2025  in Company, Development, Community 
Blog post card background image.

The hidden costs of software development

Published On 05 Jun 2025  in Business, Development