DIY HeadlessCMS + SSR with Vue & Netlify
Tags: vue, netlify, headlesscms, serverless
Published: 2020-07-12 20:58:41

[Nuxt](https://nuxtjs.org/) 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](https://www.youtube.com/watch?v=qQzdAsjWGPg). 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](https://about.gitlab.com/company/team/#jboyson))
- 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](https://gitlab.com/unclejustin/headless-cms-example). 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](https://www.gitkraken.com/) 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](https://vuejs.org/v2/guide/) 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](https://cli.vuejs.org/guide/installation.html):

And bootstrap an app:

```sh
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:

```json
{
  "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](https://flaviocopes.com/vue-tailwind/) 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:

```javascript
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](https://mertjf.github.io/tailblocks/) 😍 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](https://gitlab.com/snippets/1992944) 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:

```javascript
  // 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.

```vue
<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](https://vue-cli-plugin-ssr.netlify.app/guide/).

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](https://ssr.vuejs.org/) 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`

```javascript
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.

```javascript
  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](https://vue-meta.nuxtjs.org/guide/) `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`

```javascript
  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.

```html
  <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:

```javascript
  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](https://docs.netlify.com/cli/get-started/#installation).

```sh
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](https://dev-to-uploads.s3.amazonaws.com/i/9qkgcd3s4toh93axbmnx.gif)

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.

```toml
[[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.

```javascript
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`.

```javscript
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:

```javascript
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.

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

And to wire up our `update` method:

```javascript
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!

<span>Photo by <a href="https://unsplash.com/@jamesponddotco?utm_source=unsplash&amp;utm_medium=referral&amp;utm_content=creditCopyText">James Pond</a> on <a href="https://unsplash.com/s/photos/nerd?utm_source=unsplash&amp;utm_medium=referral&amp;utm_content=creditCopyText">Unsplash</a></span>