Using Wagtail, NuxtJS and Vuetify to build a fast and secure static site

I recently rebuilt nurseAdvance, enabling the Wagtail REST API and creating a shiny new frontend! Here's how I did it and some issues I had along the way.

Photo by Corine Bliek, CC BY-NC 2.0.

I started out with WordPress and a free marketplace theme, but that's ancient history now. For years, this site has been running on Wagtail with Django templates, plus a little sprinkling of Vue and Bulma. The point of ditching WordPress was to push my skills further and do more with the site, so please find here a fairly detailed account of how and why I refreshed nurseAdvance once again. This is just how I did it at my skill level, novice code included, so feedback is appreciated. But, I hope this is useful to anyone like me, going on the same journey. It's the least I can do to give back to the Wagtail and Vue communities!

Choosing a tech stack

The technologies I chose to rebuild nurseAdvance were Wagtail (a content management system – CMS), NuxtJS (a frontend Vue framework) and Vuetify (a Vue UI framework). There are other options for each of these, so a quick explanation: –

Wagtail

Wagtail was the CMS previously providing both the backend and frontend of nurseAdvance. It has rapidly become the leading Python CMS library, with its smart features and familiarity for anyone who's worked with Django (it is "just" a Django app, after all). Among the most notable Wagtail users is the new nhs.uk site, so it definitely scales! Most static site generators rely on Markdown files, but I wanted to keep Wagtail's elegant editorial features and workflow – especially StreamField for content creation, which I now can't imagine being without. Also the superb image handling: multiple, optimised renditions can be effortlessly created from a single source image (e.g. thumbnails, or a device-optimised srcset). To decouple the frontend from the CMS I would need a REST API, but of course Wagtail follows the "batteries included" Python philosophy, so it integrates Django REST Framework out-of-the-box.

NuxtJS

Wagtail is perfect for the backend, but Django templates are not perfect or particularly modern for creating site-wide user interactions. For ethical reasons Vue is my preference over React for writing web apps, so NuxtJS was the obvious choice. It builds on Vue in a smart-but-familiar way, similar to how Wagtail extends Django. The nuxt-router is just Vue underneath, but creates your routes implicitly from files and folders. Which is neat! Saves you time and encourages experimenting. And a good match for how Wagtail similarly creates site structure, from instances of your Page models and their descendants. NuxtJS will build for all of SSR, SPA and static hosting, giving me flexibility in the future. As a bonus, my favourite Udemy tutor Maximilian Schwarzmüller had a series on NuxtJS to help me get started.

How the site navigation is created by files and folders in NuxtJS

Vuetify

Vuetify is a leading Vue UI framework, so NuxtJS automatically configures it from a starter template and PyCharm provides code completion for the components. I like things to be that easy, especially where it comes to Webpack configuration! Runner up choice was Element UI but I had a suspicion I'd struggle to get answers from the documentation and StackOverflow if I ran into problems. One of the web apps I wanted to host as part of the site was previously written with Vuetify, so altogether the path of least resistance.

A final consideration for all three technologies: I was confident I could eventually teach my fellow postgraduate researchers at the university how to pick them up, to meet their research and clinical practice needs. Maybe even undergraduate healthcare students. All three are easy to get results with immediately, with little coding expertise (I should know!). In fact with recent versions of NuxtJS you can add markdown content to a folder and sidestep a CMS entirely, meaning in under an hour you could be live with your content, from nothing.

Amazon Web Services S3 with Cloudflare

