loading...
Cover image for DIY HeadlessCMS + SSR with Vue & Netlify

DIY HeadlessCMS + SSR with Vue & Netlify

unclejustin profile image Justin Boyson ・11 min read

Nuxt is awesome and you should probably use it, but sometimes it feels like overkill, and maybe you're an obstinate developer (like me!) who just wants to roll your own SSR so you can channel your inner Franky and do it your way. Well, welcome friend you're in the right place!

In this post we're going to use a basic Vue CLI app plus a few off the shelf plugins to get our Nuxt-lite functionality in place. Then we'll use the magic of webpack to bundle in our data and put together an "API" using nothing but fetch and a single netlify function. Let's do this 💪

First let's outline what we're building:

Nuxt-lite

  • SSR using @akryum/ssr Vue plugin
  • Auto page routing using auto-routing Vue plugin
  • Meta tags using vue-meta courtesy of the Nuxt team themselves (thanks!)

Git based Headless CMS

  • Set up our repo (we'll be using GitLab because I'm biased)
  • Setup netlify hosting + functions
  • Read data from a local json file using dynamic imports
  • Add a "user interface" with the super secret HTML attribute that they don't want you to know about
  • Save data directly to the repo using GitLab's REST API + fetch

The completed project's repo

I'm not going to go into very specific detail about each of these steps, so if you want to follow along you can clone the finished project here. I've tagged commits that represent the finished state of each step so you can try some things out, and then checkout the tag for a particular step if you need to start over.

I also recommend using your diffing tool of choice (GitKraken is nice) to compare the differences between tags and then trying out the changes yourself.

Let's get to work

Step 0: A basic Vue app

Heads up! This post assumes you are already comfortable building Vue apps. If not the docs are a great place to start. 💡

Create a Vue app and make it prettier

First things first lets bootstrap a barebones Vue app. If you don't have Vue CLI already install that bad boy:

And bootstrap an app:

vue create -b -n headless-cms-example

Note that I'm using the barebones install -b to skip all the example code and I'm skipping the git initialization -n so that it's easier to add the remote repo later.

Here are my answers to Vue CLI's pop quiz:

  • Manually select features
  • Babel, Router, Vuex, Linter/Formatter
  • Yes use history mode
  • ESLint + Prettier
  • Lint on save
  • In dedicated config files

Step 0.1: A prettier Prettier

Prettier is already opinionated, but apparently I'm even more so because I pretty much do this for every project I work on.

Create a .prettierrc file in the root of the project and paste in the following:

{
  "semi": false,
  "singleQuote": true,
  "trailingComma": "all"
}

Then run yarn lint --fix. SO MUCH BETTER.

Set up git and remote repo

