Build a Custom App using the Storyblok Management API and Nuxt.js
Storyblok is the first headless CMS that works for developers & marketers alike.
This article is outdated. Please refer to our Nuxt 3 Starter for Custom Sidebar Applications while we are working on updating this tutorial.
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 (opens in a new window) and Nuxt 2. All the code written for this guide can be found in this repository (opens in a new window) .
For this specific custom app, the SEO data is stored using the SEO field type app (opens in a new window) 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 (opens in a new window) and click install.
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 (opens in a new window) .
Connect Nuxt.js App with Storyblok
Install the @storyblok/nuxt-auth (opens in a new window) package to connect and authenticate your Nuxt.js project with Storyblok. You can read more about that here (opens in a new window) .
After installing ngrok (opens in a new window) 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.
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
}
],
],
![Settings page for a Custom Storyblok App](http://a.storyblok.com/f/88751/2880x1680/8a69dfbf97/app-storyblok-com_partner_.png/m/840x0/filters:quality(90))
Custom App Settings
![Custom Storyblok App Installation Interface](http://a.storyblok.com/f/88751/2880x1680/b0ddf4c472/app-storyblok-com_-copy.png/m/840x0/filters:quality(90))
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:
<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:
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:
<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](http://a.storyblok.com/f/88751/2880x1680/effa94e085/app-storyblok-com_.png/m/840x0/filters:quality(90))
Conclusion
This guide showcased an example of creating custom applications in Storyblok (opens in a new window) . 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 (opens in a new window) you are better able to handle the content of your space.
Related Resources | Links |
---|---|
Storyblok Management API | https://www.storyblok.com/docs/api/management |
Github Repository | https://github.com/storyblok/custom-app-examples/tree/main/seo-data-app |
TailwindCSS with Nuxt.js | https://tailwindcss.com/docs/guides/nuxtjs |
How to Build a Serverless Custom App with Netlify | https://www.storyblok.com/tp/serverless-custom-app-netlify |
Custom Application | https://www.storyblok.com/docs/plugins/custom-application |
SEO App | https://www.storyblok.com/apps/seo |
Storyblok Nuxt.js Auth | https://www.npmjs.com/package/@storyblok/nuxt-auth |
ngrok | https://ngrok.com/ |
Nuxt.js 2.x Documentation | https://nuxtjs.org/ |