How to handle content previews from Sanity in Nuxt

Jérôme Pott - Aug 16 '20 - - Dev Community

Introduction

In the Jamstack, pages are generated at build time. The static assets can then be deployed to a CDN network and served quickly to the visitors. However, this approach means that the pages cannot be built by the server on the fly, for example when content editors want to preview their content before publishing it.

So how can we solve that problem? Well, this is still a domain where innovation is currently happening, but a few solutions already exist. The easiest solution is to simply generate a preview deploy, so basically rebuild the whole website on a test URL. This approach is only viable if rebuild time is very short. Incremental builds would be the solution here, but we're not there yet.

Another approach is to take advantage of the fact that our statically generated Nuxt website hydrates into a full-blown single page application. We can then use JavaScript on the client side to dynamically fetch the content from the CMS. Thankfully, Nuxt > v2.13 makes our lives easy with its new and shiny preview mode. ✨

Enabling preview mode in Nuxt (> v2.13)

Create a plugin named preview.client.js with the following content:

// plugins/preview.client.js
export default function ({ query, enablePreview }) {
  if (query.preview) {
    enablePreview()
  }
}
Enter fullscreen mode Exit fullscreen mode

Yes! That's it! Now if the URL contains the query param ?preview=true, the enablePreview method will be invoked. Nuxt will then "discard" the data coming from the server and calls nuxtServerInit, asyncData and fetch on the client side.
To test the preview mode locally, you need to run nuxt generate and then nuxt start. You can now see in the network tab that Nuxt makes calls to the API when the preview query parameter is set.

Previewing brand new pages

By brand new pages, I mean pages that have never been deployed. We don't want Nuxt to show the 404 page to the content editor when he/she wants to preview a brand new page.

There's a SPA fallback that we can activate by setting generate.fallback to true in nuxt.config.js. Now Nuxt won't default to a 404 and will try to render the page by making an API call to the CMS.

But we still want to show the 404 page when normal users of the website visit such pages. The validate hook was designed for this situation.
What I usually do is store all slugs in Vuex (via the nuxtServerInit action) and check against the store in the validate hook if the page exists. However, don't forget to provide a "escape hatch" for the preview mode:

validate({ params, store, query }) {
    // If FALSE redirect to 404 page
    return (
      query.preview === 'true' || store.state.moviesSlugs.includes(params.slug)
    )
  }
Enter fullscreen mode Exit fullscreen mode

Generating the target URL in the CMS

In Sanity, content editors can open in a new tab the target URL of the page they want to preview or they can display it inside an iframe directly in the Studio.

Opening the preview in a new tab

Follow these instructions and add the content below to the file you created.

export default function resolveProductionUrl(document) {
  // Only show the preview option for documents for which a preview makes sense.
  if (document._type === "movie") {
    return `https://nuxt-sanity-movies.netlify.app/${document.slug.current}/?preview=true`
  }
  return undefined
}
Enter fullscreen mode Exit fullscreen mode

Showing the preview inside an iFrame

Create a JS file with the following content and add it as a new part in sanity.json, in the same fashion you did above.

import React from "react"
import S from "@sanity/desk-tool/structure-builder"

const url = "https://nuxt-sanity-movies.netlify.app/"

const WebPreview = ({ document }) => {
  const { displayed } = document
  const targetURL = url + displayed.slug.current + `/?preview=true`

  return (
    <iframe
      src={targetURL}
      frameBorder={0}
      width="100%"
      height="100%"
    />
  )
}

export const getDefaultDocumentNode = ({ schemaType }) => {
  // Conditionally return a different configuration based on the schema type
  if (schemaType === "movie") {
    return S.document().views([
      S.view.form(),
      S.view.component(WebPreview).title("Web Preview"),
    ])
  }
}

export default S.defaults()
Enter fullscreen mode Exit fullscreen mode

WebPreview is a React component (Sanity is a React SPA), but otherwise the code is just JavaScript and should be easy to follow for Vue developers. You can always refer to Sanity's extensive documentation.

Handling draft preview

You will notice that drafts are not fetched. The reason is that draft documents don't appear on the API to unauthenticated users. In order to preview drafts, the easiest way is to use auth cookie. When content editors log into the CMS, an auth cookie is automatically set by Sanity in their browser. If the Sanity client was initialized with withCredentials set to true, the cookie will be passed along with each request. And don't forget to allow credentials for your API point in the Sanity Dashboard.

const client = sanity({
  projectId: 'xxxxxxx',
  dataset: 'production',
  useCdn: false,
  withCredentials: true, // Add this line
})
Enter fullscreen mode Exit fullscreen mode

