How to Build a Serverless Custom App with Vercel

Contents

Storyblok allows you to build custom applications or tools that enhance your editor with your custom functionality. For these custom applications to have access to your Storyblok content and to be able to change content with the Management API, we will first need to authenticate the application with an OAuth flow. You can do that with your own server, for example with frameworks like Express, Koa, Hapi, or Fastify, but there is also the option to do it without a server by making use of serverless functions. This allows you to deploy the application statically and authenticate the application with Storyblok through the serverless function. This tutorial will focus on how to use serverless functions to handle OAuth login flows for your Storyblok application. To enable sessions the tutorial uses the Supabase database to store the OAuth session information across serverless functions. For the OAuth flow, the Grant library with its Vercel handler is used to simplify the OAuth process.

WARN:

Since this tutorial uses the grant library for the OAuth flow, it requires cookies to be accessible in iframes. This is a problem in Safari since they started blocking third-party cookies this year. In order for the app to be functional in Safari, the Website Tracking setting in Safari must be enabled.

You can find the code for the end result of this tutorial in the Github repository: serverless-custom-app-starter

Creating a new Storyblok App

To create a new custom application you need to be signed up as a Partner. Head into the partner portal, click on Apps {1} and then click the New button {2}. As App type select Sidebar {3} and click Create {4}.

Creating a new App

To get started we will clone the workflow app starter template: github.com/storyblok/storyblok-workflow-app. This is a basic Nuxt application that will show our logged in Storyblok user and workflow stages.

$ git clone https://github.com/storyblok/storyblok-workflow-app my-auth-app
$ cd my-auth-app
$ npm install

Let's also already install all the dependencies necessary for this tutorial

$ npm install --save 
  @supabase/supabase-js
  qs
  axios
  lodash
  grant
  crypto-js
  uuid
  storyblok-js-client

Since our OAuth flow will be handled through serverless functions we can remove the @storyblok/nuxt-auth module in the nuxt.config.js file.

Creating a Serverless Function

To authenticate our application, we will need a serverless function that handles the OAuth authentication. You can create such functions on different providers like Vercel, Netlify, AWS Lambda, Azure Function, or Google Cloud Functions. We will choose Vercel for this tutorial, but any other provider should work similarly.

To create serverless functions with Vercel, we have to create an api folder in our project root. If you're using Next.js the api folder is located in /pages/api. You can also follow the detailed Vercel docs for more information.

Your first serverless function

Let’s start by creating a Hello World example. Create a file api/hello-world.js with the following content:

export default (request, response) => {
 response.send({
   data: `hello ${request.query.name || 'world'}`,
 });
}

Now let’s deploy this first stage of the project to Vercel. Run the following commands:

$ npm run build
$ npm run generate
$ vercel

If you don't have a project connected yet, Vercel will ask you to set up a new project. After deploying the repository, there is no connection to Storyblok set up yet. But we can already call the API path of the serverless function, we just created and should get a response.

Open https://your-app.name.vercel.app/api/hello-world?name=Homer and you should see the following response:

{ "data": "hello Homer" }

Great job, that's already your first serverless deployed function!

Creating a Serverless OAuth Function

The next step is to create a function that can authenticate the application with Storyblok. We will make use of the grant package to do that.

To work with grant, we will need a few different files. Let's create a folder auth in our root directory to store our configuration and utility files for the OAuth flow. Inside the auth folder create a file auth/grantconfig.js. This file stores the configuration for our grant client with the Storyblok OAuth URLs and tokens.

auth/grantconfig.js

const SHA256 = require('crypto-js/sha256')
const { v4: uuid } = require('uuid')
const codeIdentifier = uuid()