Of course you need a host to serve content and ideally a Content Delivery Network (CDN) to optimise load times. For ease of deployment and the fact the old nurseAdvance Ubuntu server ran on AWS, I went with S3 in static site mode for hosting and Cloudflare for the CDN (it's free!). The only slight wrinkle is that if you want full TLS/SSL from AWS, you don't get it without also paying for Cloudfront – S3 no longer provides any https endpoints (boo, shame on you Amazon!).

As my content isn't sensitive, I just switched from "Full" to "Flexible" on the Cloudflare SSL service (i.e. encrypted between the user and Cloudflare, but not between Cloudflare and AWS). As nurseAdvance is just static content, Cloudflare is caching everything and it doesn't really matter, so a small price to pay to continue with their excellent free tier.

Step one – enabling the Wagtail backend

In many respects creating the backend with Wagtail was the easiest part of the project. I already had my Page models from the old version of the site, so very little needed updating. Django REST Framework (DRF) is already implemented in Wagtail, with default endpoints ready to go for Pages, Images and Documents. It kind of just works, including the excellent browser navigation of your API that DRF gives you.

DRF lets you browse your API and quickly see what data you'll get back.

Having run through the DRF tutorial recently, Wagtail was much simplified. To start with. But then you discover that not all of the Wagtail default API representations of your models are useful.

One of the most obvious examples of this issue is the EmbedBlock. When you include this default block in your StreamField, Django templates will produce nicely formatted HTML for over a dozen common oEmbed providers, like YouTube and Twitter. In the API though, all you get is the source URL. Thanks to a wonderful tutorial, I found I could override the API representation with anything I wanted. All that was left was to dig up the function Wagtail uses to change a URL into provider-specific HTML, in the wagtail.embeds module.

from wagtail.embeds.format import embed_to_frontend_html


class CustomEmbedBlock(EmbedBlock):

    def get_api_representation(self, value, context=None):
        return embed_to_frontend_html(value.url)

The way Wagtail provides dynamic image renditions was also slightly tricky for StreamField images. Images that are just a field on your Page model can use the provided ImageRenditionField serializer, like so: –

class ArticlePage(Page):
    # ...
    hero_image = models.ForeignKey(
        'wagtailimages.Image',
        null=True,
        blank=True,
        on_delete=models.SET_NULL,
        related_name='+'
    )

    hero_image_rendition = 'fill-1200x675'

    api_fields = [
        # ...
        APIField('hero_image_resized', serializer=ImageRenditionField(hero_image_rendition, source='hero_image')),
        # ...
    ]

However, in customised StreamField blocks, like my image block that includes a caption and attribution, I found (by googling, of course!) that you need to extend the standard ImageChooserBlock with a new serializer.

###############################
# SERIALIZERS
###############################

# Used in StreamField blocks to expose API url for images (with Wagtail image rendition)
class WagtailImageSerializer(serializers.ModelSerializer):
    url = serializers.SerializerMethodField()

    class Meta:
        model = WagtailImage
        fields = ['title', 'url']

    def get_url(self, obj):
        return obj.get_rendition('max-1200x1200').url


###############################
# BLOCKS
###############################

# Used for image chooser in custom StreamField blocks, exposes url to API
class APIImageChooserBlock(ImageChooserBlock):
    def get_api_representation(self, value, context=None):
        return WagtailImageSerializer(context=context).to_representation(value)


# streamfield block for an image with accompanying caption, credit, url
class ImageWithTextBlock(blocks.StructBlock):
    image = APIImageChooserBlock(required=True)
    caption = blocks.CharBlock(required=False)
    credit_text = blocks.CharBlock(required=False)
    credit_link = blocks.URLBlock(required=False)

    class Meta:
        icon = 'image'
        label = 'Image'

One last example of API customisation. I wanted to be able to use mp4 encoding to create modern gif style animations/video. Although not quite a first class Wagtail citizen with a default API endpoint, you can include video and audio using the wagtailmedia module.

from wagtailmedia.blocks import AbstractMediaChooserBlock


class Mp4MediaBlock(AbstractMediaChooserBlock):
    # ...
    def get_api_representation(self, value, context=None):
        return value.url

A few problems

The first issue I ran into was entirely predictable: when my frontend development server was running on localhost:3000 and the Wagtail development server was on localhost:8000, CORS said "no", for security reasons. One install of django-cors-headers later and whitelisting the frontend development server, I was back in business.

The biggest issue I had with Wagtail was that with all the flexibility of dynamic image renditions, the image files were only created when first requested. This was a problem, as when I would ask NuxtJS to build a static site, the images had to already exist in a folder Webpack could reference. I briefly considered a brute force approach of crawling the API to request and copy images to the NuxtJS assets folder (for Webpack to use from there). However, Wagtail provides some hooks to allow for easy customisation and I was keen to learn a bit more about using them. So, I used the page "created" and "edited" hooks in my ArticlePage model to generate the images, whenever the database was updated.

class ArticlePage(Page):
    # ...
    hero_image_rendition = 'fill-1200x675'
    body_image_rendition = 'max-1200x1200'
    # ...
    @hooks.register('after_create_page')
    def image_rendition_hook(request, page):
        page.hero_image.get_rendition(page.hero_image_rendition)
        for block in page.body:
            if block.block_type == 'image_with_text':
                block.value['image'].get_rendition(page.body_image_rendition)

You'll probably notice that I'm missing a check to see if the image rendition has already been created, or having the "os" module copy the image file over to the frontend /assets/images/ folder. Because I'm lazy and haven't done it yet. But the aim is to get to a state where this is completely irrelevant to any editor/writer just creating an article, either for previewing or publishing.

One more thing: the post preview doesn't work from the Wagtail editor because of course it's expecting Django templates, which I'm not using. While it's possible to include Vue and Vuetify standalone on a template page via a CDN, there's a module for Wagtail that will expose post preview data to your frontend. It's easy to configure and then you just need to exclude the /post-preview/ route when building, via the nuxt.config.js file.

Step two – creating the frontend experience

There isn't a lot of advice I can share about NuxtJS because it's so well documented and does just enough, before it might get in your way. I set it up with the Vue CLI, selected the option to have the Vuetify module included and it just worked, once Axios was pointed to the Wagtail API (apart from automatically crawling dynamic routes for static site building, but that was brand new at the time). Well, I had some issues with async functions, but I always do, it seems! Creating Vue components to reflect my Wagtail StreamField blocks was a real pleasure, for how close the vision of these two frameworks matched up.

All that remained was to use Vuetify to style the presentation. I greatly appreciate the stripped down Medium style approach to content, so this should be simple. Then some swearing happened.

Vuetify is obnoxiously opinionated in various ways that make it frustrating to work with. You will find, as I did, many StackOverflow complaints about difficulties with overriding its highly specific CSS defaults. There are multiple interactions to close a side-drawer (used here for site navigation) so I had to google how to properly toggle that flag in the Vue store (answer is to use a computed setter). A CDN request for the default Roboto font persisted even after I thought I'd removed all reference to it. I wanted to use the v-img component to lazy load images but it uses background images, so it doesn't constrain to the maximum width of the source image, only the containing element. A CSS tweak was needed just to not have obviously incorrect line-breaks for v-card titles on mobile devices.

And so on and so forth. I wan't expecting to have a great time with Vuetify because I'd used it before. But I was right in that it's popular enough I was able to search up answers to the problems I ran into, which was a massive advantage over some of the alternatives.

I'm also quite pleased with myself for being able to switch from PrismJS to HighlightJS (so I can have Monokai!) with all the CSS styling baked in, rather than being processed client-side.

<template>
  <pre class="mb-4"><code v-html="highlightedCode" :class="'lang-' + value.language +' my-code'"></code></pre>
</template>

<script>
  import hljs from 'highlight.js'

  export default {
    name: "StreamfieldCode",
    props: {
      value: {
        type: Object,
        required: true,
      },
    },
    computed: {
      highlightedCode () {
        const code = this.value.code
        const lang = this.value.language
        return hljs.highlight(lang, code).value
      }
    }
  }
</script>

Step three – deployment

NuxtJS bundles all the code and assets to a /dist/ folder so it just needed uploading to AWS S3, setting the bucket for static site hosting. I found out with the way NuxtJS works, my error page also had to be the root index HTML file, so 404 errors displayed correctly.

Cloudflare handles the DNS responsibilities so once I'd added a CNAME entry for my bucket endpoint, everything was live! I have a second S3 bucket for the non-https address, which just performs a 301 redirect, all very easy to set up in the AWS console. In future I'll write a script to build the site, deploy to the S3 bucket and invalidate the Cloudflare cache, but for now it's simple enough to use web interfaces to push any site updates.

Next steps

For all the personal learning I did in separating out a backend from the client, what nurseAdvance needs now is more content. Having made the jump to a fully Vue enabled frontend, I'm massively looking forward to bringing over my clinical abbreviations app, being able to inline any Vue component (like my demonstration of relative risk versus odds ratios), or finally creating the drugs numeracy practice app I've been planning for so long.