The problem now is that both versions will be returned: the published document and the draft. So if there's a draft, we'll get an array with two objects and using the default alphanumeric ordering, we don't know which version comes first. That is why we need to filter the result by _updateDate in our Nuxt page to be sure to get the draft first.

// _slug.vue
  const movie = await $sanity.fetch(
        "*[_type == 'movie' && slug.current == $slug] | order(_updatedAt desc)[0]",
        {
          slug: params.slug,
        }
      )

Enter fullscreen mode Exit fullscreen mode

Be careful with content validation when previewing drafts

Validation rules set in the Sanity Studio do not affect drafts. So make sure that empty required fields don't break the front end preview. The easy way guard against this is to add some v-if directives where code might break.

One important information that we should check for beforehand is the slug field. If this field isn't set, we should show no preview options.

Disable the Open preview option in the document context menu:

if (!document.slug?.current) {
   return undefined
}
Enter fullscreen mode Exit fullscreen mode

For the iframe, the best way is to simply show a custom HTML page:

if (!document.slug?.current) {
    return <h1>Please set a slug to see a preview</h1>
}
Enter fullscreen mode Exit fullscreen mode

Already done!

What I like the most about this whole approach is that we only had to make a few edits to our pages or Vue components. The need to add a filter to get the draft first is a but unfortunate, but I couldn't find a better way.
Another interesting detail I noticed is that once the enabePreview method has been called, the Nuxt app stays in preview mode after navigating to different pages. So a content editor can preview his blog post and then navigate to the blog listing page to see how his post teaser looks like.

Going the extra mile 🏃

Preview banner with $nuxt.refresh

In our current implementation, content editors need to reload the page (= reinitialize the whole Nuxt app) when they want to refresh the preview.
When the preview is opened in a new tab, this is not an issue, but when it's opened inside the iframe in the Sanity Studio, we don't want content editors to reload the whole CMS or have to close and reopen the iframe (and thus losing their scroll position on the page).

In Nuxt v2.9.0, a nifty feature was quietly added and was only properly documented later: the $nuxt.refresh context helper. Basically, it allows us to call asyncData (and fetch) on the client side, without doing a full reload.

This gave me the idea of showing a banner when the preview mode is activated. That banner contains a button which invokes $nuxt.refresh. Content editors can click that button to refresh the preview.

Here's a GitHub repo with an example about how to implement such a banner. I had to wrap the banner inside client-only tags because otherwise there would be a mismatch between the client and the server node tree and the hydration would break.

Another built-in property that is useful for our banner component is $nuxt.isPreview, which returns true if we're in preview mode. It means that we can easily show our banner conditionally in this way:

<PreviewBanner v-if="$nuxt.isPreview" />
Enter fullscreen mode Exit fullscreen mode

Real-time preview (the holy grail?)

In Sanity, every edit is saved in real-time and the Sanity client even comes with a listen method to react to content changes. You can set up the listener in the mounted hook of the page to be previewed.

  mounted() {
    if (this.$route.query.preview)
      this.$sanity
        .listen('*[_type == "movie" && slug.current == $slug][0]', {
          slug: this.$route.params.slug,
        })
        .subscribe((update) => {
      this.movie = update.result
        })
  }
Enter fullscreen mode Exit fullscreen mode

However, this method doesn't retrieve the references of the fetched document. If your document has no references, the Sanity listener is perfect. Otherwise one possible solution would be to call $nuxt.refresh in the subscribe callback:

.subscribe((update) => {
    this.$nuxt.refresh()
 })
Enter fullscreen mode Exit fullscreen mode

But I would advise against that, for four reasons:

  1. Do content editors really need a real-time preview? As a web developer, I'm excited about the real-time preview, but I personally doubt that it's very useful in practice.
  2. It could get expensive (financially and computationally). While the Sanity listen method uses WebSockets, our implementation with $nuxt.refresh will fire additional HTTP requests at every keystroke in the Studio.
  3. In my implementation, the content shown in the front end was always on step/update behind. (This can probably be fixed.)
  4. I prefer using the lightweight PicoSanity client, which doesn't support the listen method.

Glad to hear your feedback on this

What are your thoughts about the preview banner? Are you already using the new Nuxt preview mode? How do usually handle previews from Sanity in your Nuxt apps? What do you think of a real-time preview for content editing? I personally have never used Sanity as a content editor, so it's hard for me to judge.
Share your opinion and experience in the comment section below!

All the code snippets are taken from the following repositories. The Studio uses the sci-fi movies test dataset offered by the Sanity CLI when initializing a new project.

GitHub logo mornir / movies-web

🍿 Simple Nuxt (full static) + Sanity with preview mode

GitHub logo mornir / movies-studio

🎬 A Sanity Studio initialized with the sci-fi movies dataset

. . . . . . . . . . . . . . . . . . . . . . . . . . .