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:
- 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);
- 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:
- You'll use Sanity to build a custom preview URL for each of your documents when users access them;
- This URL will point to a specific route in your website
- It'll also contain a query string that you'll use to know which document to query and run the
fetch
andlisten
methods of @sanity/client with this document's ID; - 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 thelisten
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.
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:
- 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;
- This document object has an
_id
andtype
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; - 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. - 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 likedrafts.58abec-dd1238d-(...)
) as I only want the actual identifier for the front-end. - 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:
- We hold a
isLoading
boolean
and adata
object in the state to update thePageTemplate
component accordingly; - Upon mounting, the component runs a
fetchData
internal method and, if the document is a draft (told by the query paramisDraft
), also subscribe to changes to the document; - The
fetchData
method parses the query string and send thepageId
andisDraft
variables to a helper functionfetchDataFromSanity
, stored in another file, that grabs the data based on the Sanity client and normalizes it to my needs. - 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. - 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;
- If you're using
gatsby-image
you won't be able to usegatsby-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! - 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 thelisten
function. That's why I pass down thefetchData
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