How to create amazing SSR website with Wagtail 2 + Vue 3
Tags: python, vue, webdev
Published: 2020-09-01 11:05:10

## Why do I do that?
I want to share some tools combined with best practices.
This setup is flexible enough to create huge web projects. Indeed this is just a general idea and proof of concept, how we can combine Vue3 with Django based CMS.

In case you are to lazy or to excited to see it in action, you can go directly to the github [repo](https://github.com/devs-group/wagtail-vue).

If you like django and python you will love wagtail CMS. Check it out at: https://wagtail.io/

You heard a lot about the new version of vue? It's amazing. Let's try to combine it with the best Python CMS and make sure that we still have SSR (Server Side Rendering) and make use of the popular package [django_webpack_loader](https://pypi.org/project/django-webpack-loader/)

## Let's dive in.

- First we create a `docker-compose.yaml` file:
```yaml
version: "3"

services:
    cms:
        restart: always
        image: cms/wagtail
        build:
            context: ./cms
        volumes:
            - ./cms:/code/cms
            - ./frontend:/code/cms/frontend
        ports:
            - 8000:8000
        links:
            - frontend

    frontend:
        restart: always
        image: frontend/node
        build:
            context: ./frontend
        command: yarn serve
        ports:
            - 8080:8080
        volumes:
            - ./frontend:/code/cms/frontend
```

- We generate wagtail project inside the project folder and name it `cms`:
```bash
pip install wagtail
wagtail start cms
```

- For the frontend part create a `frontend` folder
```mkdir frontend```

- After this you can create `package.json` file inside this folder.
```json
{
  "name": "frontend",
  "version": "1.0.0",
  "main": "index.js",
  "license": "MIT",
  "scripts": {
    "serve": "webpack-dev-server --host 0.0.0.0"
  },
  "dependencies": {
    "vue": "3.0.0-rc.9",
    "webpack-bundle-tracker": "0.4.3"
  },
  "devDependencies": {
    "@vue/compiler-sfc": "3.0.0-rc.9",
    "ts-loader": "8.0.3",
    "typescript": "4.0.2",
    "vue-loader": "16.0.0-beta.5",
    "webpack": "4.44.1",
    "webpack-cli": "3.3.12",
    "webpack-dev-server": "3.11.0",
    "yarn": "1.22.5"
  }
}
```

- `tsconfig.json`:

```json
{
  "compilerOptions": {
    "allowJs": true,
    "allowSyntheticDefaultImports": true,
    "declaration": false,
    "esModuleInterop": true,
    "experimentalDecorators": true,
    "module": "es2015",
    "moduleResolution": "node",
    "noImplicitAny": false,
    "noLib": false,
    "sourceMap": true,
    "strict": true,
    "strictPropertyInitialization": false,
    "suppressImplicitAnyIndexErrors": true,
    "target": "es2015",
    "baseUrl": "."
  },
  "exclude": [
    "./node_modules"
  ],
  "include": [
    "./src/**/*.ts",
    "./src/**/*.vue"
  ]
}
```

- `webpack.config.js`
```javascript
const path = require('path')
const { VueLoaderPlugin } = require('vue-loader')
const BundleTracker = require('webpack-bundle-tracker');

module.exports = (env = {}) => {
  return {
    mode: env.prod ? 'production' : 'development',
    devtool: env.prod ? 'source-map' : 'cheap-module-eval-source-map',
    entry: path.resolve(__dirname, './src/main.ts'),
    output: {
      path: path.resolve(__dirname, './dist'),
      publicPath: "http://0.0.0.0:8080/"
    },
    module: {
      rules: [
        {
          test: /\.vue$/,
          use: 'vue-loader'
        },
        {
          test: /\.ts$/,
          loader: 'ts-loader',
          options: {
            appendTsSuffixTo: [/\.vue$/],
          }
        },
      ]
    },
    resolve: {
      extensions: ['.ts', '.js', '.vue', '.json'],
      alias: {
        'vue': '@vue/runtime-dom'
      }
    },
    plugins: [
      new VueLoaderPlugin(),
      new BundleTracker({ filename: './webpack-stats.json' })
    ],
    devServer: {
      headers: {
        "Access-Control-Allow-Origin": "\*"
      },
      public: 'http://0.0.0.0:8080',
      inline: true,
      hot: true,
      stats: "minimal",
      contentBase: __dirname,
      overlay: true
    }
  };
}
```

- `Dockerfile`

```dockerfile
FROM node:12.15.0 as base

WORKDIR /code/cms/frontend
COPY ./package*.json ./
RUN yarn install
COPY . .
```

- Than you can create `frontend/src` folder with following files inside.

- `main.ts`
```typescript
import { createApp } from 'vue';
import CountButton from './components/count_button.vue';

createApp(CountButton).mount('#vue-count-button'); // This selector name will be used in wagtail / django template.
```
The idea is to create each vue instance for each component which has to be binded to the django template.

- `shims-vue.d.ts`
```typescript
declare module "*.vue" {
  import { defineComponent } from "vue";
  const Component: ReturnType<typeof defineComponent>;
  export default Component;
}
```

- Create a `frontend/src/utils/` folder
- and following `django-variables.js` file. This get method will help us to get properties directly from django templating to our vue instance.
```javascript
function _getSingle(id) {
  if (!document.querySelector(`#${id}`)) {
    console.error(`Selector #${id} could not be found. Please check your django templates.`);
    console.error(`
      You are probably missing something like {{ value|json_script:'${id}' }}
      in your django template.`
    );
    return "";
  }
  return document.querySelector(`#${id}`).textContent.replace("\"", "");
}

