⚠️Draft Content

Storyblok Raises $80M Series C - Read News

What’s the True Total Price of Enterprise CMS? Find out here.

Skip to main content

Our API Documentation Journey with Nuxt.js, Netlify, and Github

Try Storyblok

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

After two years of closed and automated documentation writing we've got so many requests of developers that want to contribute to our documentation by providing examples, adding query parameter descriptions, or fixing a typo that we wanted to enable them to do so.

Before the launch, we had the docs directly integrated with our marketing site, with its content also managed with Storyblok and only editable by our own developers, marketers, and editors. To allow a collaborative approach on the documentation we now use Nuxt.js, Netlify and Github to manage the developer-centric documentation and deployment for the API specific docs of the Storyblok website.

Besides the obvious benefits for developers by splitting the documentation area from the marketing area, we can focus on each of those in more depth. If we have a closer look at both areas, the documentation, and the corporate website, we notice that their requirements go apparat real quick:

Comparison

API Documentation Inspiration

Before we started developing our documentation setup, we've reviewed multiple other resources and API documentation. Below you can find a list of API documentation that stood out (either technical, design, or feature wise) and that we've used as inspiration for our own.

We also considered using the OpenAPI specification but decided to go with basic Markdown and a folder structure instead.

The Documentation

Documentation start

Content Structure

The documentation content is structured in a folder to differentiate in different origins, eg. types of APIs (Content Delivery and Management in our case). In each origin we do have different language versions, followed by categories, groups, and methods resulting in a folder structure like this:

        
      - content
  - {origin} (content-delivery, management, ...)
    - {lang} (en, de, ...)
      - {category} (core-resources, examples, ...)
        - {group} (stories, datasources, useful, ...)
          - {section}.md (retrieve-one-story.md, ...)
  - {orgin}.{lang}.json (menu structure json)
    

Content File (Markdown and Frontmatter)

The basic structure of each Markdown ({section}.md) file contains a FrontMatter section that allows us to specify a title and additionally a sidebarTitle for each section.

        
      ---
title: The Sidebar Title & Headline
sidebarTitle: optional: This overrides the title
---

This is the method written content; the left area of a section.

;examplearea

This is the method example; the right area of a section.
    

Each Markdown file also contains two areas for a method, split by a separator string ;examplearea. This allows us to manage the content of a full method in only one file. Both areas support Markdown, Tables and Code examples the right area also does support Vue.js components that we use the generate our code examples.

The menu file in the root content folder will be used as the base for creating the sidebar and navigation for each origin. During the Node.js process, each entry will be enhanced by its Frontmatter title or sidebarTitle. Using a separate file rather than using the previous approach with Frontmatter allows us to reuse some sections in other categories more easily by referencing its contentPath.

        
      [
  ...,
  {
    "category": "Core Resources", // Category Headline used in Sidebar
    "items": [{ // items of this sideb
      "contentPath": "content-delivery/en/core-resources/stories/stories",
      "children": [{
        "contentPath": "content-delivery/en/core-resources/stories/the-story-object"
      },
      ...
      ]
    }]
  }
]
    

Node.js: Docgen

Docgen

Our docgen script started out as a fast prototyping setup, to try different content structure approaches independent to Nuxt.js but already does some preprocessing like preparing Markdown, splitting the sections and adding Prismjs to highlight code examples that were statically added.

In the package.json you can see that we've extended the default npm run dev command from nuxt to use node docgen.js watch=true & nuxt instead. The script watches on all changes of **/*.md and **/*.json inside the content/ folder and handles update, create and delete events.

The task of the docgen.js is to transform all **/*.md and **/*.json files from the content folder in two different sets of output files, that later can directly be consumed by Nuxt.js:

  1. One Routes JSON file for Nuxt.js generate
  2. One Menu JSON file per origin and language
  3. One Methods JSON file per origin and language

After the initial cold start, every content file is transformed into an object and added to the Docgen.sections object.

        
      // Debug log of the Docgen.sections object
{
  "content-delivery": {
    "en": {
      "core-resources/stories/stories": {},
      "core-resources/stories/the-story-object": {},
      ...
    }
  },
  "management": {
    ...
  }
}
    

If we now change or remove a file all that we need to do is to check for the incoming path, delete one specific property, and trigger the generation of the routes, menu, and methods JSON files.

Routes

