Sanity.io SSR previews without changing your front-end code

The simplest approach to previews I've found so far

Backstory: how I got to this method

Back in September 2018, I published a guide on how to do live previews to react websites with Sanity which relied on listening to data changes from the client-side.

This works okay, but requires you to change your components and front-end code. Previews should be table-stakes for editorial experience, but when projects are running late they often get de-prioritized. That's why I started using the method below.

The easiest way to provide web previews for Sanity.io content: fetching the draft version of documents if the requested URL includes a ?preview=true query parameter.

Limitation

This only works in server-side rendering (SSR) contexts. If you're using static site generators like GatsbyJS, Eleventy or Bridgetown, you'll need to approach previews differently. If your site is built with a reactive framework like React, Svelte, or Vue, my guide on client-side Sanity.io previews may help 😊

Here's an example with SvelteKit for a blog post route handler:

import { sanityClient } from '$lib/client';

// If requests include a ?preview=true query parameter, we're in preview mode
export function isPreview(request) {
	return Boolean(request.url.searchParams?.get('preview'));
}

export const get = async (event) => {
	const { slug } = event.params;
	const data = await sanityClient.fetch(/* groq */`{
    "post": *[
			_type == "post" &&
			slug.current == $slug
      // If not in preview, only show published documents
			${isPreview(event) ? '' : '&& !(_id in path("drafts.**"))'}
		]
		${
			isPreview(event)
				? // In preview, get draft if existent - it'll have the freshest _updatedAt
				  '| order(_updatedAt desc)'
				: ''
		}	
		[0]{
			..., // your query here
		},
  }`,
		{ slug }
	);

  if (!data.post?._id) {
    return {
      status: 404
    }
  }

	return {
		status: 200,
		body: data,
	};
};

The three main parts of the code above:

  • We define if we're in preview mode by checking the "preview" query parameter and converting it to a boolean
  • If we're not in preview, exclude draft documents (!(_id in path("drafts.**")) in the query's filter)
  • If we are in preview, give preference to drafts
    • We could query only drafts, but if the document was published and didn't have a draft, the page would return a 404.
    • Instead, we order all documents that match the given slug by their _updatedAt value. As drafts are the freshest, they'll always show up first.
    • However, if no draft is present, the published version will show up.
Lost?

If the GROQ query above is unclear, my tiny course for the language may help you 😊

Accessing previews from the Sanity studio

An example of the preview UI in the Sanity studio

To replicate the UI above, you can use Simeon Griggs' great sanity-plugin-iframe-pane and add it as a custom view to previewable documents.

If you don't have a custom structure builder, start by following the official docs on custom document views.

// /deskStructure.js
import S from '@sanity/desk-tool/structure-builder';
import { EarthGlobeIcon, EditIcon } from '@sanity/icons';
import Iframe from 'sanity-plugin-iframe-pane';

// Replace with your own approach to getting the URL for each document
function resolveProductionUrl(document) {
	return slugToAbsUrl(
		getDocumentPath({
			_type: document._type,
			slug: document.slug?.current
		})
	);
}

// Example where only the homepage, case studies & posts are previewable
const previewableTypes = ['home', 'caseStudy', 'post'];

/**
 * Defines views/tabs for each content type.
 */
export const getDefaultDocumentNode = (props) => {
	if (!previewableTypes.includes(props.schemaType)) {
		return S.document().views(S.view.form());
	}

	return S.document().views([
		S.view.form().icon(EditIcon),
		S.view
			.component(Iframe)
			.options({
				defaultSize: 'desktop',
				url: (doc) => resolveProductionUrl(doc) + '?preview=true',
				reload: {
					button: true
				}
			})
			.icon(EarthGlobeIcon)
			.title('Preview')
	]);
};

Want to have live previews that change as users edit? In my experience, this is more of a wasteful distraction than a useful feature - I want to focus on writing most of the time and only check how it'll visually look every once in a while.

But, if that's your jam, you can pass the document's _rev version id as a separate query parameter in the URL to constantly refresh the iFrame as users edit the content.

S.view
  .component(Iframe)
  .options({
    defaultSize: 'desktop',
    url: (doc) => resolveProductionUrl(doc) + `?preview=true&_rev=${doc._rev}`,
    reload: {
      button: true
    }
  })
  .icon(EarthGlobeIcon)
  .title('Preview')

---

And that's it! You'll need to change your GROQ queries slightly, but compared to changing components and creating dedicated preview routes, this method is much simpler than my previous iteration 😊