Now would be a good time to get git set up and commit to a repo. I'll be using GitLab for this, but if you prefer [REDACTED] instead I assume you can follow along in [REDACTED] yourself. I'm also not going to spell these steps out completely as I am expecting my dear readers (that's you!) to have a decent working knowledge of git and online repos.

In a nutshell, create a new project and name it the same as your Vue app "headless-cms-example". Do not initialize with a README. Then follow the instructions to "Push an existing folder".

Excellent, now we can undo the terrible mistakes we will inevitably make later.

Step 1: Better looking dummy content + Tailwind

Anywho, now you have a fully functional and safely version controlled but terrible looking Vue app. Let's fix that real quick, because working on pretty things is more fun that working on ugly things.

First up, let's get tailwind installed and configured. This is a great article and is what I followed for this project.

The one thing the article doesn't mention is configuring tailwind to purge css that is not in use. Let's set that up now.

Open tailwind.js and add './src/**/*.vue' to the purge array. tailwind.js should look like this:

module.exports = {
  purge: ['./src/**/*.vue'],
  theme: {
    extend: {},
  },
  variants: {},
  plugins: [],
}

I won't go into detail about what all of that is doing because this isn't a tailwind tutorial, but I definitely encourage you to go play with tailwind if you haven't seen it before. It's an excellent tool for rapidly prototyping user interfaces.

And now we're going to cheat and grab some pre-made template content from tailbocks 😍 Sadly tailblocks does not deep link to their content so I've collected the blocks into a snippet on GitLab. You can grab them here if you're following along at home or just check out step-1 and skip ahead a bit.

Awesome! Now we have a nice looking static site.

Dynamic rendered content

Finally to complete our basic Vue app we're going to wire up the blog posts to some placeholder data.

For simplicity's sake we will only be editing the title of the blog posts, so our placeholder data will look like this:

  // Home.vue
  data() {
    return {
      posts: [
        {
          id: 1,
          title: 'Post 1',
        },
        {
          id: 2,
          title: 'Post 2',
        },
        {
          id: 3,
          title: 'Post 3',
        },
      ],
    }
  },

Now we can loop over the posts in our template.

<div v-for="post in posts" :key="post.id" class="p-4 md:w-1/3">
...
<h1 class="title-font text-lg font-medium text-white mb-3">
  {{ post.title }}
</h1>
...
</div>

It's pretty basic but this sets us up for success by focusing on something we know very well so that we can do some sanity checking. When we pull in json data later all we'll have to do is set posts to an empty array and then fill it with our json data.

Go ahead and run yarn serve to see your project running if it isn't already.

Step 2: Easy SSR with CLI plugins

Thanks to Vue core team member Akryum we have vue-cli-plugin-ssr.

To get SSR set up start by running vue add @akrum/ssr

Aaaaaand that's it. I'll be honest, when I first set out to do this I had intended to roll my own SSR as per the docs but after trying the plugin like we did above, it was just too easy.

Step 3: Also easy auto routing with more CLI plugins

I promise this is not just going to be a bunch of shell commands, bear with me. But yes we are doing another one vue add auto-routing

Aaaaaand it's broken.

So what's going on here? The problem is that the plugin is using ES6 modules that can't be run server side. To get around this we need to use the beforeApp hook that the SSR plugin gave us.

The core of what we need to do is move the offending modules and the createRouterLayout function into entry-client.js

import routes from 'vue-auto-routing'
import { createRouterLayout } from 'vue-router-layout'
...
const RouterLayout = createRouterLayout(layout => {
  return import('@/layouts/' + layout + '.vue')
})

When you install the auto routing plugin it overwrites your routes directly. Since we can't use the modules like that we use Vue Router's addRoutes method to add the dynamic routes once the app is bootstrapped and loaded on the client.

  async beforeApp({ router }) {
    router.addRoutes([
      {
        path: '/',
        component: RouterLayout,
        children: routes,
      },
    ])
    await loadAsyncComponents({ router })
  },

There we go. Now we have some sweet, sweet auto routing. If we add a .vue file in the pages directory the routes will be automatically created.

For example:

If you create pages/test.vue then you will get https://your-baller-site.com/test

Step 4: Meta info

SEO. All the cool kids are doing it. Ok, full disclosure, I am not a cool kid and I know nothing about SEO 😅, but I'm pretty certain you need to set meta "things".

To that end let's install vue-meta yarn add vue-meta

For the most part we're just following the get started guide from vue-meta's docs. The only bit that's specific to our setup is where explicitly to put the server side code.

For us that's entry-server.js and index.ssr.html

  return new Promise(async (resolve, reject) => {
    const { app, router } = await createApp()
    const meta = app.$meta()

    router.push(prepareUrlForRouting(context.url))
    context.meta = meta

    router.onReady(() => {
      context.rendered = () => {}
      resolve(app)
    }, reject)
  })

Here we've just added a reference to app.$meta on the context.

  <head>
    ...
    {{{ meta.inject().title.text() }}}
    ...
  </head>

And here we inject the meta items we want injected. I've only injected the title here because as I said before: I'm terrible at SEO.

With all of that we are now finished with our "nuxt-lite" application and are now ready to CMS all the things!

Step 5: Loading data from JSON files

This part is awesome in it's simplicity. Thanks to webpack and the fact that we are going to use git to update local files, we can simply import json right where we need it.

First move the inline posts array from index.vue to db/posts.json and format it accordingly. I like to use an online javascript to json converter for this. I won't link one here since I don't want to endorse any particular one, so I trust your Google instincts here.

In index.vue simply add a created hook like so:

  created() {
    import('@/db/posts.json').then(data => {
      this.posts = data.default
    })
  },

That's it! Now you have "live" data. Run the site yarn ssr:serve and check it out. Update the json file and see the titles change.

Noice.

Step 6: Saving data to the repo

Backend

We're going to be using Netlify's functions so if you don't already have it go install Netlify's CLI dev tool.

npm install netlify-cli -g

This isn't a "setting up Netlify functions" tutorial either so I'll skip the detail, but basically create a new Netlify site and hook it up to your repo.

Then login to netlify cli with netlify login if you are not already authenticated.

Once you're logged in you can cd in to your local project and run netlify init Choose the site you just created and we're ready for magic.

Funny man making magic fingers

The easiest way to setup a netlify function is to use the cli. Create an empty functions folder and netlify.toml file at the root of your project.

At a minimum you need to set the functions directory, but here is my toml that will set you up for success.

[[redirects]]
  from = "/api*"
  to = "/.netlify/functions/:splat"
  status = 200

[build]
  functions = "functions"
  command = "yarn ssr:build"

[dev]
  framework = "#custom"
  command = "yarn ssr:serve"
  targetPort = 8000

That sets you up with a nice redirect so that you can call your function from /api/posts instead of /.netlify/functions/posts. It also configures the cli to work properly with our fancy ssr setup.

Now run netlify functions:create posts and select the node-fetch template. This will scaffold out a functions/posts directory. The only file we care about here is functions/posts/posts.js you can delete the rest. You will also need to install node-fetch so that it is available at build. yarn add node-fetch.

Ok! Now is a good time to make sure everything is wired up correctly. Run netlify dev and your site should be compiled and ready to serve. Go to the localhost url it gives you and make sure the site looks ok. Now let's test that new function by adding /api/posts to the end of your url. Something like http://localhost:8888/api/posts and it should show you a silly joke.

If all is well we can update this function to save data to our repo. First we need to pull in our private token and create a little string helper to format the url the way GitLab's API expects.

const GL_PRIVATE_TOKEN = process.env.GL_PRIVATE_TOKEN
const path = 'src/db/'.replace(/\//g, '%2F')

GL_PRIVATE_TOKEN is an environment variable that I added directly in the settings for the site on netlify.com. Netlify dev actually pulls these in locally and makes them available which is pretty cool.

Next is replacing the example GET call with a PUT.

const response = await fetch(`https://gitlab.com/api/v4/projects/${repoId}/repository/files/${path}posts.json`,
  {
    method: 'PUT',
    body: JSON.stringify({
      commit_message: 'Update posts',
      branch: 'master',
      author_name: 'CMS',
      content,
    }),
    headers: {
      'CONTENT-TYPE': 'application/json',
      'PRIVATE-TOKEN': GL_PRIVATE_TOKEN,
    },
  },
)

This is all pretty basic fetch usage. We swap in the URL for GitLab's files API, pass data through stringify in the format that GitLab expects, and set our private token in the header.

Finally we tweak the return to match our new format:

const data = await response.json()

return {
  statusCode: 200,
  body: JSON.stringify(data),
}

Sweet! Now that the backend is ready let's build up a quick and dirty interface so that we can live edit directly on the site.

Frontend

For our dead simple interface we're going to use a built feature of plain old HTML: contenteditable.

We simply set contenteditable="true" on our title and use a Vue method to submit.

<h1
  ...
  contenteditable="true"
  @keydown.enter.prevent="update(post, $event)"
>

And to wire up our update method:

update(post, event) {
  this.posts.find(p => p.id === post.id).title = event.target.innerText
  fetch('/api/posts', {
    method: 'PUT',
    body: JSON.stringify({
      content: JSON.stringify(this.posts),
    }),
  })
},

Not the prettiest code ever written, but it gets the job done. Notice the double stringify calls. The content of body needs to be a string, but the posts array also needs to be formatted into proper JSON for it to work.

And that's it! Try it out. Changing a title and pressing enter should commit a change directly to the repo. This will automatically trigger a new Netlify build and update the site, or you can git pull locally to see the changes.

Neat!

Conclusion

Obviously this isn't a production ready full blown CMS, but hopefully you see the potential, and how simple the core concepts are.

If all you needed were blog posts, you could leave the backend code exactly as it is and just keep adding contenteditable to the pieces you need. You could even use a markdown parser and have markdown capabilities in your body text.

I plan to revisit this concept and try to package it up to be more consumer friendly by building UI components so that it can wrap different content types. Think image component where you just pick the src, and a "long text" component that accepts markdown.

I'd love to see a collection of developer coding blocks that anyone could piecemeal together without being tied to a single project. Where you could just use the repo file saving piece, but roll your own interface, or use the inline editor components, but save to a database instead.

This idea was born out of frustration with the current CMS offerings, and how hard it is to update them to your needs. I feel like the pieces should be simple enough, and tight enough in their scope that you are comfortable enough to take and leave what you please. Since every site is a little bit different, your CMS probably should be too.

That's it for today! Make sure to follow me for more coding shenanigans!

Photo by James Pond on Unsplash

Posted on by:

unclejustin profile

Justin Boyson

@unclejustin

Super pumped to be a Senior Frontend Engineer at GitLab! Into all things javascripty with a heavy focus on Vue.

Discussion

markdown guide
 

Fun read. sorry I was unable to attend the meeting.

Just took a quick look and was wondering how secret does the GitLab api key need to be?

 If all is well we can update this function to save data to our repo. First 
 we need to pull in our private token and create a little string helper to 
 format the url the way GitLab's API expects.

 const GL_PRIVATE_TOKEN = process.env.GL_PRIVATE_TOKEN
 const path = 'src/db/'.replace(/\//g, '%2F')

 GL_PRIVATE_TOKEN is an environment variable that I added directly in the 
 settings for the site on netlify.com. Netlify dev actually pulls these in locally 
 and makes them available which is pretty cool.

Looks like the PRIVATE token is in clear text in the javascript. (as clear text as minified javasript can be).

I'm guessing the GITLAB token has some kind of referrer option so it can only accepts requests from specific referrrs? (your netlify site and localhost?)

I've used render.com in a similar way. It also deploys directly from [REDACTED].