function get(...args) {
  let obj = {};
  args.forEach((id) => {
    obj[id] = _getSingle(id)
  })
  return obj;
}

export {
  get
}
```

- You can create a component in `frontend/src/components`
- `count-button.vue` component

```vue
<template>
  <div>
    <h2>This is a Vue 3 component!!</h2>
    <button @click="increase">Clicked {{ count }} times.</button>
  </div>
</template>
<script lang="ts">
import { ref } from 'vue';
import { get } from '../utils/django-variables';
export default {
  setup() {
    const djangoVariables = get('header_title', 'header_title2');
    console.log(djangoVariables);
    const count = ref(0);
    const increase = () => {
      count.value++;
    };
    return {
      count,
      increase
    };
  }
};
</script>
```

- For the `CMS` Part you need to install `django_webpack_loader`
- Add following line to `requirements.txt` in cms folder
```
...
django-webpack-loader==0.6.0
```
In order to apply this change you need to build your cms container. Simply run: `docker-compose build cms`

- Add `'webpack_loader'` to your `INSTALLED_APPS` in `cms/cms/settings/base.py`
```python
INSTALLED_APPS = [
    'home',
    'search',

    'wagtail.contrib.forms',
    'wagtail.contrib.redirects',
    'wagtail.embeds',
    'wagtail.sites',
    'wagtail.users',
    'wagtail.snippets',
    'wagtail.documents',
    'wagtail.images',
    'wagtail.search',
    'wagtail.admin',
    'wagtail.core',

    'modelcluster',
    'taggit',

    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',

    'webpack_loader'
]
```

- On the bottom of `cms/cms/settings/base.py` add following dict:
```python
WEBPACK_LOADER = {
    'DEFAULT': {
        'CACHE': True,
        'BUNDLE_DIR_NAME': '/bundles/',  # must end with slash
        'STATS_FILE': '/code/cms/frontend/webpack-stats.json',
    }
}
```

- In your `base.html` template you will need to add following tags:
```{% load render_bundle from webpack_loader %}```
```{% render_bundle 'main' %}```
Example `cms/cms/templates/base.html`:
```html
{% load static wagtailuserbar %}
{% load render_bundle from webpack_loader %}

<!DOCTYPE html>
<html class="no-js" lang="en">
    <head>
        <meta charset="utf-8" />
        <title>
            {% block title %}
                {% if self.seo_title %}{{ self.seo_title }}{% else %}{{ self.title }}{% endif %}
            {% endblock %}
            {% block title_suffix %}
                {% with self.get_site.site_name as site_name %}
                    {% if site_name %}- {{ site_name }}{% endif %}
                {% endwith %}
            {% endblock %}
        </title>
        <meta name="description" content="" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />

        {# Global stylesheets #}
        <link rel="stylesheet" type="text/css" href="{% static 'css/cms.css' %}">

        {% block extra_css %}
            {# Override this in templates to add extra stylesheets #}
        {% endblock %}
    </head>

    <body class="{% block body_class %}{% endblock %}">
        {% wagtailuserbar %}

        {% block content %}{% endblock %}

        {# Global javascript #}
        {% render_bundle 'main' %}
        <script type="text/javascript" src="{% static 'js/cms.js' %}"></script>

        {% block extra_js %}
            {# Override this in templates to add extra javascript #}
        {% endblock %}

    </body>
</html>
```

- Now in order to load our count button vue component to a template, we need just to reference the id defined in `main.ts`
```
<div>
    <div id="vue-count-button"></div>
    <div id="vue-sidebar"></div>
</div>
```

- To pass some variables from template to vue components. Just add:

```{{ block.value|json_script:'header_title'}}```
Inside the template.
and add:
```
import { get } from '../utils/django-variables';
...
const djangoVariables = get('header_title');
...
```

- Example `cms/home/templates/home/home_page.html`:
```
{% extends "base.html" %}
{% load static %}
{% load wagtailcore_tags %}

{% block body_class %}template-homepage{% endblock %}

{% block extra_css %}


{% comment %}
Delete the line below if you're just getting started and want to remove the welcome screen!
{% endcomment %}
<link rel="stylesheet" href="{% static 'css/welcome_page.css' %}">
{% endblock extra_css %}

{% block content %}

<div>
    <div id="vue-count-button"></div>
    <div id="vue-sidebar"></div>
</div>
<article>
    {% for block in page.body %}
        {% if block.block_type == 'heading' %}
            {{ block.value|json_script:'header_title'}}
            {{ block.value|json_script:'header_title2'}}
            <h1>{{ block.value }}</h1>
        {% endif %}
        {% if block.block_type == 'paragraph' %}
            <p>{{ block.value }}</p>
        {% endif %}

    {% endfor %}
</article>


{% endblock content %}
```

## Finally
- Run `yarn install` in your frontend folder

- Run `docker-compose exec cms bash python manage.py migrate` to run wagtail migrations

- Open `localhost:8000`

Check our the working example on github:

-  https://github.com/devs-group/wagtail-vue