In this article, I would like to share with you how to create a dynamic web app with Next.js 15's App Router, React 19, and the Storyblok CMS, all powered by Bun for lightning-fast performance. This comprehensive guide walks you through setup, integration, and rendering dynamic content—perfect for modern developers.
Here, I created a minimalistic Nextjs template application to integrate Storyblok. You can read more in the README: https://github.com/roberto-butti/my-nextjs15-approuter-storyblok
Key takeaways from this guide
- Learn how to set up a new Next.js app using Bun as the runtime, bundler, and package manager.
- Install and configure the latest Storyblok React SDK for seamless CMS integration.
- Understand how to manage your Storyblok API token securely using environment variables.
- Discover how to fetch data from Storyblok and render it dynamically with Next.js 15's App Router.
- Enable the Storyblok Visual Editor for an enhanced content editing experience.
- Bonus: Explore how to use TypeScript definitions for strongly typed Storyblok components.
The tools
In this article, we’re combining some of the most cutting-edge tools in web development to create a powerful, efficient, and scalable web project:
- Bun: more than just a runtime, Bun simplifies JavaScript development with lightning-fast performance, a built-in bundler, and an efficient package manager, an all-in-one tool. It's designed to save developers time while delivering exceptional speed.
- Storyblok: as a headless CMS, Storyblok provides a flexible and user-friendly platform for managing content. With its Visual Editor and robust API, it empowers developers to integrate content dynamically while giving content creators an intuitive interface to work with.
- Next.js App Router: the App Router in Next.js 15 unlocks the full potential of React Server Components, offering an innovative way to handle dynamic routing. It enables seamless integration of server-side and client-side rendering, improving performance and developer experience.
Set up your Next.js 15 project
Using Bun's command-line utility, create a Next.js 15 project:
bunx create-next-app@latest
The
latest
keyword will allow you to create a Next.js 15 project. If, for some reason, you prefer to use the Next.js 14 you can use14
keyword instead oflatest
Executing the create-next-app
command, you need to answer some questions. For starting a basic project with TypeScript, here the options:
✔ What is your project named? my-nextjs15-storyblok
✔ Would you like to use TypeScript? Yes
✔ Would you like to use ESLint? No
✔ Would you like to use Tailwind CSS? No
✔ Would you like your code inside a src/
directory? Yes
✔ Would you like to use App Router? (recommended) Yes
✔ Would you like to use Turbopack for next dev? No
✔ Would you like to customize the import alias (@/* by default)? No
Once the command is executed, you can jump into the new directory my-nextjs15-storyblok
where you can find your Next.js project with all the dependencies already installed.
cd my-nextjs15-storyblok
Enabling the HTTPS
For checking and testing the installation and starting the HTTPS protocol, you can use the dev
script using the experimental HTTPS option:
bun run dev --experimental-https
As a developer, you should start a local HTTPS server when working with the Storyblok Visual Editor because the editor requires a secure connection to function correctly. Here's why:
- Browser security requirements: modern browsers enforce strict security protocols for features like iframe communication, which the Visual Editor relies on. HTTPS ensures that the content loaded in the iframe (your local app) is secure and trusted, enabling the Visual Editor to work seamlessly.
- Matching production environments: most production environments use HTTPS for secure communication. Running your local development server over HTTPS mirrors this setup, helping you catch potential issues early, such as mixed content warnings or problems with third-party integrations.
- Storyblok’s integration policies: Storyblok’s Visual Editor is designed to work with secure connections, ensuring that the embedded content and API requests are encrypted. Without HTTPS, the Visual Editor might not function as intended or could block certain features.
Install Storyblok React SDK v4
To integrate Storyblok, install its Storyblok React SDK:
bun add @storyblok/react@4
This SDK (version 4) is fully compatible with Next.js 15 and supports the App Router's features.
Configure Storyblok
Sign in to Storyblok, create a space, and copy the API token (the Preview access token) from the "Settings -> Access Tokens" section.
If you are not familiar with creating space with Storyblok, here are some links:
- Creating a space in Storyblok (yes, you can use the Community Plan for free): https://www.storyblok.com/docs/guide/getting-started
- How to retrieve and generate access tokens: https://www.storyblok.com/faq/retrieve-and-generate-access-tokens
We can set up the environment variables once you have your preview access token.
In your project root, create a .env.local
file, and add:
NEXT_PUBLIC_STORYBLOK_TOKEN=your_token_here
So now you have your Storyblok SDK installed and your access token set up. Now, we can initialize the Storyblok connection in our Next.js project,
Initialize Storyblok in our project
Initialize Storyblok and register components in a src/lib/storyblok.ts
file:
import DefaultPage from '@/components/DefaultPage';
import Teaser from '@/components/Teaser';
import HeroSection from '@/components/HeroSection';
import FallbackComponent from '@/components/FallbackComponent';
import { apiPlugin, storyblokInit } from '@storyblok/react/rsc';
export const getStoryblokApi = storyblokInit({
accessToken: process.env.NEXT_PUBLIC_STORYBLOK_TOKEN,
use: [apiPlugin],
components: {
teaser: Teaser,
"default-page": DefaultPage,
"hero-section": HeroSection,
},
enableFallbackComponent: true,
customFallbackComponent: FallbackComponent,
});
This code snippet demonstrates how to initialize Storyblok in a React application, set up components for dynamic rendering, enable API integration, and configure a fallback component. Here's a breakdown:
Imports
import DefaultPage from '@/components/DefaultPage';
import Teaser from '@/components/Teaser';
import HeroSection from '@/components/HeroSection';
import FallbackComponent from '@/components/FallbackComponent';
import { apiPlugin, storyblokInit } from '@storyblok/react/rsc';
- Component Imports: these imports include your custom React components (
DefaultPage
,Teaser
,HeroSection
, andFallbackComponent
), which will be rendered dynamically based on Storyblok content. - Storyblok features:
-
storyblokInit
: Initializes Storyblok in the app. -
apiPlugin
: A plugin that allows API interaction with Storyblok.
-
Storyblok Initialization
export const getStoryblokApi = storyblokInit({
accessToken: process.env.NEXT_PUBLIC_STORYBLOK_TOKEN,
use: [apiPlugin],
components: {
teaser: Teaser,
"default-page": DefaultPage,
"hero-section": HeroSection,
},
enableFallbackComponent: true,
customFallbackComponent: FallbackComponent,
});
-
accessToken
: theaccessToken
is retrieved from an environment variable (NEXT_PUBLIC_STORYBLOK_TOKEN
), ensuring secure access to your Storyblok space. -
use
: theapiPlugin
is registered to enable fetching content from Storyblok's API. -
components
: this object maps Storyblok content types (defined in your space, such asteaser
,default-page
, andhero-section
) to their corresponding React components (Teaser
,DefaultPage
, andHeroSection
). This ensures that the correct component is rendered based on the content type name or the component name. -
enableFallbackComponent
: when set totrue
, this enables using a fallback component if a component is not mapped or recognized. -
customFallbackComponent
: specifies a custom component (FallbackComponent) to display as the fallback. This is helpful for gracefully handling unmapped or unexpected content, such as showing an error message or placeholder content.
Exported Function
export const getStoryblokApi = storyblokInit({...});
This function initializes Storyblok and provides access to its API, allowing your application to interact with Storyblok's content delivery system dynamically.
Load Storyblok provider
In src/components/StoryblokProvider.tsx
file you can initialize the Storyblok client:
'use client';
import { getStoryblokApi } from '@/lib/storyblok';
export default function StoryblokProvider({ children }: any) {
getStoryblokApi(); // Re-initialize on the client
return children;
}
Then, in the src/app/layout.tsx
file, you can load the StoryblokProvider
:
import type { Metadata } from "next";
import StoryblokProvider from '@/components/StoryblokProvider';
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<StoryblokProvider>
<html lang="en">
<body>
{children}
</body>
</html>
</StoryblokProvider>
);
}
Set Up the App Router with a catch-all route
Next.js 15’s App Router uses a file-based routing system powered by the app directory. Let’s set up dynamic routes for Storyblok content.
We are going to create a "catch-all" route for creating the page.tsx
file into the src/app/[slug]/
directory.
The src/app/[slug]/page.tsx
file can be:
import styles from "../page.module.css";
import { getStoryblokApi } from '@/lib/storyblok';
import { StoryblokStory } from '@storyblok/react/rsc';
async function fetchData(slug: string) {
const storyblokApi = getStoryblokApi();
return storyblokApi.get('cdn/stories/' + slug, { version: 'draft' });
}
export default async function StoryPage({params}
: {
params: Promise<{ slug: string }>
}) {
const slug = (await params).slug
console.log(slug)
const { data } = await fetchData(slug);
return (
<div>
<StoryblokStory story={data.story} />
</div>
);
}
Creating frontend components
The Content-Type component
First, you can create the frontend component to render the Storyblok content type. For example, I have the default-page
Storyblok content type mapped into the DefaultPage
React component (via the storyblok.ts
file):
The src/components/DefaultPage.tsx
file:
import { storyblokEditable, StoryblokServerComponent } from '@storyblok/react/rsc';
export default function DefaultPage({ blok }: any) {
return <main {...storyblokEditable(blok)}>
{blok.body.map((nestedBlok: any) => (
<StoryblokServerComponent blok={nestedBlok} key={nestedBlok._uid} />
))}
</main>
}
For now I'm using
any
for defining the type of the data. Later we will see how to improve this code for a better type checking.
The fallback component
Storyblok is very flexible. The Storyblok SDK via the StoryblokServerComponent
and the StoryblokStory
React components load dynamically React components based on the structure and the nested components defined in Storyblok. If a React component is not yet implemented on the frontend side, a fallback component can be loaded as a placeholder. The configuration of the fallback behavior is in our src/lib/storyblok.ts
file in the storyblokInit
function via:
enableFallbackComponent: true,
customFallbackComponent: FallbackComponent,
So, the fallback component is in src/components/FallbackComponent.tsx
:
import { storyblokEditable } from '@storyblok/react/rsc';
const FallbackComponent = ({ blok }: any) => {
return (
<h2 data-cy="fallback" {...storyblokEditable(blok)}>
This component does not exists: {blok.component }
</h2>
);
};
export default FallbackComponent;
Your first Nestable component
For example, if you have a teaser
Nestable component in Storyblok with a field named headline
you can:
- load the components in the
storyblokInit
function in thesrc/lib/storyblok.ts
file.
components: {
teaser: Teaser,
"default-page": DefaultPage,
"hero-section": HeroSection,
},
- create your component in
src/components/Teaser.tsx
:
export default function Teaser({ blok }: any) {
return <>
{ blok.headline }
</>;
}
As you can see, we use the any type definition extensively in the components and input arguments. Now, we want to improve the type definitions to enhance type checking.
BONUS: using TypeScript and automatic generation of type definitions
As you can notice, probably your editor (I'm using Zed editor) will highlight some TypeScript warning for the type checking.
Now we are going to solve this issue, generating automatically all the types and the interfaces needed by your project, based on the component structure defined in Storyblok. Amazing!
I strongly recommend installing and using the Storyblok CLI. If you're exploring or actively using Storyblok, investing time to learn this powerful command-line tool is highly worthwhile. It's like a Swiss Army knife for Storyblok, offering a wide range of functionalities to manage content and components efficiently. One standout feature is its ability to generate type definitions.
More info about Storyblok CLI : https://www.storyblok.com/tp/storyblok-cli-best-practices#generating-types
First, you have to export the component JSON structure into a JSON file:
storyblok pull-components --space <your-space-id>
Replace <your-space-id>
with your Space ID.
Then, once you have the file , typically components.<your-space-id>.json
, you can generate the d.ts
file,:
storyblok generate-typescript-typedefs --sourceFilePaths ./components.<your-space-id>.json --destinationFilePath ./src/components/component-types-sb.d.ts
If you take a look of your component-types-sb.d.ts
file you will see a lot of auto-generated types and interfaces.
For example:
export interface HeroSectionStoryblok {
headline?: string;
overlay?: number | string;
text_color?: "light" | "dark";
subheadline?: string;
buttons?: ButtonStoryblok[];
horizontal_alignment?: "left" | "center";
vertical_alignment?: "start" | "center" | "end";
full_height?: boolean;
background_image?: AssetStoryblok;
background_video?: AssetStoryblok;
component: "hero-section";
_uid: string;
[k: string]: any;
}
The nice thing is that you can update this structure based on the component defined directly in Storyblok.
Now let's use this type definition for example in a new component src/components/HeroSection.tsx
:
import { storyblokEditable } from '@storyblok/react/rsc';
import { HeroSectionStoryblok } from '@/components/component-types-sb'
interface HeroSectionProps {
blok: HeroSectionStoryblok;
}
const HeroSection = ({ blok} : HeroSectionProps) => {
return (
<div data-cy="hero-dection" {...storyblokEditable(blok)}>
<h2 >
{blok.headline }
</h2>
<h3 >
{blok.subheadline }
</h3>
<img src={blok.background_image?.filename + "/m/300x200"} width="300" height="200" />
</div>
);
};
export default HeroSection;
Another exmaple, i want to improve the DefaultPage component using for example the autogenerated DefaultPageStoryblok
and the SbBlokData
from Storyblok the JS client:
import { storyblokEditable, StoryblokServerComponent } from '@storyblok/react/rsc';
import { DefaultPageStoryblok } from '@/components/component-types-sb'
import { SbBlokData } from '@storyblok/js';
export default function DefaultPage({ blok }: DefaultPageStoryblok) {
return <main {...storyblokEditable(blok)}>
{blok.body.map((nestedBlok: SbBlokData) => (
<StoryblokServerComponent blok={nestedBlok} key={nestedBlok._uid} />
))}
</main>
}
Now you can implement all your component, placing them in the src/components
directory and listing them in the storyblokInit
function in src/lib/storyblok.ts
file.
Run your project
To test your project, be sure to have your server up and running. To start the server:
bun run dev --experimental-https
Visit https://localhost:3000/home to see your dynamic content rendered via Storyblok and the App Router.
If you want to use the Storyblok Visual Editor you can set the visual editor using the
https://localhost:3000
as explained here: https://www.storyblok.com/docs/guide/getting-started#configuring-the-visual-editor
Top comments (7)
Nice! I love that you're using bun!
I'm using Bun for everything within JavaScript 🐰
I use Bun for building, testing, installing packages, running, etc.
So, yeah, pretty much everything, it's quite fast!
I've built JS package with bun, using the bun test. Super!
Yeah me too, a few of my npm libraries are fully bundles and tested with bun :)
Someone is working very hard these days 👏🏽
Really hope storyblok team references this for app router.