DEV Community

Cover image for How to build a dynamic website with Next.js 15 App Router, React 19, Storyblok, and Bun (+ Typescript)
Roberto B.
Roberto B.

Posted on • Edited on

How to build a dynamic website with Next.js 15 App Router, React 19, Storyblok, and Bun (+ Typescript)

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
Enter fullscreen mode Exit fullscreen mode

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 use 14 keyword instead of latest

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

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
Enter fullscreen mode Exit fullscreen mode

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,
});
Enter fullscreen mode Exit fullscreen mode

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';
Enter fullscreen mode Exit fullscreen mode
  • Component Imports: these imports include your custom React components (DefaultPage, Teaser, HeroSection, and FallbackComponent), 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,
});
Enter fullscreen mode Exit fullscreen mode
  • accessToken: the accessToken is retrieved from an environment variable (NEXT_PUBLIC_STORYBLOK_TOKEN), ensuring secure access to your Storyblok space.
  • use: the apiPlugin is registered to enable fetching content from Storyblok's API.
  • components: this object maps Storyblok content types (defined in your space, such as teaser, default-page, and hero-section) to their corresponding React components (Teaser, DefaultPage, and HeroSection). This ensures that the correct component is rendered based on the content type name or the component name.
  • enableFallbackComponent: when set to true, 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({...});
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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>
}
Enter fullscreen mode Exit fullscreen mode

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,
Enter fullscreen mode Exit fullscreen mode

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;

Enter fullscreen mode Exit fullscreen mode

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 the src/lib/storyblok.ts file.
  components: {
    teaser: Teaser,
    "default-page": DefaultPage,
    "hero-section": HeroSection,
  },
Enter fullscreen mode Exit fullscreen mode
  • create your component in src/components/Teaser.tsx:
export default function Teaser({ blok }: any) {
  return <>
    { blok.headline }
  </>;
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;

Enter fullscreen mode Exit fullscreen mode

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>
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

Your Next.js 15 app, with App router, working in the Visual Editor

Top comments (7)

Collapse
 
best_codes profile image
Best Codes

Nice! I love that you're using bun!

Collapse
 
robertobutti profile image
Roberto B.

I'm using Bun for everything within JavaScript 🐰

Collapse
 
best_codes profile image
Best Codes

I use Bun for building, testing, installing packages, running, etc.
So, yeah, pretty much everything, it's quite fast!

Thread Thread
 
robertobutti profile image
Roberto B.

I've built JS package with bun, using the bun test. Super!

Thread Thread
 
best_codes profile image
Best Codes

Yeah me too, a few of my npm libraries are fully bundles and tested with bun :)

Collapse
 
schemetastic profile image
Schemetastic (Rodrigo)

Someone is working very hard these days 👏🏽

Collapse
 
theklr profile image
Kevin R

Really hope storyblok team references this for app router.