The routes file is a string array that will be generated according to the folder structure of content. As we integrate the documentation in our marketing site we prepend the /docs/api/ path and also remove the default language en resulting in an array just like this:

        
      [
  "/docs/api/content-delivery/",
  "/docs/api/management/",
  "/de/docs/api/content-delivery",
  "/de/docs/api/management",
]
    

As we started with the default language only /de/docs/api/content-delivery is only an example of how it would look like if we add a new translation. Currently, only the first two entries are available. You can actually see that during our Netlify Deploy Preview which will be created on every Pull Request on Github.

The menu file which Docgen adds to the /static/ folder is only a copy of /content/{origin}.{lang}.json, before we switched to this JSON approach it generated a similar JSON structure enhanced with information of the content files.

Methods

The methods file is the result of combining and preparing all Markdown files per origin and language. Every markdown file in the content folder will be loaded and transformed into an object, where each object represents one section.

        
      {
  "contentPath": "content-delivery/en/topics/introduction", // Original Content Path
  "path": "topics/introduction", // Relative Content Path
  "lang": "en", // Language
  "origin": "content-delivery", // Origin
  "title": "The methods title or subtitle Frontmatter attribute", // title or subtitle from frontmatter
  "attributes": {}, // all attributes from frontmatter
  "content": "<p>Content for left part of method section already as HTML</p>", // Content before the splitting string ;examplearea
  "example": "<p>Content for right part of method section already as HTML but still with <RequestExample></RequestExample> Components</p>" // Content after the splitting string ;examplearea
}
    

The content and example property of the above object are the result of using marked to get HTML and Prismjs for highlighting. We still have unresolved Vue.js components in the HTML, eg. <RequestExample></RequestExample>.

Nuxt.js

Nuxt Js

We've chosen Nuxt.js as our presentation layer: it allows us to easily generate static HTML files by executing npm run generate and still enables us to use Vue.js. You might ask yourself why we did not opt for Gatsby, Next.js, or other React based setups. Well: Storyblok itself is built with Vue.js since we started 4.5 years ago, as we still love the simplicity and extensibility we wanted to stay in the ecosystem. We also considered VuePress as it is a great, lightweight setup for basic documentation and extensible enough to easily support our use-case, however, as our team is more familiar with Nuxt.js we went for it. Our presentation layer itself is completely replaceable, which allows us to still make a technology switch at any time; right now we're happy with the documentation result (even tho there is room for improvement) and the roadmap of Nuxt.js.

Consuming generated data

As our data layer is already available through our Docgen script, we can now focus on consuming it on the different levels of our Nuxt.js application. Since the Storyblok documentation will be generated using npm run generate we will have a look at the nuxt.config.js and its generate function. The routes.json, which was one of the results of our Docgen script enables us to generate all routes necessary, besides that we also require the methods and menu JSON files we've created to save some time during the generation process, as this is way faster than doing the requests in the pages fetch itself.

        
      // Require prepared routes.json so we know each available route.
const routes = require('./routes')

 ...
 generate: {
    routes(callback) {
      let enrichedRoutes = []
      routes.forEach(route => {
        const parts = route.split('/')
        // checks for default language and sets language to 'en'
        let lang = parts[1]
        let origin = parts[4]
        if (lang == 'docs') {
          lang = 'en'
          origin = parts[3]
        }

        // Require prepared content files and pass it as payload to each route.
        enrichedRoutes.push({
          route: route,
          payload: {
            sections: require(`${__dirname}/static/${origin}.methods.${lang}.json`),
            menu: require(`${__dirname}/static/${origin}.menu.${lang}.json`)
          }
        })
      })
      callback(null, enrichedRoutes)
    }
  },
  ...
    

In the pages folder of Nuxt.js, we do have two different set-ups. The main difference is that one of the routes contains the language parameter and the other page does not.

        
      - /api/docs/_origin.vue
- /_lang/api/docs/_origin.vue 
    