module.exports = {
  config: {
    defaults: {
      origin: 'http://localhost:3000',
    },
    storyblok: {
      key: process.env.CONFIDENTIAL_CLIENT_ID,
      secret: process.env.CONFIDENTIAL_CLIENT_SECRET,
      redirect_uri: process.env.CONFIDENTIAL_CLIENT_REDIRECT_URI,
      authorize_url: 'https://app.storyblok.com/oauth/authorize',
      access_url: 'https://app.storyblok.com/oauth/token',
      callback: '/api/callback',
      oauth: 2,
      response: ['tokens'],
      scope: 'read_content write_content',
      // https://github.com/simov/grant#custom-parameters
      custom_params: {
        response_type: 'code',
        code_challenge: SHA256(codeIdentifier).toString(),
        code_challenge_method: 'S256',
        state: codeIdentifier,
      },
    },
  },
  session: {
    name: 'my-cookie-name',
    secret: 'my-cookie-secret-123',
    cookie: {
      sameSite: 'none',
      path: '/',
      httpOnly: 'true',
      secure: true,
    },
  },
}

When looking at the configuration, you can see that there are a few environment variables. You can store your required environment variables in the .env-template file by renaming it to .env

STORYBLOK_CLIENT_ID="your-client-id=="
STORYBLOK_CLIENT_SECRET="your-client-secret=="
STORYBLOK_CLIENT_REDIRECT_URI=https://5ae4933bbe67.ngrok.io/auth/callback

Let's get the correct tokens and ids from Storyblok. Head into the partner portal and under Apps {2} and click on your app name. There you will find the client id {3} and client secret {4}.

For the Live URLs, we need to add our Vercel deployment URL with the /connect/storyblok path for the URL to your app {5} like htttps://my-app.vercel.app/connect/storyblok.

We also need to add the OAuth callback URL {6} with the /connect/storyblok/callback/ path like htttps://my-app.vercel.app/connect/storyblok/callback

If you want to keep a local version running you can add a local ngrok tunnel URL under development {7}. This allows you to access your localhost app when opening the app with a ?dev=1 parameter.

Storyblok App Settings

First, we need to set all these variables on Vercel, so our application can authenticate with Storyblok. Open your Vercel Dashboard by signing in at vercel.com. Open your current project {1} and click on Settings {2} and then on Environment Variables {3}. Add a new secret variable, with the matching name of the Starter, e.g. STORYBLOK_CLIENT_ID, then create a new reference name and enter the client id we just retrieved from Storyblok {5}. Do the same for the STORYBLOK_CLIENT_SECRET. We will also need to add a BASE_URL variable, which can be Plaintext. The BASE_URL should be URL of your Vercel deployment, e.g. https://my-custom-app.vercel.app

Vercel Environment Variables Setting

Once we have our environment variables set, we need to create a vercel.json file in the root of our project to redirect the callback routes to the correct serverless functions.

vercel.json

{
  "rewrites": [
    { "source": "/connect/storyblok", "destination": "/api/grant" },
    { "source": "/connect/storyblok/callback", "destination": "/api/callback" },
    { "source": "/auth/(.*)", "destination": "/api/storyblok" }
  ]
}

We want to redirect the connect/storyblok URL to our serverless function in the api/grant.js file and the connect/storyblok/callback to the api/callback.js function. Next, we need to create those two serverless function for the authentication.

grant.js

Inside the api folder create a grant.js file with the following code. First, we import the grant package and the grantconfig file. We set up the grant library with its Vercel handler with the following configuration. Grant then automatically redirects to our api/callback function, since we configured that in the callback property in the grantconfig.js file.

api/grant.js

const grant = require('grant')
const grantConfig = require('../auth/grantconfig.js')
const grantClient = grant.vercel(grantConfig)

module.exports = async (req, res) => {
  req.cookies = [req.cookies] // this is necessary because of a grant error on vercel, that tries to join the cookies
  await grantClient(req, res)
}

callback.js

