Build a Custom App using the Storyblok Management API and Nuxt.js

Contents
    Try Storyblok

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

    In this guide, we will demonstrate how to get started with the Storyblok Management API while creating a custom application using Nuxt 2. Together, we will build a simple application directly within Storyblok where users have a complete overview of the SEO data for all stories which can be revised and saved by combining both the power of the Storyblok Management API and Nuxt 2. All the code written for this guide can be found in this repository.

    For this specific custom app, the SEO data is stored using the SEO field type app and for this example we will just be covering two text fields: meta title and meta description. Once you’re inside your newly created Storyblok space, navigate to the Apps section, find the SEO app and click install.

    Create Nuxt.js App

    Create a new Nuxt.js project using the create-nuxt-app package and be sure to include TailwindCSS and dotenv.

    npm init nuxt-app sb-custom-app
    cd sb-custom-app and npm i 

    Install TailwindCSS

    We are using Tailwind for quick styling of the application. The official guide on connecting TailwindCSS with Nuxt.js can be read here.

    Connect Nuxt.js App with Storyblok 

    Install the @storyblok/nuxt-auth package to connect and authenticate your Nuxt.js project with Storyblok. You can read more about that here

    After installing ngrok we can run it in our terminal using the path from which your current project resides relative to where ngrok was installed:

    ../downloads/ngrok http 3000

    Inside the Partner Portal, navigate to the Apps tab and select 0auth2 and paste the ngrok URL from your running ngrok server. In the input with the label URL to your app, we need to append: /auth/connect/storyblok to the end of the URL. And for the label 0Auth2 callback URL /auth/callback has to be appended as well.

    Reminder:

    If you restart the NGROK service, you must refresh all NGROK URLs inside the .env file and in the app settings in Storyblok.

    We need the auth callback URI, Client Secret and the Client ID from the 0Auth 2 settings for the .env file, which should look like this:

    BASE_URL=http://localhost:3000
    CLIENT_TOKEN=Qz54787e89uirk68fnvj
    CONFIDENTIAL_CLIENT_ID=eE4jd9zS9kvztN0GVE2pqtJQ==
    CONFIDENTIAL_CLIENT_SECRET=4VDoYrBjGZBnGb+xa41s+Tk7fRoHT+D+qhJELsHOU8xgE0DsqlHKLxoGWwspvAHAri37nSo5ThnWpfTQJInejQ==
    CONFIDENTIAL_CLIENT_REDIRECT_URI=http://eda8-24-214-130-95.ngrok.io/auth/callback

    And inside nuxt.config.js we should have the following:

    modules: [
        ['storyblok-nuxt', {
          accessToken: 'process.env.CLIENT_TOKEN',
          cacheProvider: 'memory'
        }],
        '@nuxtjs/dotenv',
        '@nuxtjs/axios',
        '@nuxtjs/auth-next',
        [
          '@storyblok/nuxt-auth',
          {
            id: process.env.CONFIDENTIAL_CLIENT_ID,
            secret: process.env.CONFIDENTIAL_CLIENT_SECRET,
            redirect_uri: process.env.CONFIDENTIAL_CLIENT_REDIRECT_URI
          }
        ],
      ],

    Install Custom Application

    At this point, navigate to the Partner Portal again and Copy Link and paste into a different tab in the browser. It should prompt you to install this app into one of your Spaces.

    Settings page for a Custom Storyblok App

    Custom App Settings

    Custom Storyblok App Installation Interface

    Installation Screen

    Be sure to have both the Nuxt.js app and the NGROK tunnel running with an established connected to the Storyblok app. Clear cookies if you are receiving a tunnel error when visiting the custom app in the left sidebar.

    Next we can retrieve some data from Storyblok, so add or replace the pages/index.vue with the following code:

    pages/index.vue
    <script>
    import { getStories } from "./../lib/utils";
    
    export default {
      data() {
        return {
          storiesWithData: [],
          pageSize: 3, // stories per page
          current: 1, // current page
          total: null, // all stories
        };
      },
    
      async mounted() {
        if (window.top == window.self) {
          // Redirect if outside Storyblok
          window.location.assign("https://app.storyblok.com/oauth/app_redirect");
        } else {
          // Init the stories
          await this.setPage(1);
        }
      },
    
      computed: {
        totalPages: function () {
          return Math.ceil(this.total / this.pageSize);
        },
      },
    
      methods: {
        setPage: async function (pageNumber) {
          this.current = pageNumber;
          const { stories, total } = await getStories(
            this.$route.query.space_id,
            this.pageSize,
            this.current
          );
          this.total = total;
          this.storiesWithData = stories;
        },
      },
    };
    </script>
    
    <template>
      <div class="container mx-auto">
        <div class="w-full flex justify-center py-4">
          <nav class="inline-flex rounded" aria-label="pagination">
            <button
              v-for="pageNumber in totalPages"
              :key="pageNumber"
              @click="setPage(pageNumber)"
              class="rounded p-1 px-3 text-sm bg-gray-300 hover:bg-gray-400 mx-1"
            >
              <span>{{ pageNumber }}</span>
            </button>
          </nav>
        </div>
        <div class="container">
          <div class="w-100 px-20 flex flex-col justify-center">
            <div>
              <story-card
                v-for="story in storiesWithData"
                v-bind:key="story.id"
                v-bind:data="story"
              />
            </div>
          </div>
        </div>
      </div>
    </template>

    And instead of adding our functions to the index.vue file, we can extract the 2 main functions of the application by creating a lib folder in the root of the project and create a utils.js file so we can export and distribute the methods where needed throughout the application:

    lib/utils.js
    import axios from 'axios'
    
    export const getStories = async (spaceId, pageSize, currentIndex) => {
      let page = await axios.get(`/auth/spaces/${spaceId}/stories?per_page=${pageSize}&page=${currentIndex}&sort_by=name:asc`)
      let stories = [];
      await Promise.all(
        page.data.stories.map(story => {
          return axios
            .get(`/auth/spaces/${spaceId}/stories/${story.id}`)
            .then((res) => {
                if (!res.data.story.content.seo) {
                  res.data.story.content.seo = {
                    title: '',
                    description: '',
                    plugin: 'seo_metatags',
                  }
                }
                stories.push(res.data.story);
            })
            .catch((error) => {
              console.log(error);
            })
        })
      )
      stories.sort((a, b) => {
        const ids = page.data.stories.map(s => s.uuid)
        return ids.indexOf(a.uuid) - ids.indexOf(b.uuid)
      })
      return { stories, total: page.data.total }
    }
    
    export const saveData = async (spaceId, story, publish) => {
      let storyData = {story: {content: story.content, unpublished_changes: !publish }}
    
      if (publish) {
        storyData.publish = 1
      }
      
      try {
        const rest = await axios.put(`/auth/spaces/${spaceId}/stories/${story.id}`, storyData)
        if (rest.status === 200) {
          return rest.data.story
        }
      } catch (err) {
        console.log(err, 'error')
      }
      return false
    }

    Above, we are mapping over all the stories and request data in the getStories function by calling a GET request to:

    /auth/spaces/${this.$route.query.space_id}/stories/${story.id}

    There’s no need for a token when using the Storyblok Management API Client with OAuth, as the auth/ URL handles that for us. We then map over all the stories and retrieve the stories, paginate them and then request each story one by one. When using the Storyblok Management API it isn't possible to retrieve the content with the stories/ endpoint, so we need to query each entry after retrieving the list of items of the current page and set the SEO content we want to target by creating the title and description string variables. This is made possible via the SEO app we installed previously.

    We will use the saveData function to read the content body within our stories and use a PUT for updating the inputs in the 'draft' and 'published' JSON of Storyblok using the same /auth URL.

    To get the final part of the application functioning, let's create a new component called StoryCard.vue and paste the following:

    components/StoryCard.vue
    <script>
    import { saveData } from "../lib/utils";
    
    export default {
      props: {
        blok: {
          type: Object,
          required: true,
        },
      },
      props: ["data"],
    
      data() {
        return {
          story: {},
          changed: false,
        };
      },
    
      mounted() {
        if (this.data)
          if (!this.data.content.body) {
            this.data.content.body = [];
          }
        this.story = this.data;
      },
    
      methods: {
        async saveStoryData(publish) {
          const save = await saveData(
            this.$route.query.space_id,
            this.story,
            publish
          );
    
          if (save) {
            this.story = save;
            this.changed = true;
            setTimeout(() => {
              this.changed = false;
            }, 2000);
          }
        },
      },
    };
    </script>
    
    <template>
      <div class="container mx-auto my-10">
        <div
          :key="story.id"
          v-if="story && story.content"
          class="py-4 px-6 bg-white shadow-md rounded my-2 mx-2"
        >
          <div class="flex justify-between">
            <div class="block text-gray-800 text-md font-bold">Story name: "{{ story.name }}"</div>
            <div class="rounded p-1 px-3 text-sm bg-gray-300">{{ story.published ? "Published story" : "Unpublished story" }}{{ story.unpublished_changes ? " with unpublished changes" : "" }}</div>
          </div>
          <div>
            <label
              class="block text-gray-800 text-sm font-bold mb-2 mt-5"
              for="title"
              >Meta Title</label
            >
            <input
              type="text"
              v-model="story.content.seo.title"
              class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
              id="name"
            />
          </div>
          <div>
            <label
              class="block text-gray-800 text-sm font-bold mb-2 mt-5"
              for="description"
              >Meta Description</label
            >
            <textarea
              type="text"
              v-model="story.content.seo.description"
              class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
              id="name"
            />
          </div>
    
          <div v-if="changed" class="bg-green-700 mt-2">
            <div class="max-w-7xl mx-auto py-3 px-3 sm:px-6 lg:px-8">
                <p class="ml-3 w-full font-medium text-white text-right">
                  Changes Saved
                </p>
            </div>
          </div>
    
          <div v-if="!changed" class="flex justify-end mt-4">
            <button
              class="mx-2 bg-gray-800 hover:bg-blue-900 text-gray-100 py-2 px-4 rounded"
              @click="saveStoryData()"
            >
              Save Draft
            </button>
            <button
              class="mx-2 bg-gray-800 hover:bg-blue-900 text-gray-100 py-2 px-4 rounded"
              @click="saveStoryData(true)"
            >
              Publish
            </button>
          </div>
        </div>
      </div>
    </template>

    Inside the saveStoryData method we are importing the saveData function with the parameters it takes from the utils file. And for a better user experience we also want to trigger that an action has in fact happened when either draft or published changes have occurred once an input is edited and an action happens; here we are doing this by simulating a notification when the boolean value is changed.

    Open the App from the Sidebar. If you are opening it for the first time, you will be asked if you want to give access to this app. We should now have something that looks similar to the following:

    Interface of the Custom Storyblok App

    Conclusion


    This guide showcased an example of creating custom applications in Storyblok. Using these techniques you can create your own apps and host them for authenticated users using technologies you’re most comfortable with. This opens the door for you to create custom apps and features to solve your users’ problems. Using our Management API you are better able to handle the content of your space.