Skip to main content

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.

storyblok space creation screen

Next, go to the "Content" tab and open the homepage. You should see a screen like this.

storyblok space home page

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.

storyblok 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

storyblok live editing

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.

storyblok prod version

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

WRITTEN BY

Alex Hramovich

Alex Hramovich

TechLead at FocusReactive