Storyblok with Next.js app router guide
The release of Next.js app router marked a new milestone for web applications. It brought life and usage to React server components and solved many existing issues for SRR & SSG applications. However, there is a downside to it, as the entire market now needs to adapt, which will take some time. In this article, we will explore how to use StoryBlok and Next.js app router. A similar article exists on the official source, but we will go a step further, giving you the ability to use live preview and server components at the same time.
Storyblok space
To achieve our goals, we need to create a new space in StoryBlok. Please log in to their website and create a new trial space.
Next, go to the "Content" tab and open the homepage. You should see a screen like this.
Github repository
If you don't want to do everything manually, you can simply fork this repository, as it fully reflects what will be described in the article.
Next.js app initialization
We will need the latest version of Next.js (at the time of writing this article - 14.1.0), let's create it:
npx create-next-app@latest
Activate visual editor
To make all of this work, you will need a local HTTPS proxy server. Below is an example specifically for macOS users (windows version).
brew install mkcert
mkcert -install
mkcert localhost
After that, add the library and script that will handle the proxying.
yarn add -D local-ssl-proxy
And, finally, add a script to the package.json
:
{
"scripts": {
...,
"proxy": "local-ssl-proxy --source 3010 --target 3000 --cert localhost.pem --key localhost-key.pem"
}
}
To complete the setup, we need to reference https://localhost:3010/
in the space settings.
Connect StoryBlok to Next.js
Now we need to connect the data to the project itself, and for that, we will need the @storyblok/react
library.
yarn add @storyblok/react
From this point on, there will be a difference from the official documentation, and here's why. The official version suggests using either a client-side component, preserving the full power of the live editor, or using a full server-side componentx. However, in that case, we would lose the live preview functionality, or at least part of it.
We don't want to lose any of these features, so we will use both options at the same time. Here's how to do it.
The first thing we need to do is update our .env.local
variables. One of them is needed for accessing StoryBlok data, and the other is used to determine if we are in preview mode.
SB_PREVIEW_TOKEN=XXX
NEXT_PUBLIC_IS_PREVIEW=true
Now we need to initialize StoryBlok on the server side:
// app/layout.tsx
import { storyblokInit, apiPlugin } from "@storyblok/react/rsc";
storyblokInit({
accessToken: process.env.storyblokApiToken,
use: [apiPlugin],
apiOptions: {
region: "eu",
},
});
We also need to create a Storyblok provider component to initialize the client on the client side:
"use client";
import React from "react";
import { storyblokInit, apiPlugin } from "@storyblok/react/rsc";
interface IStoryblokProviderProps {
children: React.ReactNode | React.ReactNode[];
}
storyblokInit({
accessToken: process.env.storyblokApiToken,
use: [apiPlugin],
apiOptions: {
region: "eu",
},
});
const StoryblokProvider: React.FunctionComponent<IStoryblokProviderProps> = ({
children,
}) => {
return children;
};
export default StoryblokProvider;
Attention, specifying the region is mandatory, otherwise you will get an error!
Now that we have everything ready to work with data, we need to prepare a few components for preview functionality. Simply create files and copy this code into them. Remember that there is a link to the GitHub repository above, where all of this already exists.
// app/components/Feature.jsx
import { storyblokEditable } from "@storyblok/react/rsc";
const Feature = ({ blok }) => (
<div {...storyblokEditable(blok)}>{blok.name}</div>
);
export default Feature;
// app/components/Grid.jsx
import { storyblokEditable, StoryblokComponent } from "@storyblok/react/rsc";
const Grid = ({ blok }) => {
return (
<div {...storyblokEditable(blok)}>
{blok.columns.map((nestedBlok) => (
<StoryblokComponent blok={nestedBlok} key={nestedBlok._uid} />
))}
</div>
);
};
export default Grid;
// app/components/Page.jsx
import { storyblokEditable, StoryblokComponent } from "@storyblok/react/rsc";
const Page = ({ blok }) => (
<main {...storyblokEditable(blok)}>
{blok.body.map((nestedBlok) => (
<StoryblokComponent blok={nestedBlok} key={nestedBlok._uid} />
))}
</main>
);
export default Page;
// app/components/Teaser.jsx
import { storyblokEditable } from "@storyblok/react/rsc";
const Teaser = ({ blok }) => {
return <h2 {...storyblokEditable(blok)}>{blok.headline}</h2>;
};
export default Teaser;
After the components have been created, we need to register them in Storyblok. To do this, we will create an index.js
file that will simply gather all our components into one object.
// app/components/index.js
import Page from "./Page";
import Teaser from "./Teaser";
import Feature from "./Feature";
import Grid from "./Grid";
export const COMPONENTS = {
page: Page,
teaser: Teaser,
feature: Feature,
grid: Grid,
};
And now let's simply add this object to both places where we initialize Storyblok (layout.js, StoryblokProvider.js).
storyblokInit({
accessToken: process.env.SB_PREVIEW_TOKEN,
use: [apiPlugin],
components: COMPONENTS,
apiOptions: {
region: "eu",
},
});
Now we are ready to create our first page. To capture all the routes of the application, we need to create a folder [[...slug]]
inside the app
folder. Inside this folder, create a file page.tsx
.
//app/[[...slug]]/page.tsx
import {
ISbStoriesParams,
ISbStoryData,
StoryblokComponent,
StoryblokStory,
} from "@storyblok/react/rsc";
import { redirect } from "next/navigation";
import StoryblokProvider from "@/components/StoryBlokProvider";
// in case of draft mode we want to force dynamic rendering and static in case of published
export const dynamic = isDraftModeEnabled ? "force-dynamic" : "error";
const isDraftModeEnabled = process.env.NEXT_PUBLIC_IS_PREVIEW === "true";
type Props = {
params: { slug?: string[] };
searchParams: { [key: string]: string | string[] | undefined };
};
const fetchStoryBySlug = () => {
return {};
};
const Home = async ({ params }: Props) => {
const path = params.slug;
const { story } = await fetchStoryBySlug(path);
if (!story) {
redirect("/404");
}
if (isDraftModeEnabled) {
return (
<StoryblokProvider>
<div>Draft mode</div>
</StoryblokProvider>
);
}
return <div>Published mode</div>;
};
export default Home;
The code above demonstrates how we separate the logic for draft and published content based on ENV variables. In simple terms, if we are in preview mode, we retrieve content from the server, which is rendered on each request, and we also enable the Storyblok provider, which activates live preview. In the case of a production ENV, we serve static content that was generated during the build process and does not contain any additional logic.
To make the page work, we need to add a data loading function, and here is another nuance. One of the main features of the app router is how flexible we can control the cache using path or tag revalidation processes. To maintain full control over this, we will not use the Storyblok Client; instead, we will make the request ourselves using fetch.
The final page should look like this:
//app/[[...slug]]/page.tsx
import {
ISbStoriesParams,
ISbStoryData,
StoryblokComponent,
StoryblokStory,
} from "@storyblok/react/rsc";
import StoryblokProvider from "@/components/StoryBlokProvider";
const isDraftModeEnabled = process.env.NEXT_PUBLIC_IS_PREVIEW === "true";
export const dynamic = isDraftModeEnabled ? "force-dynamic" : "error";
export async function fetchStoryBySlug(
slug?: string[]
): Promise<{ story: ISbStoryData }> {
const contentVersion = isDraftModeEnabled ? "draft" : "published";
// check StoryBlok cache documentation for more information
const cv = new Date().getTime() / 1000;
const searchParamsData: ISbStoriesParams = {
token: process.env.NEXT_PUBLIC_SB_PREVIEW_TOKEN,
cv,
version: contentVersion,
};
const searchParams = new URLSearchParams(
searchParamsData as Record<string, string>
);
const { story } = await fetch(
`https://api.storyblok.com/v2/cdn/stories/${
slug?.join("/") || ""
}?${searchParams.toString()}`,
{
next: {
tags: ["page"],
},
}
).then((res) => res.json());
return {
story: story,
};
}
type Props = {
params: { slug?: string[] };
searchParams: { [key: string]: string | string[] | undefined };
};
const Home = async ({ params }: Props) => {
const correctPath = params.slug;
const { story } = await fetchStoryBySlug(correctPath);
if (!story) {
return <div>404</div>;
}
if (isDraftModeEnabled) {
return (
<StoryblokProvider>
<StoryblokStory story={story} />
</StoryblokProvider>
);
}
return <StoryblokComponent blok={story.content} />;
};
export default Home;
Now we are ready to build, proxy, and go to the CMS to see how it all works.
yarn dev
yarn proxy
As you can see, the click-to-edit flow is working, and we can see real-time changes. At the same time, if we run the page in production mode, we will see that the preview does not work, indicating that there is no unnecessary code in the codebase.
Conclusion
This article covers the concept of saving live preview in Storyblok when using React server components with Next.js app router.
However, please note that if you want to use this solution in production, make sure you have two projects and two preview tokens. This way, the Storyblok preview token will not be accessible in production.
Additionally, if you want to secure your preview project, you can do so based on secret token validation provided by Storyblok. Here is a link that explains how to do it - LINK.
If you'd like migrate to Headless CMS or you are building it from scratch - contact us, we are Storyblok agency and we can set up a free consulatation around the project you have in mind