The callback function will be called when the app authentication was successful with grant and always when we open the app, even if the access was already granted. When the user opens the app in Storyblok, Storyblok will call the OAuth callback URL and send a code and space_id parameter like described in the app authentication docs. So our callback needs to take this code parameter and request the access_token and the refresh token. If you haven't set up a session store, the default cookie store is used. This is not ideal in terms of security, which is why we will also set up a session store at the end of this tutorial. But let's continue without the session store for now. In our callback function, we will read the code and space_id parameters from our req.query object, that Storyblok sends when calling the OAuth callback. Then we will make use of a helper function getTokenFromCode to get an access_token and refresh_token from Storyblok. Lastly, we will redirect to our index path / with a space_id parameter, so our application will know which space to use.

const getTokenFromCode = require('../auth/util')

export default async function (req, res) {
  try {
    const { code, space_id } = req.query
    const { access_token, refresh_token } = await getTokenFromCode({
      code,
      provider: 'storyblok',
      grant_type: 'authorization_code',
    })
    
   res.redirect(`/?space_id=${space_id}`)

  } catch (e) {
    const statusCode = e.response ? e.response.status : 500
    res.status(statusCode).json({ error: e.message })
  }
}

Since we don't have the getTokenFromCode function yet, we need to create the helper function file util.js inside the auth folder with the following code. This sends a post request with the code parameter to Storyblok to retrieve the access_token and refresh_token for accessing the Storyblok API.

auth/util.js

auth/util.js

const qs = require('qs')
const axios = require('axios')
const grantConfig = require('./grantconfig.js')

module.exports = function getTokenFromCode({
  code,
  provider = 'storyblok',
  refresh_token,
  grant_type = 'authorization_code',
}) {
  const providerConfig = grantConfig.config[provider]
  return new Promise((resolve, reject) => {
    const requestConfig = {
      url: providerConfig.access_url,
      method: 'POST',
      headers: {
        'content-type': 'application/x-www-form-urlencoded',
      },
      data: qs.stringify({
        grant_type,
        code,
        refresh_token,
        client_id: providerConfig.key,
        client_secret: providerConfig.secret,
        redirect_uri: providerConfig.redirect_uri,
      }),
    }

    axios(requestConfig)
      .then((response) => {
        const { access_token, refresh_token } = response.data

        resolve({
          access_token,
          refresh_token,
        })
      })
      .catch(reject)
  })
}

With this set up we should already be able to authenticate our app. Deploy these functions and the Nuxt app by running npm run generate && vercel. When your app is deployed, open Storyblok and install the App if you haven't already. Then open the App from the Sidebar. When you open the app the first time, you will be asked if you want to give access to this app.

Storyblok Allow Authentication Window

When you approved the application, you should see the Nuxt application loaded inside Storyblok inside an Iframe. Since we haven't set up any loading of content yet in a serverless function, the app will not be able to load the Storyblok content just yet.

Iframe App in Storyblok

Setting up a Session

In order to store the code and access_tokens that we got in the api/callback.js serverless function, we need to set up a session store with a database, so other functions in our app also have access to those tokens and can request content from Storyblok. As a database, you can use Firebase or any other database that allows selecting, updating, or deleting of content. For this tutorial we chose Supabase. You can also check grants firebase store implementation if you would rather use Firebase. First, we need to sign up for a free account for app.supabase.io. Once you're logged in, you can create a new project {1} and then go into the table editor {2}. There we create a new table {3} called session_storage {4}. Do not include the primary key {5} for now, since we will create it in the next step.

Supabase create table

Now in our table {1}, we want to add a new column {2} with the name sid {3} of type varchar {4}. This will be our primary key {5}.

Supabase add sid

Then add another column {1} with the name json {2} of type json {3} and click save. That's all the setup we need in the database. Now the last thing is to retrieve our Supabase client id and secret.

Supabase add json

To retrieve the access keys, go into Settings {1} and then click on API {2}. There you will find your Supabase URL {3} and an anon public key {4}

Supabase keys

We need to add these environment variables in Vercels like we did before with the STORYBLOK_CLIENT_ID.

