Add a headless CMS to Next.js in 5 minutes

Contents
    Try Storyblok

    Storyblok is the first headless CMS that works for developers & marketers alike.

    Hint:

    Please note that this article has already been updated to match Storyblok V2. If you haven’t already started using it, you can find out how to make the switch here.

    Important:

    Our Next.js Ultimate Tutorial series is using Next.js 12. If you are using Next.js 13, there may be some breaking changes.

    In this short tutorial, we will explore how to integrate Storyblok into a Next.js application, and enable the live preview in the Visual Editor. We will use Storyblok's React SDK to load our data using the Storyblok API and enable the live editing experience. We will also see how to enable Next.js preview mode to preview our changes when working with static site generation.

    Hint:

    If you’re in a hurry, have a look at our live demo in Stackblitz!

    Environment Setup

    Requirements

    To follow this tutorial there are the following requirements:

    • Basic understanding of JavaScript, React and Next.js

    • Node.js LTS version

    • An account in the Storyblok App

    Setup the project

    Let's start by creating a new Next.js application.

    npx create-next-app basic-nextjs
    # or
    yarn create next-app basic-nextjs

    Let's also install Storyblok's React SDK. This package allows us to interact with the Storyblok API and will help us to enable the real-time editing experience inside the Visual Editor. We also need to install axios that work along with the package to send requests behind the scenes.

    cd basic-nextjs
    
    npm install @storyblok/react axios
    # or
    yarn add @storyblok/react axios

    Then, let's start the development server

    npm run dev
    # or
    yarn dev

    Open your browser in http://localhost:3000. You should see the following screen.

    NextJs Landing Page

    Connecting to Storyblok

    Now that we kickstarted our project, we need to create a connection to Storyblok and enable the Visual Editor.

    To initialize the connection, go to pages/_app.js and add the following code.

    pages/_app.js
    ...
    
    import { storyblokInit, apiPlugin } from "@storyblok/react";
    
    storyblokInit({
      accessToken: "your-preview-token",
      use: [apiPlugin]
    });
    
    ...
    hint:

    For spaces created in the United States, you have to set the region parameter accordingly: { apiOptions: { region: 'us' } }.

    This function will do mainly two things: Initialize the connection with Storyblok (enabling the Visual Editor) and provide an API client that we can use to retrieve content from the platform, to be used in our application.

    In the Storyblok app, Create a new space and retrieve your Preview token {3} from your Space Settings {1} under Access Tokens {2}. Add the token as the accessToken directly, or from an .env file.

    Hint:

    If you want to use an env variable, you should follow this official Next.js tutorial. You should have a next.config.js file in your project, and add the env config storyblokApiToken: process.env.STORYBLOK_API_TOKEN, in order to set accessToken: process.env.storyblokApiToken in your storyblokInit function.

    app.storyblok.com
    Storyblok Preview Token
    1
    2
    3

    Fetching Data

    To fetch data, we will make use of Next.js getStaticProps function. Add the following code to the pages/index.js file. This will load our home story using the client we just initialized (getStoryblokApi) and display the name of the story.

    pages/index.js
    import Head from "next/head"
    import styles from "../styles/Home.module.css"
    
    import { getStoryblokApi } from "@storyblok/react"
    
    export default function Home(props) {
      return (
        <div className={styles.container}>
          <Head>
            <title>Create Next App</title>
            <link rel="icon" href="/favicon.ico" />
          </Head>
    
          <header>
            <h1>
              { props.story ? props.story.name : 'My Site' }
            </h1>
          </header>
    
          <main>
            
          </main>
        </div>
      )
    }
    
    export async function getStaticProps() {
      // home is the default slug for the homepage in Storyblok
      let slug = "home";
    
      // load the draft version
      let sbParams = {
        version: "draft", // or 'published'
      };
    
      const storyblokApi = getStoryblokApi();
      let { data } = await storyblokApi.get(`cdn/stories/${slug}`, sbParams);
    
      return {
        props: {
          story: data ? data.story : false,
          key: data ? data.story.id : false,
        },
        revalidate: 3600, // revalidate every hour
      };
    }

    Setting the Preview Url

    In the Storyblok app, go to Settings {1} > Visual Editor {2}, and set the Location (default environment) {3} to https://localhost:3010/.

    app.storyblok.com
    Preview URL
    1
    2
    3

    Preview URL

    For this tutorial, we will set up our dev server with an HTTPS proxy, to use a secure connection with the application. We'll use port 3010, so the URL to access our website will end up being https://localhost:3010/.

    HINT:

    If you don’t know how to setup an HTTPS proxy, you can read this guide to configure it on macOS, or this guide if you are a Windows user.

    Let's open our Home Story now by clicking on Content {1} and then the Home Story {2}.

    app.storyblok.com
    Storyblok Content
    1
    2

    Storyblok Content

    Setting the Real Path

    We need to set the Real Path to / {1} because we want to display the story with the slug home under our base path / and not /home. Once you set the preview URL and the real path, you should be able to see your development server inside Storyblok showing the name of the story Home.

    app.storyblok.com
    Set the Real Path
    1

    Set the Real Path

    Creating and loading the components

    In the next step, we have to create the components that already exist in the Home story: Page, Teaser, Grid and Feature. Create a new folder components with the following files:

    components/Page.js
    import { storyblokEditable, StoryblokComponent } from "@storyblok/react";
    
    const Page = ({ blok }) => (
      <main {...storyblokEditable(blok)}>
        {blok.body.map((nestedBlok) => (
          <StoryblokComponent blok={nestedBlok} key={nestedBlok._uid} />
        ))}
      </main>
    );
    
    export default Page;
    components/Teaser.js
    import { storyblokEditable } from "@storyblok/react";
    
    const Teaser = ({ blok }) => {
      return <h2 {...storyblokEditable(blok)}>{blok.headline}</h2>;
    };
    
    export default Teaser;
    components/Grid.js
    import { storyblokEditable, StoryblokComponent } from "@storyblok/react";
    
    const Grid = ({ blok }) => {
      return (
        <div className="grid" {...storyblokEditable(blok)}>
          {blok.columns.map((nestedBlok) => (
            <StoryblokComponent blok={nestedBlok} key={nestedBlok._uid} />
          ))}
        </div>
      );
    };
    
    export default Grid;
    components/Feature.js
    import { storyblokEditable } from "@storyblok/react";
    
    const Feature = ({ blok }) => (
      <div className="column feature" {...storyblokEditable(blok)}>
        {blok.name}
      </div>
    );
    
    export default Feature;

    By using storyblokEditable with any component, we can make them loaded and clickable in the Storyblok Visual Editor, and we can edit its properties in real-time.

    To load the right content in Next.js, we will need a dynamic element that can resolve the component names we get from Storyblok API to the actual components in our Next.js application. For this purpose, we use the StoryblokComponent feature included in @storyblok/react. You can see how it works in the Page and Grid components, where we have the body and columns properties that can load any type of component.

    Finally, we need to configure the components identified by StoryblokComponent, and link them to their representation in the Storyblok space. To do that, let's go back to pages/_app.js and add a new parameter to storyblokInit call.

    pages/_app.js
    ...
    
    import { storyblokInit, apiPlugin } from "@storyblok/react";
    import Feature from "../components/Feature";
    import Grid from "../components/Grid";
    import Page from "../components/Page";
    import Teaser from "../components/Teaser";
    
    const components = {
      feature: Feature,
      grid: Grid,
      teaser: Teaser,
      page: Page,
    };
    
    storyblokInit({
      accessToken: "your-preview-token",
      use: [apiPlugin],
      components,
    });
    
    ...

    In order to display the components, let's include StoryblokComponent in our return function in the pages/index.js file:

    pages/index.js
    ...
    
    import { getStoryblokApi, StoryblokComponent } from "@storyblok/react"
    
    export default function Home(props) {
        const story = props.story
       
        return (
          <div className={styles.container}>
            <Head>
              <title>Create Next App</title>
              <link rel="icon" href="/favicon.ico" />
            </Head>
       
            <header>
              <h1>
                { story ? story.name : 'My Site' }
              </h1>
            </header>
       
             <StoryblokComponent blok={story.content} />
          </div>
        )
      }
    
    ...

    Once you loaded the components you should be able to see the available components in your Storyblok Live Preview. It should show the Grid component {1} and the Teaser component {2}. If you change their order in Storyblok and click Save, they should dynamically switch their order on the page.

    app.storyblok.com
    Storyblok Components
    1
    2

    Storyblok Components

    Enabling the Visual Editor & Live Preview

    So far we loaded our content from Storyblok, but we aren't able to directly select and edit the different components. To enable Storyblok's Visual Editor, we need to connect the Storyblok Bridge. In order to do that, we will use the useStoryblokState React hook provided by @storyblok/react, so we enable live updating for the story content.

    Let's load this hook in our pages/index.js file.

    pages/index.js
    ...
    
    import {
      useStoryblokState,
      getStoryblokApi,
      StoryblokComponent,
    } from "@storyblok/react";
    
    export default function Home({ story }) {
      story = useStoryblokState(story);
    
      return (
        <div className={styles.container}>
          <Head>
            <title>Create Next App</title>
            <link rel="icon" href="/favicon.ico" />
          </Head>
    
          <header>
            <h1>{story ? story.name : "My Site"}</h1>
          </header>
    
          <StoryblokComponent blok={story.content} />
        </div>
      );
    }
    
    ...

    By returning the revalidate value in the getStaticProps function, we enable our static content to be updated dynamically every 3600 seconds (1 hour) with Next.js Incremental Static Regeneration feature.

    Once we added the Storyblok Bridge and the Storyblok hook, you should be able to click the Teaser and Feature components and see the live editing updates {1}.

    app.storyblok.com
    Storyblok Live Preview
    1

    Storyblok Live Preview

    Dynamic route generation with getStaticPaths

    Next.js offers the getStaticPaths functionality to pre-render dynamic routes. If you export an async function called getStaticPaths from a page that uses dynamic routes, Next.js will statically pre-render all the paths specified by getStaticPaths. Let's create a few different pages, by creating a folder pages {1} with two stories about {2} and contact similar to the home story.

    app.storyblok.com
    New Folder
    1
    2

    New Folder

    app.storyblok.com
    New Page
    1
    2

    New Page

    Catch all routes in Next.js: [...slug].js

    To create all routes programmatically, we need to create a file pages/[...slug].js to dynamically generate routes for all stories inside Storyblok. Add the following code to the file.

    pages/[...slug].js
    import Head from "next/head";
    import styles from "../styles/Home.module.css";
    
    import {
      useStoryblokState,
      getStoryblokApi,
      StoryblokComponent,
    } from "@storyblok/react";
    
    export default function Page({ story }) {
      story = useStoryblokState(story);
    
      return (
        <div className={styles.container}>
          <Head>
            <title>{story ? story.name : "My Site"}</title>
            <link rel="icon" href="/favicon.ico" />
          </Head>
    
          <header>
            <h1>{story ? story.name : "My Site"}</h1>
          </header>
    
          <StoryblokComponent blok={story.content} />
        </div>
      );
    }
    
    export async function getStaticProps({ params }) {
      let slug = params.slug ? params.slug.join("/") : "home";
    
      let sbParams = {
        version: "draft", // or 'published'
      };
    
      const storyblokApi = getStoryblokApi();
      let { data } = await storyblokApi.get(`cdn/stories/${slug}`, sbParams);
    
      return {
        props: {
          story: data ? data.story : false,
          key: data ? data.story.id : false,
        },
        revalidate: 3600,
      };
    }
    
    export async function getStaticPaths() {
      const storyblokApi = getStoryblokApi();
      let { data } = await storyblokApi.get("cdn/links/");
    
      let paths = [];
      Object.keys(data.links).forEach((linkKey) => {
        if (data.links[linkKey].is_folder || data.links[linkKey].slug === "home") {
          return;
        }
    
        const slug = data.links[linkKey].slug;
        let splittedSlug = slug.split("/");
    
        paths.push({ params: { slug: splittedSlug } });
      });
    
      return {
        paths: paths,
        fallback: false,
      };
    }

    On this dynamic page generation file, we have a function getStaticPaths that reads all links from Storyblok. You can check what is returned from the links endpoint by appending your preview token to the following link: https://api.storyblok.com/v1/cdn/links?&starts_with=pages&version=draft&token=preview-token

    Then, we check each link that is returned from Storyblok, and it creates a page for every link except for any folder or the home story itself, since that is already created in our index.js file.

    Finally, we can read the slug of each page in getStaticProps with params.slug.join('/) (Line 31). Here the slug is an array because of the catch all rule in Next.js. Using that slug, we're requesting the correct entry from Storyblok.

    Learn:

    It’s also possible to make the catch-all routes optional by putting the ...slug in double brackets: [[...slug]].js. The main difference between catch-all and optional catch-all routes is that with optional, the route without the parameter is also matched.

    Hint:

    In order not to have to change the real path & slugs constantly, it makes sense to already create the correct folder structure in Storyblok and use the catch all routes.

    Adding Preview Mode

    Next.js offers a preview mode feature, to allow you to load either a draft version or the published version, depending on what you need.

    To add Next.js preview mode we will need to create two files pages/api/preview.js and pages/api/exit-preview.js.

    pages/api/preview.js
    export default async function preview(req, res) {
      const { slug = "" } = req.query;
      // get the storyblok params for the bridge to work
      const params = req.url.split("?");
    
      // Check the secret and next parameters
      // This secret should only be known to this API route and the CMS
      if (req.query.secret !== "MY_SECRET_TOKEN") {
        return res.status(401).json({ message: "Invalid token" });
      }
    
      // Enable Preview Mode by setting the cookies
      res.setPreviewData({});
    
      // Set cookie to None, so it can be read in the Storyblok iframe
      const cookies = res.getHeader("Set-Cookie");
      res.setHeader(
        "Set-Cookie",
        cookies.map((cookie) =>
          cookie.replace("SameSite=Lax", "SameSite=None;Secure")
        )
      );
    
      // Redirect to the path from entry
      res.redirect(`/${slug}?${params[1]}`);
    }

    The preview.js file will check for a secret token, that needs to be set on the server-side, and if the token matches, it will set the Next.js preview cookie. Since Storyblok loads your site inside an iframe, we need to change the SameSite policy of this cookie to None, so the preview cookie can also be read inside the iframe.

    To exit the preview mode, we will need a api/exit-preview.js file with the following code:

    pages/api/exit-preview.js
    export default async function exit(req, res) {
      const { slug = "" } = req.query;
      // Exit the current user from "Preview Mode". This function accepts no args.
      res.clearPreviewData();
    
      // set the cookies to None
      const cookies = res.getHeader("Set-Cookie");
      res.setHeader(
        "Set-Cookie",
        cookies.map((cookie) =>
          cookie.replace("SameSite=Lax", "SameSite=None;Secure")
        )
      );
    
      // Redirect the user back to the index page.
      res.redirect(`/${slug}`);
    }

    Adding a preview URL

    Once we created these files, we need to add a new preview URL for the https://localhost:3010/api/preview path. We can do it through the Settings menu, as we did before, or inside the Visual Editor, we can click on Change URL {1} and on Add or change preview URLs {2}.

    app.storyblok.com
    Add new preview URL
    1
    2

    Add new preview URL

    Inside the settings we will add two new preview URLs: Preview and Exit Preview. The first preview URL will be
    https://localhost:3010/api/preview?secret=MY_SECRET_TOKEN&slug=
    The secret token needs to be the same as the token you set in your preview function in Next.js.

    Once you open this route with the correct secret token, you should be redirected to the current draft version of the story and the Next.js preview cookie will be set. Storyblok will automatically append the slug for the current story for you. To exit preview mode just add another preview URL for https://localhost:3010/api/exit-preview?slug=. Once you open this exit preview path, the cookie for the preview will be reset and you should see the published version if you're loading the published version outside of the preview mode or if you're using the public token.

    Loading the Draft Version only in Preview Mode

    If we want to see the published version and then only see the editable draft version once we're inside this preview mode, we need to adapt the way our pages/index.js file is loading the bridge. We will only initialize the bridge if we're in preview mode.

    pages/index.js
    import Head from "next/head";
    import styles from "../styles/Home.module.css";
    
    import {
      useStoryblokState,
      getStoryblokApi,
      StoryblokComponent,
    } from "@storyblok/react";
    
    export default function Home({ story, preview }) {
      story = useStoryblokState(story, {}, preview);
    
      return (
        <div className={styles.container}>
          <Head>
            <title>Create Next App</title>
            <link rel="icon" href="/favicon.ico" />
          </Head>
    
          <header>
            <h1>{story ? story.name : "My Site"}</h1>
          </header>
    
          <StoryblokComponent blok={story.content} />
        </div>
      );
    }
    
    export async function getStaticProps(context) {
      let slug = "home";
    
      let sbParams = {
        version: "published",
      };
    
      if (context.preview) {
        sbParams.version = "draft";
      }
    
      const storyblokApi = getStoryblokApi();
      let { data } = await storyblokApi.get(`cdn/stories/${slug}`, sbParams);
    
      return {
        props: {
          story: data ? data.story : false,
          key: data ? data.story.id : false,
          preview: context.preview || false,
        },
        revalidate: 3600,
      };
    }

    The useStoryblokState hook will evaluate the preview context to initialize (or not) the connection with the bridge. We'll do it only if we're inside the preview mode.

    Make sure you publish your stories once to make them available in the published versions of the Storyblok client. Now if you enter the preview mode {1} and change some content, you should see the live updates {2}.

    app.storyblok.com
    Inside Preview Mode
    1
    2

    Inside Preview Mode

    Save, but don't publish the changes. Then enter the Exit Preview Url {1}. Now you should see the published content {2} instead of the draft version.

    app.storyblok.com
    Published Version
    1
    2
    3

    Published Version

    Using Server Side Rendering

    If you don't want to use static rendering and the Preview Mode, you can also use Next.js server-side rendering. This allows you to check for the _storyblok parameter that is passed to the visual editor iframe and the preview in a new window. Checking for this parameter, allows you to load your draft content only if you're inside Storyblok without the preview mode.

    pages/index.js
    ...
    
    export async function getServerSideProps(context) {
      // get the query object
      const insideStoryblok = context.query._storyblok;
      const shouldLoadDraft = context.preview || insideStoryblok;
    
      let slug = "home";
    
      let sbParams = {
        version: "published", // or 'draft'
      };
    
      if (shouldLoadDraft) {
        sbParams.version = "draft";
      }
    
      const storyblokApi = getStoryblokApi();
      let { data } = await storyblokApi.get(`cdn/stories/${slug}`, sbParams);
    
      return {
        props: {
          story: data ? data.story : false,
          key: data ? data.story.id : false,
          preview: shouldLoadDraft || false,
        },
      };
    }
    
    ...

    Since getStaticProps doesn't have access to the request parameters, because it's building the site statically, we have to change to getServerSideProps in our index.js file. This means that instead of having pre-built pages, the pages are generated on the server on every request.

    Inside the context object of getServerSideProps we have access to the query and we can check if the _storyblok parameter is present. Then, we do the same as we would do with the preview mode and load the draft version only if the parameter is present.

    Conclusion

    And that's it! We learned how to integrate Storyblok into a Next.js project. We saw how to manage and consume content using the Storyblok API, and how to enable a real-time visual experience using the Visual Editor. We went through different features that Next.js offers to create great user experiences: Static site generation, server-side rendering, preview mode, etc.

    Next Part:

    In the next part of this series, we will see how to start making a real website with Next.js and Storyblok.

    Resource Link
    Storyblok Next.js Ultimate Tutorial https://www.storyblok.com/tp/nextjs-headless-cms-ultimate-tutorial
    Stackblitz Demo https://stackblitz.com/edit/nextjs-5-minutes
    Next.js Technology Hub Storyblok Next.js Technology Hub
    Storyblok React SDK storyblok/storyblok-react
    Next.js Documentation Next.js Docs

    Developer Newsletter

    Want to stay on top of the latest news and updates in Storyblok?
    Subscribe to Code & Bloks - our headless newsletter.

    An error occurred. Please get in touch with marketing@storyblok.com

    Please select at least one option.

    Please enter a valid email address.

    This email address is already registered.

    Please Check Your Email!

    Almost there! To confirm your subscription, please click on the link in the email we’ve just sent you. If you didn’t receive the email check your ’junk folder’ or