The payload we passed during generate can be consumed in the fetch method of Nuxt.js pages. Since we do run Nuxt.js itself during development with SSR (with actual requests) and in production generate (with requiring once) all we have to do it so check if the payload is available to us, other than that Nuxt.js already takes away all the work we have to do. We assign the menu and sections of the payload to our local variables so we can then commit them into the store. As we reuse the same content structure in multiple components we're not using props to pass information to child components, instead, we're using the Vuex Store to have access to the sections and menu wherever we need it in the application. For the case that we did not receive a payload, we fall back to two parallel axios GET requests.

        
        async fetch ({ store, params, payload }) {
    const origin = params.origin
    const lang = params.lang || 'en'

    let menu = null
    let sections = null

    if (!!payload) {
      menu = payload.menu
      sections = payload.sections
    } else {
      const base = process.client ? window.location.origin : 'http://localhost:3000'
      const [menuRes, sectionsRes] = await Promise.all([
        axios.get(base + `/${origin}.menu.${lang}.json`), 
        axios.get(base + `/${origin}.methods.${lang}.json`)])
      menu = menuRes.data
      sections = sectionsRes.data
    }

    store.commit('SET_LANG', lang)
    store.commit('SET_ORIGIN', origin)
    store.commit('SET_SECTIONS', sections)
    store.commit('SET_MENU', menu)
  }
    

Why fetch instead of asyncData?

As our setup uses Vuex as its central data repository and we want to make sure to utilize it while loading data we choose fetch as it has access to the Vuex store but does not set component data. Yes, asyncData would also have access to the store but should be used if you're about to set component data.

Layout

The layout of our documentation is completely different from that of our marketing site. The documentation should be able to stay the same even tho we relaunch our website version, so you as a developer will never have to search for an example or documentation part because we switched the URL structure or landing page layout. The Basic layout of the documentation is highly inspired by the Stripe documentation as we ourself had a great experience using it, and most of the time we got the information that it is the best practice API documentation example.

Layout

The great thing about Nuxt.js is that the layout structure above is directly reflected in the components we've created:

        
      - TopHeader
- SidebarNavigation
- Methods
  - MethodArea
    - MethodContent
    - MethodExample
    

TopHeader and SidebarNavigation contain all navigation elements and also allow you to set the language of the code examples we display. We choose to switch the navigation to a native <select> on smaller screens, where each option is treated like a jump link and <optgroup> are the content categories so we still have the grouping available for smaller devices.

MethodContent

The MethodContent component is fairly simple as it basically only uses v-html to output the content. As we've already prepared the content and transformed the Markdown to HTML during our Node.js process we've got rid of the complexity in Nuxt.js. Each section has at least one headline which includes the title, depending on the current index we switch from h2 to h1, but all of them contain an anchor element with a jump link to the current section.

MethodExample

The MethodExample is way more interesting than the MethodContent as it registers new dynamic Vue.js components with the prepared Markdown as template. This allows us to pass RequestExample as a child component to the component and if the template now contains a RequestExample component it will automatically be mounted and allows us to generate all those different code examples. Another great thing is that we can now use <div v-if></div> syntax in our markdown to hide/show content depending on store values, you can see that in action in Authentication where we added examples for our JavaScript client by using <div v-show="$store.state.technology == 'javascript'">...</div>.

RequestExample

The RequestExample can be used directly in our Content Markdown Files without having to import it in each file. Below you can see how that would look like in our Retrieve one Story Content File.

        
      ---
title: Retrieve one Story
---

Returns a story object for the `full_slug`, `id` or `uuid` if

...

;examplearea

...

<RequestExample url="https://api.storyblok.com/v1/cdn/stories/posts/my-third-post?token=ask9soUkv02QqbZgmZdeDAtt"></RequestExample>

...
    

The amount props our RequestExample component allows are fairly small, as we do not want to write every request in some strange format, but rather can copy and paste the actual Response in the docs. We use the actual request and our RequestMixin parses the necessary information from there. As we not only have GET requests but all kind HTTP Methods (POST, PUT, DELETE, and GETOAUTH; where the latter is an indicator that it is a GET request for the Management API which issues OAuth tokens), we added the prop httpMethod which is a basic string. As POST and PUT contain request objects we introduced the requestObject prop which is a basic JS object containing the request body object.

        
      {
  url: String,
  httpMethod: String,
  requestObject: Object,
  keepToken: Boolean
}
    

Now that we have all information in our RequestExample component we use v-show depending on the technology to load the JavaScript, Ruby, Java or any other example. Each of those examples uses RequestMixin, combined with marked and prismjs we can now output the dynamically generated and highlighted code examples depending on your choice. Besides that generic example approach, we've introduced the possibility to define the code example in a sdk/{technology}/methods.js file. If there is a client library that does not follow such a generic approach we can now override RequestExamples depending on the contentPath. The RequestExample part in MethodExample is fairly experimental and if you find full static generated way to achieve the same flexibility and syntax in the content files feel free to create a pull request on our Github repository, also if you have a suggestion on how this could be improved, feel free to open an issue for a discussion!

