I've been trying to find the right Vue-based pairing to Craft CMS ever since my introduction to the concept of Jamstack at the 2019 Dotall conference. Since that conference, the team I work with has created headless Craft sites using Gridsome as well as Put Your Lights On’s fantastic Blitz plugin. The next framework on my list to check out was Nuxt.js and—coincidently—Nuxt.js 2.13 was already in the works.

Version 2.13.0 brings full static generation, which makes it easier to host a Nuxt.js app on static hosts, like Netlify or Vercel. Preview Mode was added so you can use features like Craft’s Live Preview with your statically generated Nuxt.js app. Also, a new way to handle environment variables was added to the Nuxt.js config that allows for better security.

My personal website is very much a work in progress and while there's certainly much more to do, I was able to get Live Preview working so I could write articles like this one using Craft and Nuxt.js together. Here's my current solution for live preview, but I’d happily welcome suggestion and improvements.

Article Updates

  • Nuxt 2.14 came out with a faster nuxt generate command, so all mentions of nuxt build && nuxt export have been changed to nuxt generate.

  • Added an example of the injected $craft variable in use.

Secret Secrets

In general, Nuxt.js has made setting up Live Preview very easy, but during the process of figuring it out, I got caught up on the issue of hiding my API secrets. When running nuxt generate environment variables are consumed server side but they aren't available client side unless you make them public (more on that below). This meant that during a live preview update, the environment variables that connect Nuxt.js to my GraphQL endpoint were coming up blank and API requests couldn’t be made.

I found a workaround for this but this led me to setting up two front-ends: this production site and a separate preview site. It would be great to live preview from the live site, so if I can figure out a more elegant solution in the future I'll update this post. Luckily all I had to do was set up an extra environment variable and add a new branch to git to make deploying to both sites very easy.

Setting up Nuxt.js

Speaking of environment variables, let’s set one up and get started.

.env
# Enable Live Preview
# Turn this off in Production
LIVE_PREVIEW=true

I set up a LIVE_PREVIEW variable and set it to true on my preview site and false on my production site. This variable comes into play when using Nuxt.js’ new runtime config settings in nuxt.config.js:

nuxt.config.js
privateRuntimeConfig: {
  craftApiUrl: process.env.CRAFT_API_URL,
  craftAuthToken: process.env.CRAFT_AUTH_TOKEN,
},
publicRuntimeConfig: {
  livePreview: process.env.LIVE_PREVIEW === 'true',
  craftApiUrl: process.env.LIVE_PREVIEW === 'true' ? process.env.CRAFT_API_URL : '',
  craftAuthToken: process.env.LIVE_PREVIEW === 'true' ? process.env.CRAFT_AUTH_TOKEN : '',
},

In privateRuntimeConfig my Craft API URL and GraphQL authorization token are set. Variables set in privateRuntimeConfig are only available during build time, and they’ll be used to statically generate my site’s pages during nuxt generate.

In publicRuntimeConfig I'm setting a livePreview boolean that will be used at runtime to determine if live preview should be enabled. I'm also setting my Craft API URL and GraphQL token again, but only when LIVE_PREVIEW is set to 'true'. This allows Nuxt.js to use these variables client side when live preview needs to re-fetch the latest draft content.

Now it’s important to note that this setup works if you plan to lock down your preview site so it’s not available to the public. If you can’t restrict access to your preview site, you may be better off setting up a different way to retrieve your access token via JWT or using a custom controller in a Craft CMS module.

Talking to Craft

There are several different ways to set up HTTP requests through Nuxt.js, but the HTTP module is straightforward and a good fit for my site.

To make a request with the HTTP module, I've set up a Nuxt.js plugin that sets up my Craft API endpoint, adds my GraphQL token, then injects a $craft function into all of my components:

plugins/craft.js
export default function ({ $config, $http, query }, inject) {
  // Create $craft and inject it into Vue components
  // Usage: `this.$craft({ query: gqlQuery, variables: { uri: `code/${this.$route.params.slug}` } })`
  
  // Create KY instance
  const $craft = $http.create({
    prefixUrl: $config.craftApiUrl,
  });

  // Add GraphQL authorization token for non-public schemas
  if ($config.craftAuthToken !== '') {
    $craft.setToken($config.craftAuthToken, 'Bearer');
  }

  // If `token` is set in query parameters, pass it along to Craft API endpoint
  inject('craft', $craft.$post.bind($craft, query.token ? `?token=${query.token}` : ''));
}

The last line of this plugin appends a token query param to your Craft API URL. You’ll get the value for this token when Craft’s Live Preview loads your page and here we’re passing it back to the Craft GraphQL endpoint to close the loop. When a request to your Craft GraphQL endpoint includes a valid token, any request for an entry will return the corresponding draft instead of the currently published version.

The plugin just needs to be added to the nuxt.config.js file to be used by your Nuxt.js app:

nuxt.config.js
plugins: [
  '~/plugins/craft.js',
],

The inject() function puts a $craft variable into all of my Vue components, so any time I need to make a request to the Craft CMS GraphQL API, I can use this.$craft({}) and include my query and any variable I want to send along with the request.

For articles like this one, my page template looks something like this:

pages/article/_section/_slug.vue
<template>
  <article v-if="Object.keys(entry).length">
    <ArticleHeader :entry="entry" />
    <MatrixFieldArticleBody class="mt-12 pb-20" :matrix-blocks="entry.articleBody" v-if="entry.articleBody" />
    <ArticleFooter
      :article-section="articleSectionLabel"
      :article-slug="$route.params.slug"
      :related-entries="relatedEntries"
    />
  </article>