Once you added the variables to Vercel under Settings {1}, Environment Variables {2}, you should now have 5 environment variables set up {3}.

Vercel Environment Variables

store.js

The next step is to add the session_storage store to grant. Inside of the auth folder add a new store.js file with the following code:

auth/store.js

const { merge } = require('lodash')
const { createClient } = require('@supabase/supabase-js')

const supabaseUrl = process.env.SUPABASE_DB_URL
const supabaseKey = process.env.SUPABASE_PUBLIC_KEY
const supabase = createClient(supabaseUrl, supabaseKey)

module.exports = {
  // select
  get: async (sid) => {
    const { data: session_storage } = await supabase
      .from('session_storage')
      .select('sid, json')
      .eq('sid', sid)
    return session_storage && session_storage.length
      ? session_storage[0].json
      : {}
  },
  // upsert
  set: async (sid, json) => {
    // get the previous set item
    const { data: session_storage } = await supabase
      .from('session_storage')
      .select('sid, json')
      .eq('sid', sid)

    if (session_storage && session_storage.length) {
      const fullJson = merge(session_storage[0].json, json)

      // set the json object
      await supabase
        .from('session_storage')
        .update({ json: fullJson })
        .match({ sid })
    } else {
      await supabase
        .from('session_storage')
        .insert([{ sid, json }], { upsert: true })
    }
  },
  // delete
  remove: async (sid) => {
    await supabase.from('session_storage').delete().match({ sid })
  },
}

We will also need to install the Supabase client by running this command:

$ npm i --save @supabase/supabase-js

Finally, we adapt auth/grantconfig.js to use transport-session and our new store. Open the file and use the following settings:

auth/grantconfig.js

const SHA256 = require('crypto-js/sha256')
const { v4: uuid } = require('uuid')
const codeIdentifier = uuid()

module.exports = {
  config: {
    defaults: {
      origin: 'http://localhost:3000',
      transport: 'session'
    },
    storyblok: {
      key: process.env.CONFIDENTIAL_CLIENT_ID,
      secret: process.env.CONFIDENTIAL_CLIENT_SECRET,
      redirect_uri: process.env.BASE_URL + '/api/callback',
      authorize_url: 'https://app.storyblok.com/oauth/authorize',
      access_url: 'https://app.storyblok.com/oauth/token',
      callback: process.env.BASE_URL + '/api/callback',
      oauth: 2,
      response: ['tokens'],
      scope: 'read_content write_content',
      // https://github.com/simov/grant#custom-parameters
      custom_params: {
        response_type: 'code',
        code_challenge: SHA256(codeIdentifier).toString(),
        code_challenge_method: 'S256',
        state: codeIdentifier
      }
    }
  },
  session: {
    name: 'my-cookie-name',
    secret: 'my-cookie-secret-123',
    cookie: {
      sameSite: 'none',
      path: '/',
      httpOnly: 'true',
      secure: true
    },
    store: require('./store')
  }
}

Using the Store in the Serverless Functions

After retrieving the access_token, the next step is to store the token in our session. Let's open the api/callback.js file and adapt it. We will set up a session, which retrieves our session cookie and can save content to our Supabase database with the cookie name. If there is already an entry with content in the database, we add the space_id, code, access_token and refresh_token to the existing entry. Finally, we write to the database with the session.set() function. With the session.remove() function, we could remove the entire session from the database, when we don't need it anymore.

api/callback.js

const Session = require('grant/lib/session')({
  name: 'my-cookie-name',
  secret: 'my-cookie-secret-123',
  store: require('../auth/store'),
})
const getTokenFromCode = require('../auth/util')

