Come affrontare il tema delle preview e degli aggiornamenti in tempo reale con NextJS e Storyblok (o con qualunque altro CMS headless)
SHARE
La preview in fase di creazione di un contenuto e l'aggiornamento (non) istantaneo sono da sempre tra i temi più discussi nel mondo del serverless e dei CMS headless.
Benché molti CMS headless abbiano implementato delle soluzioni di Live Editing, gli approcci suggeriti sono quasi sempre più vicini al mondo degli sviluppatori che a quello dei content creator. Stesso discorso per l'aggiornamento di un contenuto esistente, che spesso richiede lunghi rebuild, di cui peraltro è difficile monitorare l'avanzamento senza effettuare l'accesso a Vercel.
Con le nuove versioni di NextJS, l'App Router e l'utilizzo dei Server Components, la situazione è ulteriormente peggiorata.
In questo articolo proverò a descrivere la soluzione utilizzata per la gestione di questo sito con Storyblok, ma che comunque può essere adattata - con le dovute differenze - anche ad altri CMS.
Storyblok offre un sistema di Live Editing dei contenuti, che in teoria dovrebbe consentire di visualizzare in tempo reale le modifiche apportate. Questa funzionalità ha però alcuni limiti:
funziona solo per i Client Components, che vengono poi estratti lato codice tramite lo <StoryblokStory story={story} />
non tiene conto del fatto che in un template possano esserci anche delle aree statiche e gestite tramite Server Component; ad esempio, l'area di header che visualizza titolo, sottotitolo o cover image difficilmente viene componentizzata e settata manualmente su ogni entry di Storyblok. Più correttamente viene inserita nel template di pagina, che però essendo un Server Component non riflette né i cambiamenti Live, né quelli successivi al salvataggio della pagina (cliccando "Save", Storyblok effettua un refresh della pagina ogni volta).
non è possibile avere un link di preview da condividere, utile in caso di contenuti che necessitano di un particolare flusso di approvazione
La soluzione per ovviare a questo problema è la seguente: creare una pagina dinamica (cioè generata in SSR) alla quale passare come parametro lo slug della entry di Storyblok di cui si desidera visualizzare la preview.
Prima di andare ad esaminare nel dettaglio la soluzione scelta, assicuratevi di aver già configurato correttamente il vostro stack NextJS + Storyblok, seguendo le ultime istruzioni disponibili relative all'App Router sul loro SDK (attenzione al fatto che l'implementazione è cambiata col rilascio della v. 4.0.0 a novembre 2024).
Utile chiarire anche il funzionamento di token e versioning. Storyblok permette la creazione di due tipologie di token, Public e Preview. La prima funziona solo in produzione e permette di accedere ai contenuti con versioning "Published" (entry già pubblicate); la seconda funziona anche in ambiente di sviluppo e permette di visualizzare sia i contenuti in "Published" che quelli in "Draft".
Il token si inserisce nel blocco di inizializzazione delle API di Storyblok:
export const getStoryblokApi = storyblokInit({
accessToken: process.env.NODE_ENV === "development" ? process.env.NEXT_PUBLIC_STORYBLOK_PREVIEW_TOKEN : process.env.NEXT_PUBLIC_STORYBLOK_PUBLIC_TOKEN,
use: [apiPlugin],
components: components
})
Mentre il versioning si decide nelle singole chiamate per il fetch dei contenuti:
let sbParams = {
version: process.env.NODE_ENV === "development" ? 'draft' : 'published',
resolve_relations: 'page.parents,post.categories',
cv: Date.now()
};
const storyblokApi = getStoryblokApi();
let { data } = await storyblokApi.get(`cdn/stories/${slug}`, sbParams);
Importante ricordare che i token di Storyblok sono esposti nel codice a frontend. Benché entrambi siano in sola lettura, la soluzione proposta in questo articolo consentirà di limitare l'esposizione del "Preview Token", per evitare che qualcuno con troppo tempo libero possa accedere ai vostri contenuti in draft.
Iniziamo dalla brutta notizia: sarà necessario duplicare alcune parti dell'applicativo: layout e template di pagina. Questa soluzione non è quindi indicata per applicativi di grosse dimensioni, ma può andare bene per un sito statico di poche pagine, o un semplice blog.
La buona notizia è che andremo a creare una route completamente separata da quella attuale, su cui poter sperimentare senza timore di andare ad impattare sulla visualizzazione del sito in produzione.
La struttura dell'applicativo verrà modificata così:
app
|-- (staging)
|------ preview
|---------- page.tsx
|------ layout.tsx
|------ page.tsx
|-- (production)
|------ [...slug]
|---------- page.tsx
|------ layout.tsx
|------ page.tsx
lib
|-- storyblok.js
|-- storyblok-preview.js
src
|-- components
|------ storyblokprovider.tsx
|------ storyblokprovider-preview.tsx
La prima cosa da osservare sono le due cartelle con le parentesi tonde, (staging) e (production). Sono due Route Groups di NextJS, in pratica una sorta di "segmentazione" del nostro applicativo, che però non ha effetto sulla struttura degli url ma solamente sulla configurazione "logica" del progetto. Tra i vantaggi dei Route Groups c'è la possibilità di creare Root Layout multipli: nel nostro caso ci sarà utile per creare due layout che fanno uso dei due diversi token di Storyblok, il Public Token (per il sito statico) e il Preview Token (per la nostra pagina di preview dinamica).
Nelle cartelle /lib e /src/components abbiamo duplicato la connessione a Storyblok e lo StoryblokProvider, ovvero i due file che avevamo inizialmente creato seguendo le istruzioni dell'SDK di Storyblok.
Per quanto riguarda la connessione, assicuriamoci di inizializzarla col Preview Token:
export const getStoryblokApi = storyblokInit({
accessToken: process.env.NEXT_PUBLIC_STORYBLOK_PREVIEW_TOKEN,
use: [apiPlugin],
components: components
})
Lo StoryblokProvider verrà invece rinominato in StoryblokProviderPreview:
'use client';
import { getStoryblokApi } from '@/lib/storyblok-preview';
export default function StoryblokProviderPreview({ children }) {
getStoryblokApi();
return children;
}
Abbiamo poi duplicato layout.tsx e page.tsx.
Il Root Layout per la preview verrà modificato così (notare l'uso di <StoryblokProviderPreview />):
import { Header, Footer } from "@/src/components"
import StoryblokProviderPreview from "@/src/components/storyblokprovider-preview";
export default function PreviewLayout({ children }: { children: React.ReactNode }) {
return (
<StoryblokProviderPreview>
<html lang="it">
<body>
<Header />
{children}
<Footer />
</body>
</html>
</StoryblokProviderPreview>
)
}
La page.tsx dell'area preview andrà invece modificata in modo da accettare il parametro ?slug= che riceverà da Storyblok e che conterrà lo slug della entry da mostrare.
Il codice (qui semplificato per motivi di spazio) sarà il seguente:
export const dynamic = 'force-dynamic'; // Add configuration
import { StoryblokClient, StoryblokStory, ISbStoriesParams } from "@storyblok/react/rsc"
import { getStoryblokApi } from "@/lib/storyblok-preview"; //Use SB preview token
import { notFound } from "next/navigation";
async function PagePreview(
props: {
params: Promise<{ slug: string | string[] }>
searchParams: Promise<{ [key: string]: string | string[] | undefined }>
}
) {
const searchParams = await props.searchParams;
const params = await props.params;
/*
Standard Page Preview
*/
const { props: { story } } = await fetchStory(searchParams.slug as string[]);
return(
<>
<h1>{story.content.title}</h1>
<StoryblokStory story={story} isPreview={true} />
</>
)
}
export default PagePreview
/*
Fetch functions
*/
async function fetchStory(slugArray: string[]): Promise<any> {
let sbParams: ISbStoriesParams = {
version: 'draft',
resolve_relations: 'page.parents,post.categories',
cv: Date.now()
};
try {
const storyblokApi: StoryblokClient = getStoryblokApi();
let { data } = await storyblokApi.get(`cdn/stories/${slugArray}`, sbParams);
return {
props: {
story: data ? data.story : false
}
};
}
catch(e) {
notFound()
}
}
Da notare innanzitutto la prima riga: il force-dynamic servirà a fare in modo che la pagina venga rigenerata ad ogni caricamento, mostrando sempre l'ultimo salvataggio disponibile.
Poco più avanti, il client getStoryblokApi viene importato da @/lib/storyblok-preview, per poter avere accesso (anche) ai contenuti in draft.
La funzione fetchStory() invece è stata modificata in modo da elaborare il parametro ?slug= passato nell'url e recuperare la entry corrispondente da Storyblok; da notare l'utilizzo del "version: draft".
Nello <StoryblokStory /> abbiamo infine aggiunto la proprietà isPreview={true}, che sarà possibile passare in prop-drilling a tutti i sottocomponenti per capire lato codice quando siamo in preview o meno (ad esempio per mostrare all'utente un alert con l'indicazione che si tratta di una preview).
Ovviamente dovrete aggiungere la logica necessaria per gestire le preview di differenti aree del sito, ad esempio per la home page.
Se avete fatto tutto correttamente, dovreste riuscire ad accedere alla preview delle vostre pagine con un URL del genere:
www.example.com/preview?key=[SECRET_KEY]&slug=/blog/post-title
Noterete la presenza di un parametro ?key=, da popolare con una chiave segreta a vostro piacere, di cui parleremo tra poco.
A questo punto, siete pronti per aggiungere l'url appena creato nella sezione Settings / Visual Editor.
Una volta salvate le impostazioni, aprendo in editing una entry (o creandone una nuova) dovreste avere sia il Live Editing funzionante che la possibilità di aprire la preview in una nuova scheda.
I componenti della pagina che supportano il Live Editing verranno aggiornati in tempo reale, mentre le parti statiche saranno aggiornate al reload della pagina, che avverrà cliccando il tasto "Save".
L'area preview dovrebbe essere accessibile soltanto all'interno del CMS o da chi ha a disposizione il link.
Per ottenere questo effetto possiamo inserire nel codice della pagina delle verifiche sui due parametri passati nell'URL, o più semplicemente utilizzare il Firewall di Vercel.
Nel pannello Firewall relativo al vostro progetto create una nuova regola con queste condizioni:
Il Request Path è uguale a /preview
La query "key" deve essere uguale alla chiave segreta da voi scelta
Potete ovviamente scegliere altre regole di protezione (ad esempio l'accesso solo da un determinato IP).
Impostando come azione "Deny", chiunque provi ad accedere a /preview senza avere il link corretto riceverà solamente una schermata di "403: Forbidden" di Vercel, senza che venga esposta la pagina di preview e di conseguenza il Preview Token contenuto nel codice.
Una delle cose più snervanti per chi gestisce le modifiche ai contenuti è il dover attendere il rebuild dell'intero sito, anche nel caso sia stata modificata una semplice virgola.
Questo solitamente avviene grazie ai Deploy Hook di Vercel, opportunamente settati sul CMS; nel momento in cui l'utente clicca "Publish" su Storyblok viene lanciato il rebuild dell'intero sito, e potrà "vedere le modifiche" a distanza di qualche minuto.
Normalmente l'utente non ha però visibilità di come procede l'andamento del rebuild, e le tempistiche su siti con parecchi contenuti potrebbero andare ben oltre il "qualche minuto".
In alternativa, è però possibile creare una API che sfrutti la funzione revalidatePath di NextJS, con qualcosa del genere:
import { getStoryblokApi } from '@/lib/storyblok-preview'
import { NextRequest, NextResponse } from "next/server";
import { revalidatePath } from "next/cache";
export async function POST(request: NextRequest) {
try {
const body = await request.json();
// Check the request is coming from your Storyblok Space
if (body.space_id !== parseInt(process.env.REVALIDATE_SPACE_ID as string)) {
return NextResponse.json({ status: "KO", msg: 'Error: Cannot perform revalidation.' }, { status: 401 });
}
// Fetch data from Storyblok
storyblokInit({
accessToken: process.env.NEXT_PUBLIC_STORYBLOK_PREVIEW_TOKEN,
use: [apiPlugin]
});
const storyblokApi: StoryblokClient = getStoryblokApi();
let sbStoryID = body.story_id;
const sbData = await storyblokApi.get(`cdn/stories/${sbStoryID}`, {
version: body.action == "published" ? 'published' : 'draft',
resolve_relations: 'page.parents,post.categories',
excluding_fields: 'body'
});
if (sbData.data.story) {
// Basic paths: home page, blog archive and the story itself
const pathsToRevalidate = [
'/',
'/blog',
'/' + sbData.data.story.full_slug
];
// Manage categories
if (sbData.data.story.content.categories) {
sbData.data.story.content.categories.forEach((cat: any) => {
pathsToRevalidate.push('/' + cat.full_slug);
});
}
// Manage page parents
if (sbData.data.story.content.parents) {
sbData.data.story.content.parents.forEach((parent: any) => {
pathsToRevalidate.push('/' + parent.full_slug);
});
}
// Revalidate all paths
for (const path of pathsToRevalidate) {
revalidatePath(path);
console.log('revalidating path: ', path)
}
return NextResponse.json({ status: "OK" }, { status: 200 });
} else {
return NextResponse.json({ status: "KO", msg: 'Error: Cannot perform revalidation.' }, { status: 401 });
}
} catch (error) {
console.error('Error in revalidation:', error);
return NextResponse.json({ status: "KO", msg: 'Error: An error occurred while processing the request.' }, { status: 500 });
}
}
export async function GET() {
return NextResponse.json({ status: "KO", msg: 'Error: Method not allowed. Use POST for revalidation requests.' }, { status: 405 });
}
La logica è abbastanza semplice: impostando l'url dell'API così creata sugli eventi di publish/unpublish su Storyblok, al momento della pubblicazione il CMS invierà al nostro endpoint una richiesta formattata in questo modo:
{
"text": "The user username@domain.com published the Story Test (test)\nhttps://app.storyblok.com/#/me/spaces/123/stories/0/0/1234",
"action": "published",
"space_id": 123,
"story_id": 1234,
"full_slug": "test"
}
Il nostro endpoint si occuperà di invalidare il path della singola entry, la home page ed eventualmente le pagine correlate (nell'esempio proposto, anche eventuali categorie o page parents).
Alla successiva visita della pagina, NextJS recupererà i dati aggiornati e rigenererà la pagina statica.
Anche per questo endpoint potete impostare delle misure di protezione (lato codice, come la verifica dello Space ID nell'esempio proposto), oppure lato Firewall di Vercel.
Condividilo sui Social, o contattami per farmi qualche domanda o discuterne insieme!
Oppure, se ti va, puoi offrirmi un caffè o una pizza! :)