Hosting Layer

Documentation End

Github

We were looking for the best place to have developers come together to contribute to open source, run it locally and even allow them to fork the project to create their own documentation if they need it. The obvious choice for us was GitHub, as by their slogan: "GitHub brings together the world's largest community of developers to discover, share, and build better software.". GitHub allows us to make our documentation Nuxt.js set-up available to every developer, besides that it allows us to easily allow contribution via Pull Requests and it enables all of you to check if there were changes since the last time you used a feature.

Netlify

Netlify

This one was tricky as we've played a while with different services and even used AWS directly to deliver a preview for every Pull Request. We ultimately settled for Netlify as their ecosystem of integrations allows us to do exactly what we wanted. After sign-up we've enabled Netlifys Continuous Deployment by installing the Netlify App on Github for our repository.

Netlify Deploy Previews

The developer experience of Netlify just worked out for us. We were up and running in no time and now we're able could focus on details rather than the overall process. We got our subdomain storyblok-docs.netlify.com redirected to our documentation overview storyblok.com/docs by adding one configuration file for builds on master, and a routes overview for every other branch. A funny and great gimmick is the Netlify Status Badge which of course we had to add to our documentation.

AWS

The hosting for our production environment is slightly different because we want it to run smoothly within our dynamic setup on AWS. In the last step during the Netlify build, we're additionally transforming the .html files in .liquid files and also generate a routes file for our ruby set-up to handle the routing according to the routes available in Nuxt.js. For this, we've again used a Node.js script that does some string replacements and URL adjustments. After that we end up with the liquid files for our own service which automatically will be uploaded to our own service, the assets in /_nuxt/ will still be served by Netlify.

The Marketing Site

Storyblok

For our marketing site, our Content Layer is Storyblok, our own CMS. With our ruby setup, we consume the Content Delivery API and render our website according to the components and content types provided for each route. Every entry is called Story which allows a nested tree of Components. To give you an example on how the content structure could look like for one page, you can have a look at the data behind our For Developers Page by performing a GET request. If you want to know more, you can now dive into the documentation and learn more about stories.

Our marketing team can reuse those components across all entries and content types if we allowed nesting by adding the field type bloks to the content type. We did restrict some components to be only nested in specific others, benefit components, for example, should only be able to be added inside a benefits component. Since a plain data structure with all that nesting might get some confused, Storyblok ships with a live preview for your content that you can enable on every technology stack using our JavaScript Bridge.

Ruby + Liquid

Our own website is running on a Ruby set-up that uses Liquid as it's rendering engine, no additional JavaScript and statically cached in our CDN. As soon as our marketers publish content a Webhook of Storyblok will trigger a cache invalidation for the changed route and resource.

Atlassian: Bitbucket

For our internal source code versioning, we're using Bitbucket by Atlassian. We've also switched our deployment setup from Jenkins to their Bitbucket build pipelines. Similar to what we're doing with Netlify on Github, we did with the Bitbucket build pipelines and AWS. On every branch, we deploy a development version of our marketing site to an internal URL of Storyblok, so our developers can share the preview with our marketing team at any point in time. Since every development stage is able to use the production content by exchanging the Storyblok access tokens.

CDN

We're using Amazon CloudFront as our content delivery network. Content delivery networks provide a globally-distributed network of proxy servers which cache content more locally to consumers, thus improving access speed for downloading the content. Besides the Amazon CloudFront, we also utilize Netlifys ADN for assets of our documentation itself. For other resources such as our images, we're using our Image Service so we can automatically crop, resize, and optimize all our assets. This image service is also available to you in all our plans, even in the free plan!

The result

Docs

Rewriting our whole API documentation from a dynamically generated, slightly manually enhanced to a now fully manually, but detailed documentation was a great journey so far. As always we did this because so many of you suggested changes, wanted to improve the docs and enrich it with examples. Together we can now grow our documentation in a cookbook for all beginners to have them up and running with their projects even faster than before. We want to thank all of you who sent us their feedback on the preview versions we handed out, and of course, we also want to already thank all of you who are willing to contribute to it. So what's next? Check out our roadmap or suggest features for Storyblok itself.

Author

Dominik Angerer

Dominik Angerer

A web performance specialist and perfectionist. After working for big agencies as a full stack developer he founded Storyblok. He is also an active contributor to the open source community and one of the organizers of Scriptconf and Stahlstadt.js.