Live preview changes to react websites with Sanity

Learn how to make your clients’ lives easier with a simple-to-setup live preview for their CMS with any website built using a framework like react, vue or stencil.

I personally got used to present static sites to clients as a clear tradeoff between security/performance/flexibility and ease of management, as they had to wait up to 4 minutes for a change made in the CMS to go live... Thankfully, though, by combining Sanity's listener API and the flexibility provided by React, we can allow non-technical writers to experience live-preview editing in their website!

Disclaimer: The set-up I have must be heavily adequated to each project, so I won't go through as much coding, but rather present some high level understanding of all this. If you have any suggestions, please tweet me at @hdorodev :D

Second note: You can use the same idea and apply it to any front-end framework or static site generator built on them ;)

Pre-requisites

You can do this with an existing website or create a test project, but there are a couple of things you absolutely need:

  1. A Sanity project with documents for previewing. Learn the in's and out's of building the CMS in their docs (it's really easy and flexible, promise);
  2. A website whose template system is based on a front-end framework such as React or Vue. It could perfectly be a static site generator such as Gatsby (which is what I'll be using) or Nuxt.js.

Understanding the preview process:

  1. You'll use Sanity to build a custom preview URL for each of your documents when users access them;
  2. This URL will point to a specific route in your website
  3. It'll also contain a query string that you'll use to know which document to query and run the fetch and listen methods of @sanity/client with this document's ID;
  4. Finally, you'll run the data through your front-end to render the proper templates and components. Then, as soon as someone makes a change to the document, sanity-client will fire the callback function of the listen method and allow your framework to re-render everything.

Building the preview URLs

Note: Sanity has a brief piece on previewing in their docs, feel free to read it detail.

Setting Sanity up for the preview is quite simple as all you want is to display a link on the document page (image below, credits to Sanity.io). To do so, you'll have to install the @sanity/production-preview plugin and implement a new part in your sanity.json file (at the root of the project) and point its path to a JS document. All this file does is returning a string with the preview URL.

Open preview prompt. Credits: https://sanity.io

First, run sanity install @sanity/production-preview and the CLI will automatically add this plugin to your project. Then, you'll want to edit your sanity.json file and make sure it contains the following:

// In sanity.json
{
  "plugins": [
    //...
    "@sanity/production-preview"
  ],
  "parts": [
    //...
    {
      "implements": "part:@sanity/production-preview/resolve-production-url",
      "path": "./getPreviewUrl.js" // or the path to the proper file
    }
  ]
}

Finally, create a getPreviewUrl.js file that follows something like this:

const isDraft = id => id.includes('drafts');

export default function resolveProductionUrl(document) {
  // First, we select a specific type of document
  if (document._type === 'page') {
    // Then we get its ID
    let id = document._id;
    // if it's a draft, we split its _id with the "drafts." substring, which will return an array,
    // and get the second item in it, which will be the isolated _id without "drafts."
    if (isDraft(id)) {
      id = document._id.split('drafts.')[1];
    }
    // And return a template string reflecting the URL structure we want. In this case, we're doing a
    // simple conditional to return '&isDraft=true' as a param for drafts as we'll query them
    // differently in the front-end
    return `https://yourUrl.com/preview?pageId=${id}${
      isDraft(document._id) ? '&isDraft=true' : ''
    }`;
  }
  return undefined;
}

Understanding the code above:

  1. We return a function that receives a document object from Sanity and should return a string. If it doesn't return a value the "Open preview" prompt simply won't show in the menu;
  2. This document object has an _id and type properties. You can use these to build your URLs as you want, but you absolutely need to return the _id to get the proper document in the front-end;
  3. In this example, I'm only interested in documents of type page, but you could return a &pageType=${document._type} parameter in your query string and process it accordingly in the front-end to return the proper template for the document.
  4. I'm also interested to know if the document is a draft or not. If it's, the function extracts only the id from the _id property (it would look like drafts.58abec-dd1238d-(...)) as I only want the actual identifier for the front-end.
  5. Finally, we build an URL from this information and return it for displaying on the document page ;)

This is it for the Sanity part, now let's move on to your front-end structure...

Using the URL on the website

Note: This step will depend on your tech stack, in this example I'm using Gatsby, but maybe you can get some ideas from reading on... Else, feel free to jump to the conclusions ;)

The first step you need is to create a route for the URL structure you provided before... for Gatsby, that means creating a preview.jsx file in your /src/pages directory and configuring gatsby-plugin-create-client-paths to tell the build system this page is client-only (or configure it manually through gatsby-node). This page needs to render a component that takes the window location (which could be provided by a router), parses the document id, fetches the data and listens to its changes and renders a template passing down the data. The following is my own implementation:

export const PreviewPage = () => {
  return (
    <LayoutBasis>
      {/* I use React Helmet to add a meta tag that avoids indexing of this page */}
      <Helmet>
        <meta name="robots" content="noindex, nofollow" />
      </Helmet>
      {/*
        @reach/router offers a <Match /> component that passes down router-related props
        to a child function. We use this function to either render the template if we're in
        the proper path - and pass the location prop to it -, or navigate to the homepage
      */}
      <Match path="/preview">
        {props => {
          if (!props.match) {
            navigate('/');
            return null;
          } else {
            return <PreviewTemplate location={props.location} />;
          }
        }}
      </Match>
    </LayoutBasis>
  );
};

Then, in your PreviewTemplate component, you'll get the pageId from the URL you built with Sanity through query-strings and fetch the proper document data with the proper query. You can also use paths for document IDs, but personally I had a hard time figuring it out on the server and ended up migrating to query strings, which are also super easy to use and flexible. It boils down to personal preference.

My PreviewTemplate component holds an internal state with a logged boolean that I use for simple (and insecure) client-side authentication, in which the user is prompted with a login form with hard-coded credentials: if they submit the correct user/pswd, this internal state gets updated with logged: true and we save this info to localStorage... but this is outside of the scope of this tutorial (lemme know if you wanna learn more about this), so here's a summarized version of this component:

// ...
import * as queryString from 'query-string';

export class PreviewTemplate extends React.Component {
  // Initial state
  state = {
    isLoading: true,
    data: undefined,
  };

  // Method for fetching data and updating state
  public fetchData = async () => {
    // Parse the query from the location prop
    const query = queryString.parse(this.props.location.search);
    // Get the pageId and isDraft from the generated object
    const { pageId, isDraft } = query;
    // Fetch data from Sanity by using a helper function
    const sanityData = await fetchDataFromSanity(pageId, isDraft);

    // If there's data, send it to state
    if (sanityData) {
      this.setState({
        isLoading: false,
        data: sanityData,
      });
    } else {
      this.setState({
        isLoading: false,
      });
    }
  };

  // When the component is first rendered, fetch data and,
  // if it's a draft, listen to changes
  public componentDidMount() {
    const query = queryString.parse(this.props.location.search);
    const { pageId, isDraft } = query;
    this.fetchData();
    if (isDraft) {
      // the subscribeToData helper function runs sanity-client's listen
      // method to create an observable that runs a callback function
      // every time the data is changed (in this case, this.fetchData)
      subscribeToData(pageId, this.fetchData);
    }
  }
  public render() {
    const {
      state: { isLoading, data },
    } = this;
    if (isLoading) {
      // If the data is still being fetched for the first time, return a loading
      // component (just a centralized h1 with "Loading..." as content)
      return <LoadingDiv />;
    }
    if (!data) {
      console.log('Data not found :(');
      navigate('/');
      return null;
    } else {
      // Finally, if it's not loading and there's data, render the desired
      // PageTemplate. Here you could do conditionals on the document type
      // and render different templates as needed ;)
      return <PageTemplate data={data} />;
    }
  }
}

Breaking down the above component:

  1. We hold a isLoading boolean and a data object in the state to update the PageTemplate component accordingly;
  2. Upon mounting, the component runs a fetchData internal method and, if the document is a draft (told by the query param isDraft), also subscribe to changes to the document;
  3. The fetchData method parses the query string and send the pageId and isDraft variables to a helper function fetchDataFromSanity, stored in another file, that grabs the data based on the Sanity client and normalizes it to my needs.
  4. This step is crucial: you must configure your client properly to handle drafts, get images' URLs with @sanity/image-url if you want to preserve hotspots and crops, normalize the data as your components need, etc.
  5. I didn't include this function here because, as I've said before, it's highly specific to my use-case, but if you want you can take a look at a gist containing both;
  6. If you're using gatsby-image you won't be able to use gatsby-plugin-sharp to generate fixed or fluid sources, so be sure to have a conditional on your components with images in order to render regular <img> tags!
  7. Then we render a template for our page, passing down the data from state. If it's still loading or there's no data, we handle them accordingly with simple conditionals in the rendering.

And that's it! You should be good to go if you've did a good job at normalizing the data for your page template's needs. To test in development, go into your getPreviewUrl.js file in the Sanity repo/folder and change https://yourUrl.com with your localhost equivalent, run sanity start and navigate to a document you want to open the preview of ;)

Caveats and limitations

As much as this is liberating for me and amazing for the clients, adding more value to my work without much effort, there are a few caveats I should point:

  • It's not a one-size-fit-all type of solution, and potentially requires a lot of time to deal with the data coming in. That's why you should understand the process before applying it, so then you can replicate it easily to other projects. Unfortunately, though, as far as I'm concerned this can't be turned into a plugin of sorts;
  • You can't do server-side manipulation of the data, such as Gatsby's gatsby-transformer-sharp plugin that allows you to process images and serve them super optimized. Make sure you have fallbacks for components that rely on these;
  • Sanity's observables can't return referenced documents' data in the result property from the returned object emitted by the listen function. That's why I pass down the fetchData callback to the listener, essentially re-querying my API twice. This comes at obvious performance issues but is nothing major in my experience.

That's all I know for now, hit me up if you have anything to add! I hope this guide has been useful to put you on the path of making "static" websites even more amazing and abandoning traditional CMSs for good, hehe