export default async function (req, res) {
  req.cookies = [req.cookies] // this is needed because of a grant.js error on vercel
  const session = Session(req)

  try {
    const { code, space_id } = req.query
    // get the access token from the code parameter
    const { access_token, refresh_token } = await getTokenFromCode({
      code,
      provider: 'storyblok',
      grant_type: 'authorization_code',
    })

    // set the access token in the session to use in other serverless functions
    await session.set({
      storyblok: {
        space_id,
        code,
        access_token,
        refresh_token,
      },
    })

    // redirect to index and add the space_id so the app can set it
    res.redirect(`/?space_id=${space_id}`)
  } catch (e) {
    const statusCode = e.response ? e.response.status : 500
    res.status(statusCode).json({ error: e.message })
  }
}

Let's deploy these changes by running vercel in our command line. If we access our application and reload, there should already be some data written into our Supabase store. Let's see what was written inside our session in app.supabase.io table. The sid will be the name of the grant cookie and the JSON, will be the JSON returned from grant plus the extra entries (space_id, code, ... ) from Storyblok we just set with the session.set() function. If we inspect the JSON field in our session_storage table {1}, we can see the data that was written by grant, along with the data that we set in the api/callback.js, the space_id, application_code, access_token and refresh_token.

Supabase session

With the session set up, we can use this session in other serverless functions. If we take a look at the pages/index.vue file we can see that the application is requesting data in the loadStories function by calling a GET request to /auth/spaces/${this.spaceId}/stories .

pages/index.vue

   async loadStories() {
      // get the space id from URL and use it in requests
      return await axios
        .get(`/auth/spaces/${this.spaceId}/stories`)
        .then((res) => {
          this.perPage = res.data.perPage
          this.total = res.data.total
          this.stories = res.data.stories
        })
    },

So we need to set up the /auth/ routes with a serverless function that loads and returns content from Storyblok. We set up the route in vercel.json in the root of our project, so every request that goes to /auth/... is redirected to our api/storyblok.js serverless function.

vercel.json

{
    "rewrites": [
        { "source": "/connect/storyblok", "destination": "/api/grant" },
        { "source": "/connect/storyblok/callback", "destination": "/api/callback" },
        { "source": "/auth/(.*)", "destination": "/api/storyblok" },
    ]
}

And then create a storyblok.js file inside of the api folder to request the content. In the getEndpointUrl function, we remove the/auth/ part of the URLs and fill in the space_id from the session if it's not yet filled. If the user is requested, we send the request to the oauth/user_info endpoint, like described in the app auth docs. Then we check if the session contains the access_token and create a Storyblok Management API Client with the OAuth token. Finally, we request the data from Storyblok and return it as JSON to the client.

api/storyblok.js

const StoryblokClient = require('storyblok-js-client/dist/es5/index.cjs')
const Session = require('grant/lib/session')({
  name: 'my-cookie-name',
  secret: 'my-cookie-secret-123',
  store: require('../auth/store'),
})

function getEndpointUrl(url, session) {
  let endpointUrl = url.replace('/auth/', '')

  if (url.includes('user')) endpointUrl = 'oauth/user_info'

  return endpointUrl
}

export default async (req, res) => {
  req.cookies = [req.cookies]
  // get the current session
  const session = Session(req)
  const sessionEntry = await session.get()
  const url = getEndpointUrl(req.url, sessionEntry)

  if (sessionEntry && sessionEntry.storyblok && sessionEntry.storyblok.access_token) {
    // get storyblok request
    const sbClient = new StoryblokClient({
      oauthToken: `Bearer ${sessionEntry.access_token}`,
    })

    try {
      const { data, perPage, total } = await sbClient.get(url)
      res.json({ perPage, total, ...data })
    } catch (e) {
      const statusCode = e.response ? e.response.status : 500
      res.status(statusCode).json({ error: e.message })
    }
  } else {
    res.json({ error: 'No session found' })
  }
}

Now if we deploy the functions again by running the vercel command in the command line and then reload our app, we should see that it's working because our app is already showing the logged-in user {1}.

Serverless App Live
Resource Link
Github Repository for this tutorial github.com/storyblok/serverless-custom-app-starter