</template>

<script>
import { articleGql } from 'GQL/articleGql.js';
import { gqlToObject } from 'JS/seomatic.js';
import ArticleFooter from 'Components/article_footer/ArticleFooter.vue';
import ArticleHeader from 'Components/article_header/ArticleHeader.vue';
import MatrixFieldArticleBody from '~/components/MatrixFieldArticleBody.vue';
export default {
  components: { ArticleFooter, ArticleHeader, MatrixFieldArticleBody },
  data() {
    return {
      entry: {},
      relatedEntries: {},
    };
  },
  async fetch() {
    const request = await this.$craft({
      query: articleGql(`${this.$route.params.section}Article`),
      variables: {
        uri: `article/${this.$route.params.section}/${this.$route.params.slug}`,
      },
    });
    if (request.data.entry) {
      this.entry = request.data.entry;
      this.relatedEntries = request.data.relatedEntries;
    } else {
      this.$nuxt.error({ statusCode: 404 });
    }
  },
  head() {
    return { ...(this.entry.seomatic && { ...gqlToObject(this.entry.seomatic) }) } || {};
  },
  computed: {
    articleSectionLabel() {
      return this.$route.params.section === 'code' ? 'Code' : 'Maker';
    },
  },
};
</script>

Using this.$craft() in the fetch() method, gets the result of my GraphQL query and then passes that data into data variables that I then pass into my template.

NOTE: In this case I'm importing a method, called articleGql(), but all that does is a simple string replacement and returns my query as a string.

When running nuxt generate on my production server, the fetch() method will render all of my article content in HTML and then fetch() will no longer be used. On the preview server fetch() will re-send the API call on every load, getting fresh data every time.

Pointing Craft to the Preview Site

By default, Sections in Craft set preview targets to {url}?CraftPreviewSlug={slug}. This loads the URL of your page into Live Preview based on how your URI pattern is set up. Because I'm using a separate front-end for my preview URLs, I've updated my default preview targets to {alias('@livePreviewUrl')}/{uri}?CraftPreviewSlug={slug}.

NOTE: {url} is changed to {uri} here.

While I could have hard coded the live preview site’s domain here, I set up an alias in my general.php config so I could use a Craft environment variable to change where Live Preview is pointing during local development.

Turning on Preview Mode in Nuxt.js

To let Nuxt.js know that I want Live Preview to pull my draft content from Craft when the page refreshes, I can use another plugin to turn on preview mode when the CraftPreviewSlug query param is present (that param is part of my preview target pattern above, but you could change this to anything you’d like):

plugins/preview.client.js
export default async function ({ $config, enablePreview, query }) {
  if (query.CraftPreviewSlug && $config.livePreview) {
    await enablePreview();
  }
}

This plugin takes into account whether or not live preview was enabled by my LIVE_PREVIEW environment variable.

This plugin only needs to be available when Craft refreshes the URL to update Live Preview, so by appending '.client' to the filename, Nuxt will make sure that the plugin is only used client side. :

nuxt.config.js
plugins: [
  '~/plugins/craft.js',
  '~/plugins/preview.client.js',
],

Build & Test

Both Craft and Nuxt.js should be set up, so after running nuxt generate you should see your Nuxt.js front-end in your Craft Live Preview.

Depending on how your Nuxt.js page components are set up, Live Preview should work with existing entries as well as new entries.

In my case, I set up dynamic routes to match my URI patterns set up in Craft. I pass my slug into my GraphQL queries during fetch() to get the right content for the page.

Keeping Live Preview Scroll Position

When Live Preview reloads the page it takes you back to the top because the content on the page needs to be fetched again before it’s rendered. To fix this I'm keeping track of the scroll position on the page, storing that in to sessionStorage, then scrolling the page after the content has been updated.

You can do this in your layout, your page, or in a component that is rendered by the page. You could also create another plugin and call it after your fetched data has been retrieved. I already had code set up to handle anchor tags, so I combined these two things there:

components/MatrixFieldArticleBody.vue
data() {
  return {
    scrollPosition: 0,
    ticking: false,
  };
},
mounted() {
  // Scroll down to anchor
  if (this.$route.hash) {
    const el = document.querySelector(this.$route.hash);
    el &&
      el.scrollIntoView({
        behavior: 'smooth',
      });
  } else if (this.$config.livePreview) {
    const storageKey = `scrollPosition:${this.$route.path}`;

    // If scroll position is set, scroll to it
    if (sessionStorage.getItem(storageKey)) {
      window.scrollTo(0, parseInt(sessionStorage.getItem(storageKey)));
    }

    // Record scroll position in session storage to retain scroll position in Live Preview
    setTimeout(() => {
      window.addEventListener('scroll', () => {
        this.scrollPosition = window.scrollY;

        if (!this.ticking) {
          window.requestAnimationFrame(() => {
            sessionStorage.setItem(storageKey, this.scrollPosition);
            this.ticking = false;
          });

          this.ticking = true;
        }
      });
    }, 1000);
  }
},

This code uses my LIVE_PREVIEW environment variable to turn this on for my preview site, but leaves this off in production.

The result is Live Preview that works just like a Twig-based Craft site:

Fin

Clients have told me how much they like Live Preview in Craft CMS and both Craft and Nuxt.js have solved one of the biggest challenges I was running into while diving into Jamstack tools and frameworks.

I like using Craft because it’s a CMS that is designed to be both performant and flexible. The more I explore Nuxt.js, the more I like its flexibility and thoughtful design. Both teams have created an awesome set of tools that compliment each other really well for Jamstack sites. 🥃+⛰