Skip to Menu

Craft CMS Live Preview with Nuxt 3

Will Browar playing the drums

Will Browar

February 18, 2023

I recently upgraded my website from Craft CMS 3 to Craft 4 and from Nuxt 2 to Nuxt 3. It’s still a work in progress, but I'd like to share my learnings along the way.

In 2020 I wrote about how to set up Live Preview between Craft CMS and Nuxt 2. At that time, headless, static, Jamstack sites were a new thing to me and the main issue I was tackling was around wanting to serve the content pages of my site in a fully static way, while allowing me to see the changes I make to new articles before they are published.

Nuxt 2 has a preview mode that lets you take a static page and replace its content by re-rendering the page as an SPA, but in the way Nuxt 2 worked, you had to set up serverless functions to avoid exposing your API endpoint and authorization tokens. My solution in that article was to take your one front-end codebase and build it twice (one as a fully static public site, and the other as a hidden SPA that always re-requests your page content from the API on every page).

As Nuxt 3 was going stable, I began the work to upgrade my site and I found that the new Hybrid Rendering feature gets me closer to the same sort of setup as my Nuxt 2 site, but it both simplifies and complicates things. On the one hand, I was able to drop the second build of my site (along with the separate infrastructure) because Nuxt could now pre-render some routes while leaving other routes dynamic. On the other hand, this meant running Nuxt on a Node server and using SSR as part of rendering my content. Using SSR means the back-end API needs to stay connected, and this means the site is no longer fully headless.

These are trade-offs I can live with and, technically, I could have just left it on the duel-build setup. I think the hybrid rendering setup would have been a much better fit for clients that I’ve worked with in the past, so some of taking this approach was to help figure all of that out.

Anyway, if you’re building a site with both Nuxt 3 and Craft CMS 4, here's an approach you can take to make Live Preview work:

The Gist of It

Okay, so before getting into the technical stuff, what we want to do is create a route in both Craft and Nuxt that is a dynamic version of our page content. Using my blog as an example, the URI for this article is:

When someone visits this page, they will be served the full pre-rendered article, as built by my CI/CD process.

In order to preview this article, I am going to prefix my URI with a unique URI segment and tell Nuxt, “Hey Nuxt, any route that starts with this segment should not be pre-rendered, so keep hitting that API every time you load the page”. Nuxt doesn't talk back, but I assume it would say “Hey, I gotchu”.

So I'm going to add an additional URI pattern in both Craft and Nuxt so the preview URL will look like this:

Now, when Nuxt sees live-preview in the first route segment, it will reach for the same content as the static page, but re-fetch it on every page request.

In addition to adding the separate route segment, I also want to add a query parameter that is unique and can be changed via environment variables, to add just a little extra obscurity to my live preview pattern. So now my Live Preview URLs will looks something like this:

So now Nuxt is looking for both the live-preview route segment, and has-live-preview query param to determine if it should show Live Preview content.

Setting Up a Separate Live Preview Target in Craft

Craft gives you flexibility to choose the URL pattern that is used during Live Preview, and I want to get this route segment and query parameter into my Live Preview URLs. We can start by adding the following variables in my Craft CMS .env file.

# Live Preview settings
# Set preview tarrgets to something like this:
#   {alias('@livePreviewUrl')}/{uri}?{alias('@livePreviewParam')}={slug}

In order to concatenate my URI pattern, I added a couple of URL aliases in my Craft CMS config file.


use craft\config\GeneralConfig;
use craft\helpers\App;

return GeneralConfig::create()
        '@livePreviewParam' => getenv('LIVE_PREVIEW_PARAM'),
        '@livePreviewUrl' => getenv('LIVE_PREVIEW_URL'),

With these in place I can now update the sections of my site that I would like to enable Live Preview. For my articles and for most pages in my site, I’ll set my Preview Targets → URL Pattern to this:


The first portion adds in live-preview, then we add the full URI for the page, as resolved from the Entry URI Format setting. Then, we tack on the has-live-preview query parameter.

One thing to note here is that if you wanted to set up your home page for live preview, you should remove the {uri} portion and use a pattern like this:


If you want to make sure this worked, go to an entry page in the CMS, click the Preview button and use your browser DevTools to inspect the right side of the page. Look for the iframe that loads your page content and check that the src attribute looks like this:

Now if you aren't familiar with how Live Preview works, you might notice the x-craft-live-preview and token query parameters have been added here. These will be important later, but for now if you see something like this you should be all set in the Craft side of things.

Matching Environments Up in Nuxt

Just like in the Craft setup, let’s tackle the settings stuff first. In my Nuxt config I'll add the following to my runtimeConfig:

export default defineNuxtConfig({
  runtimeConfig: {
    graphqlAccessToken: '',
    graphqlEndpoint: '',
    livePreviewParam: 'has-live-preview'

In order to communicate with my Craft CMS GraphQL endpoint, I already had the graphqlAccessToken and graphqlEndpoint properties set and I am using Environment Variables to update those.

To match up my LIVE_PREVIEW_PARAM in my Craft .env file, I am setting the same string, has-live-preview here. Note that you could also update this to be unique to each server environment by adding LIVE_PREVIEW_PARAM=has-live-preview to your Nuxt .env.

GraphQL Queries in Nuxt

These variables have to be used somewhere, and for the sake of keeping things DRY, I am going to create a Nuxt server route that will be used to make all of my requests out to my GraphQL API.

In server/api I'll create a new TypeScript file, called query.ts and that will look like this:

export default defineEventHandler(async (event) => {
  const body = await readBody(event)
  console.log('API: query', body)
  const runtimeConfig = useRuntimeConfig()

  async function runQuery () {
    const requestBody: {
      query: string;
      variables?: object
    } = {
      query: body.query
    if (body.variables) {
      requestBody.variables = body.variables

    const endpointUrl = body.route?.query?.[runtimeConfig.livePreviewParam] && body.route?.query?.token ? `${runtimeConfig.graphqlEndpoint}?token=${body.route.query.token}` : runtimeConfig.graphqlEndpoint

    const data: any = await $fetch(endpointUrl, {
      body: JSON.stringify(requestBody),
      headers: {
        Authorization: `Bearer ${runtimeConfig.graphqlAccessToken}`,
        'Content-Type': 'application/json'
      method: 'POST'
    if (data) {

  const response = await runQuery()
    .then((response) => {
      return response
    .catch((e) => {
      console.error('GraphQL API error', e)
      throw e

  return response

Let’s break this down.

First, I am going to use the POST method to use this file, and I'll be passing in parameters via a post body. The body variable gets the parameters for the request and the runtimeConfig variables gets those settings from my Nuxt config file.

In the runQuery() method I pull out my GraphQL query and variables from the request body.

Then I set endpointUrl to the full endpoint that I’ll use to hit Craft CMS with my request. There is some logic here that checks to see if there is a parameter that matches my livePreviewParam as well as a token parameter—from the <iframe> URL mentioned above. If both of those parameters are there, we’ll pass that token variable back to Craft as a Live Preview request. If one of those variables are missing, we'll assume this is not a Live Preview request and that we just need the GraphQL endpoint so we can query for the published page content.

There's a lot going on in that line, but from here we're going to use Nuxt’s built-in $fetch to make our request and either get back the live page content or dynamic Live Preview content.

Server Route Usage

In my article page components I use <script setup> so in order to use this server route, I do something like this:

import { articleGql } from '@/gql/articleGql'

const route = useRoute()

const articleCategory = ref(route.params.articleCategory)
const articleSlug = ref(route.params.articleSlug)

const { data: page, pending } = await useFetch('/api/query', {
  body: {
    query: articleGql,
    variables: {
      uri: `article/${articleCategory.value}/${articleSlug.value}`
  method: 'POST'

First, I keep my GraphQL queries in a separate directory, so I import in my query as a string. Then I need context from the Nuxt route for the current page in order to match this up with the article’s route in Craft CMS.

From there I use Nuxt’s useFetch composable to hit my custom server route and make the request for my content. In my request body I'm passing in my GraphQL query, the current page route, and the URI that will be passed along as a GraphQL variable.

Because I'm passing in my route, my server route will know if the has-live-preview param is present or not, however, up until this point we are still not prepending live-preview to the front of the route. That comes next.

File-Based Preview Routing

The components that render my articles live in this directory structure:

| pages/
--| articles/
----| [articleCategory]/
------| [articleSlug].vue

To prepend live-preview to the front of my Live Preview-specific routes, I can add a live-preview directory to my pages folder. Within that pages/live-preview directory, I want to match my current directory structure so the rest of the URI stays the same. Here's what that would look like:

| pages/
--| articles/
----| [articleCategory]/
------| [articleSlug].vue
--| live-preview/
----| articles/
------| [articleCategory]/
--------| [articleSlug].vue

The [articleSlug].vue component in that pages/live-preview directory could be a duplicate of the original page component, but the downside would be that if I ever wanted to make changes to one, I’d have to make the same change to both.

Luckily, Vue and Nuxt are just a whole bunch of JavaScript files, so we can do things like this:

<script lang="ts" setup>
import PageComponent from '@/pages/article/[articleCategory]/[articleSlug].vue'

  <component :is="PageComponent" />

In my preview version of [articleSlug].vue I am importing the original component file, then as my <template> content I am using a Vue dynamic component as the root element.

By doing this, I get all of the refs and functionality from my original component’s <script setup>, but when I pass the route parameter into my server route, it’ll now be based on the page/live-preview route. That will complete the requirements in my server route to show my Live Preview content.

Route Rules Rule

In my Nuxt setup I have some Nitro route rules configured, so to make sure my Live Preview routes are not pre-rendered, I can add the following rules to my Nuxt config file:

export default defineNuxtConfig({
  nitro: {
    prerender: {
      crawlLinks: true,
      routes: routeUrls,
      ignore: ['/live-preview', '/live-preview/**']
    routeRules: {
      '/live-preview': { ssr: false },
      '/live-preview/**': { ssr: false }
  runtimeConfig: {
    graphqlAccessToken: '',
    graphqlEndpoint: '',
    livePreviewParam: 'has-live-preview'

I can’t say that I've fully figured out how to use Nitro yet, but this seems to work for now. In nitro.prerender I am telling Nitro to ignore all routes that begin with /live-preview when crawling my site. In nitro.routeRules I’m letting Nitro know that when Nitro does get to one of those routes, that I don't want to use SSR at all. This treats those routes like an SPA, fetching all of the data every time the page is hit.

Note: I would like to keep working on this to optimize the pre-rendered routes, so this might change in the future.

Keeping the Scroll Position

Oh, just like in my previous article, keeping scroll position during Live Preview can be solved in Nuxt 3. This time, I'm using a composable to help:

export const useScrollPosition = () => {
  const route = useRoute()

  const scrollPosition = ref(0)
  const ticking = ref(false)

  if (route.path.startsWith('/live-preview')) {
    const storageKey = `scrollPosition:${route.path}`

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

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

        if (!ticking.value) {
          window.requestAnimationFrame(() => {
            sessionStorage.setItem(storageKey, scrollPosition.value.toString())
            ticking.value = false

          ticking.value = true
    }, 1000)

Note: this does expose my `/live-preview` segment, but you could change the logic here. In my full file I also use this composable to handle hashes in my URLs, so use this as a starter.

This composable uses sessionStorage to store the scrollY value of the page in Live Preview and then it sets the page to scroll down to that value whenever it refreshes.

Unlike a plugin, this composable needs to be added to each page that you want it to be active for, so you can add this to your page components:

import { useScrollPosition } from '~/composables/useScrollPosition'

onMounted(() => {
  nextTick(() => {


If everything is set up correctly, when I visit the article’s URL I should see the fully rendered version of this article. When I go into Craft CMS and hit Preview, I should also see my changes update as Craft saves my content drafts.

I'd still like to figure out how to set all of this up in a headless way, but for now this setup works for me. Just as it was in Nuxt 2, Nuxt 3 and Craft CMS remain a very good pair in my tech stack.

Craft ❤️ Nuxt