Real-time editing using Storyblok with Next.js Server Components

Storybloks real-time editor is awesome! Although it's easy to implement in your react application, it's not that easy when it comes to react server components. Here's why.

Real-time editing in Storyblok

Storyblok is one of our favourite content management systems. One of its core features is a real-time preview of the website as you edit the content.

Here’s how it works. The Storyblok editor renders a preview of our website in an iframe and sends update events to our implementation by registering a bridge on the iframe window. We can then use this bridge to listen to update events and update the page accordingly.

How it works with React

This article explains how to render content using react. Storyblok provides a react library for easy integration. Here's a shortened overview of how it looks like:

// initialize
storyblokInit(/* options */);

//  render components
export function App() {
  const slug = /* retrieve the slug from window.location */

  const story = useStoryblok(slug, /* options */);

  return <StoryblokComponent blok={story.content} />;
}

Let's look at the implementation of useStoryblok() closer. (I left out information for better understanding)

// storyblok-react/lib/index.ts
export const useStoryblok = (slug, apiOptions, bridgeOptions) => {
  const [story, setStory] = useState({});

  useEffect(() => {
    const { data } = await storyblokApi.get(slug, apiOptions);
    
    setStory(data.story);

    registerStoryblokBridge(
      storyId,
      (newStory) => setStory(newStory),
      bridgeOptions
    );
  }, [slug, apiOptions, storyblokApi]);

  return story;
};

An internal react state is held for the story. Initially the data gets filled using an API request to Storyblok. Any later updates happen through the bridge between Storyblok's UI and the iframe. As soon as the bridge tells our application that the user made a new update, we update the state and therefore re-render, giving us real-time updates as we edit the content.

React Server Components

This is what Storyblok writes about react server components in their article about setting up Storyblok with Next.js: The problem is that some components are rendered on the server but because the bridge only communicates with the client it is impossible to re-render the page.

Before we review how we solved this problem, let's quickly talk about why we have server components in the first place. There are some cases where server components make a lot of sense:

  • Markdown rendering (like this page) requires processing the markup string and turning it into HTML and components. - It yields better performance, because we can cache the rendered markdown on the server - This process requires some sort of a library that doesn't need to be shipped to the client. Just imagine the client would need to handle syntax highlighting for every language in a code block we use on our page.
  • Data connections like reading from a database or reading a file from the disk. - If the type of data we read depends on the content of the story it becomes impossible for the client to re-render the page because the client doesn't necessary know how some server components are rendered using the content.

All of these cases would break the real-time editing, because the client relies on the server for re-rendering the page. In the next section we explain how we overcame this problem and give a solution you can use yourself.

Server-side rendering with Storyblok

Before jumping into the code, here's the overall concept: Any update events that happen on the client will be sent to the server via an API. After the client sent what the page should look like, it revalidates the page in order to request any updated content from the server. The server itself caches the data temporarily and prioritises it when responding to requests.

For the client this means we need to connect to the bridge manually and listen for update events. If an update occurs, we invoke the /api/edit endpoint with the new story data.

// On the client
const router = useRouter();
const bridge = new window.StoryblokBridge({ ... });

bridge.on(["input"], async (event) => {
    await fetch("/api/edit", { body: JSON.stringify(event.story) })
    router.refresh()
})

On the server side, we save the data temporarily in-memory. Before sending out a page, we first check if there are any temporary changes which we should prioritise.

const cache = new Map<string, ISbStoryData>()

// app/api/edit/route.ts
async function POST(req: NextRequest) {
    const body = await req.json();
    cache.set(body.story.uuid, body.story);
}

// app/[...slug]/page.tsx
export default async function Page() {
    const story = await storyblokApi.getStory(storyblokPath, /* options */)

    const cachedStory = cache.get(story.uuid)

    if (cachedStory) {
        story.data.story = cachedStory;
    }

    return <Story story={story} />
}

Running multiple instances

As soon as there are more than two instances of the application running in parallel, this implementation breaks.

In a lot of cases the two requests will be sent to different instances. This means the client informs a server about an update that is different from the server it gets the page from. Because the temporary data is only held in-memory, the changes made in the UI are not displayed in the preview. This breaks the editing process.

We came to this conclusion after comparing local versions of our website with the deployed version at Vercel. In a lot of cases the deployed version would not display the newly made changes and instead show the last saved content.

Server Actions

The final solution to the problem are server actions. In React, server actions are functions that can be executed on the server from the client.

Server actions have another important behavior in Next.js. Here is a quote from the Next.js documentation on server actions:

"Server Actions integrate with the Next.js caching and revalidation architecture. When an action is invoked, Next.js can return both the updated UI and new data in a single server roundtrip"

This is the last important step. If the entire exchange takes place within a request, there is no longer a problem with multiple instances. The communication path is as follows:

The implementation is also much simpler. The bridge communicates using the server action instead of using an API. Next.js then takes care of the rest. However, the current path must be revalidated on the server side, otherwise Next.js will not deliver the new UI.

// On the server
async function updateStory(story: ISbStoryData) {
  "use server";
  cache.set(story.uuid, story);
  revalidatePath(path);
}

return <Bridge updateStory={updateStory}>

// On the client
const bridge = new window.StoryblokBridge({ ... });

bridge.on(["input"], async (event) => {
  props.updateStory(event.story);
})

This solved our challenges with server-side rendering in Next.js and the Storyblok editor.

Conclusion

The ability to edit content in real-time, like this blog post, is very useful. It bugged us out for a long time that there are some cases, where the editing would just not work as intended. We also learned a lesson here. In the future, before implementing something in-memory, we will think about how this solution would behave when scaling to more than one instance.