Echtzeitbearbeitung mit Storyblok und Next.js Server Components

Storybloks Echtzeit-Editor ist großartig! Obwohl er einfach in deine React-Anwendung zu integrieren ist, ist es nicht so einfach, wenn es um React Server Components geht. Hier zeigen wir euch den Grund.

Echtzeit-Bearbeitung in Storyblok

Storyblok ist eines unserer beliebtesten Content-Management-Systeme. Eine der wichtigsten Funktionen ist die Echtzeit-Vorschau der Website, während du den Inhalt bearbeitest. Und so funktioniert es:

Der Storyblok-Editor rendert eine Vorschau unserer Website in einem Iframe und sendet update events an unsere Implementierung, indem er eine Bridge auf dem Iframe window registriert. Über diese Bridge können wir dann auf events hören und die Seite entsprechend aktualisieren.

Wie es mit React funktioniert

Dieser Artikel erklärt, wie man Inhalte mit React rendert. Storyblok bietet eine React-Bibliothek zur einfachen Integration. Hier ist ein kurzer Überblick, wie es aussieht:

// 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} />;
}

Schauen wir uns die Implementierung von useStoryblok() näher an. (Ich habe hier zum besseren Verständnis Informationen weggelassen).

// 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;
};

Für die Story wird ein interner State gehalten. Zu Beginn werden die Daten über eine API-Anfrage an Storyblok befüllt. Spätere Aktualisierungen erfolgen über die Bridge zwischen Storyblocks UI und dem Iframe. Sobald die Bridge unserer Anwendung mitteilt, dass der Benutzer eine neue Aktualisierung vorgenommen hat, wird der State aktualisiert und somit neu gerendert, so dass wir am Ende in Echtzeit Updates erhalten, während wir den Inhalt bearbeiten.

React Server Components

Folgendes schreibt Storyblok über React Server Components in einem Artikel über die Einrichtung von Storyblok mit Next.js:

Das Problem ist, dass einige Komponenten auf dem Server gerendert werden, aber da die Bridge nur mit dem Client kommuniziert, ist es unmöglich, die Seite neu zu rendern.

Bevor wir uns ansehen, wie wir dieses Problem gelöst haben, schauen wir uns erst einmal an, warum wir überhaupt Server Components haben. Es gibt einige Fälle, in denen Server Components sehr sinnvoll sind:

  • Das Rendern von Markdown (wie auf dieser Seite) erfordert die Verarbeitung des Markup-Strings und seine Umwandlung in HTML und Komponenten. - Das liefert eine bessere Performance, da wir das gerenderte Markdown auf dem Server zwischenspeichern können. - Der Prozess erfordert eine Bibliothek, die nicht an den Client ausgeliefert werden muss. Wir brauchen uns nur vorzustellen der Client müsste für jede Programmiersprache eine Implementation von Syntax Highlighting haben.
  • Datenverbindungen wie das Lesen aus einer Datenbank oder das Lesen einer Datei von der Festplatte. - Wenn die Art der Daten, die wir lesen, vom Inhalt der Story abhängt, wird es für den Client unmöglich, die Seite neu zu rendern, da der Client nicht wissen muss, wie einige Server Components mit der Story gerendert werden.

In all diesen Fällen würde die Echtzeitbearbeitung unterbrochen werden, da der Client für Re-Rendering der Seite auf den Server angewiesen ist. Im nächsten Abschnitt erläutern wir, wie wir dieses Problem überwunden haben und geben eine Lösung an, die man selbst bei sich verwenden kann.

Serverseitiges Rendering mit Storyblok

Bevor wir uns in den Code stürzen, möchte ich das Konzept dahinter erläutern: Alle Update Events, die auf dem Client auftreten, werden über eine API an den Server gesendet. Nachdem der Client mitgeteilt hat, wie die Seite aussehen soll, validiert er die Seite erneut, um aktualisierte Inhalte vom Server anzufordern. Der Server selbst speichert die Daten vorübergehend in einem Cache und gibt den Daten aus dem Cache vorübergehend Vorrang.

Für den Client bedeutet das, dass wir uns manuell mit der Bridge verbinden und auf Update Events warten müssen. Wenn ein Update stattfindet, rufen wir den Endpunkt /api/edit mit den neuen Story-Daten auf.

// 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()
})

Auf der Serverseite speichern wir die Daten vorübergehend in einer Map. Bevor wir eine neue Seite rausgeben, prüfen wir zunächst, ob es vorübergehende Änderungen gibt, welche Vorrang haben.

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} />
}

Mehrere Instanzen

Sobald mehr als zwei Instanzen der Anwendung parallel laufen, bricht diese Implementierung zusammen.

In vielen Fällen werden die beiden Anfragen an unterschiedliche Instanzen gesendet. Das bedeutet, dass der Client einen Server über eine Aktualisierung informiert, der sich von dem Server unterscheidet, von dem er die Seite neu abruft. Da die temporären Daten nur in-memory gehalten werden, sind die in der UI vorgenommenen Änderungen nicht in der Vorschau zu sehen. Dadurch ist der Bearbeitungsprozess wieder kaputt.

Wir sind zu diesem Schluss gekommen, nachdem wir die lokale Version unserer Website mit der bei Vercel deployten Version verglichen haben. In vielen Fällen zeigte dann die Version von Vercel Änderungen nicht an und stattdessen die zuletzt gespeicherte Version der Seite.

Server Actions

Eine endgültige Lösung des Problems sind Server Actions. Unter Server Actions versteht man in React Funktionen, welche vom Client aus auf dem Server ausgeführt werden können.

In Next.js haben Server Actions noch eine weitere wichtige Funktion. Zitat aus der Next.js Dokumentation zu 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"

Das ist der letzte wichtige Schritt. Findet der gesamte Austausch innerhalb von einer Request statt, gibt es kein Problem mehr mit mehreren Instanzen. Der Kommunikationsweg sieht wie folgt aus:

Die Implementation ist auch um einiges einfacher. Die Bridge kommuniziert über die Server Action anstatt über eine API. Next.js kümmert sich dann um den Rest. Allerdings muss serverseitig der aktuelle Pfad revalidiert werden, da Next.js sonst nicht das neue UI ausliefert.

// 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);
})

Damit waren unsere Herausforderungen mit serverseitigem Rendering in Next.js und dem Storyblok Editor gelöst.

Fazit

Die Möglichkeit, Inhalte in Echtzeit zu bearbeiten, wie z. B. diesen Blogbeitrag, ist sehr nützlich. Es hat uns lange Zeit gestört, dass es einige Fälle gibt, in denen die Bearbeitung nicht wie vorgesehen funktioniert. Wir haben hier auch eine Lektion gelernt. In Zukunft werden wir, bevor wir etwas in-memory implementieren, darüber nachdenken, wie sich diese Lösung bei der Skalierung auf mehr als eine Instanz verhalten würde.