Sanity Studio v3 is here. Find out more on our blog →
November 22, 2021

How to build a Remix website with and live preview

By Simeon Griggs

Remix is a new framework for creating React websites with a server/client approach instead of a static build step. In this guide, we'll hook up for content as well as Live Preview for a seamless content editing experience.


This guide contains code examples for an older version of Sanity Studio (v2), which is deprecated.

Learn how to migrate to the new Studio v3 →


There's a newer, better demo of Sanity Studio v3 in Remix with Live Preview!

Take a look at the example repository. The Readme has some details about all its features.

It uses the new @sanity/preview-kit and is much simpler to set up. This guide will eventually be updated with more details.


To follow this tutorial you will need:

  • Basic knowledge of JavaScript, React, and Remix (which is very new but the documentation is a great place to start)
  • Node.js installed, as well as the Sanity CLI
  • An understanding of GROQ for querying data from Sanity


The complete, finished version of the code produced in this guide is available for download from the sanity-remix-preview repository. Run sanity init from inside the ./studio folder to create a new project or to connect it with an existing one.

How it works

Remix has a focus on server-side logic of website requests. If you've only been building Jamstack sites might feel like a new concept – if you predate the Jamstack you'll feel right at home! is a platform for structured content with a fully featured API. You build your content model in schema files, and the Studio compiles into a great editing interface which you can deploy to the hosting of your choice.

When requests to your website are made, we can check for authentication and serve either published content or put the page into preview mode and continually listen to and show updates. No serverless function step is required.

For our Next.js live preview guide we use the next-sanity toolkit to query, preview and display Sanity content. In this guide, we'll set up those components individually to achieve the same result.

The package groq-store streams the entire dataset to your browser and overlays draft content into published documents.

Installing Sanity Studio

If you have an existing Sanity Studio, you can skip this step.

You could use this guide to add live preview and data fetching to an existing Remix project, but we'll focus on creating something completely new.

Let's start a new project, in a folder my-project

Create a new folder, in it create a studio folder and run sanity init inside to start a new Sanity project. In this guide we'll use the movies dataset to get started faster.

Installing Remix

If you have an existing Remix project, skip this step.

Back in the my-project folder, run npx create-remix and call the project web.

For this guide, we'll choose "Remix App Server" as the deploy target and "JavaScript" – but neither choice affects this guide.

You should now have a folder structure like this:

├─ studio/
└─ web/

In separate terminal windows you can now run:

  • sanity start from the studio folder, and view the studio at http://localhost:3333
  • npm run dev from the web folder, and view the Remix project at http://localhost:3000
Your blank Remix site ready for Sanity content

Getting content from Sanity into Remix

In the web folder, install picosanity

npm install picosanity

Picosanity is a "tiny Sanity client alternative". Since we're only querying data, we can keep our web project smaller by using it.

We'll put all our Sanity utilities for the Remix site in a dedicated folder, create these two files:

// ./web/app/lib/sanity/config.js

export const config = {
	apiVersion: "2021-03-25",
	// Find these in your ./studio/sanity.json file
	dataset: "production",
	projectId: "YOUR_PROJECT_ID",
	useCdn: false,
// ./web/app/lib/sanity/getClient.js

import PicoSanity from "picosanity";

import { config } from "./config";

// Standard client for fetching data
export const sanityClient = new PicoSanity(config);

// Authenticated client for fetching draft documents
export const previewClient = new PicoSanity({
	useCdn: false,
	token: process.env.SANITY_API_TOKEN ?? ``,

// Helper function to choose the correct client
export const getClient = (usePreview = false) =>
	usePreview ? previewClient : sanityClient;

You can see we're laying the groundwork for previews. But first, we'll focus on just querying published, public data from our project and injecting it into the Remix site.

Query Sanity content from Remix

Let's display a list of all movies on the home page. Open ./web/app/routes/index.jsx.

Data fetching in Remix is done inside a route's loader() function. It's here we'll import our getClient() and run a GROQ query to list all our movies.

// ./web/app/routes/index.jsx

// ...other imports, meta() and links() functions

import { getClient } from "~/lib/sanity/getClient";

// loader() must be async!
export async function loader() {
	const movies = await getClient().fetch(
		`*[_type == "movie"]{ _id, title, slug }`

	return { movies };

export default function Index() {
	let { movies } = useLoaderData();

	return (
		<div style={{ textAlign: "center", padding: 20 }}>
			{movies?.length > 1
				? => (
						<div style={{ padding: 10 }} key={movie._id}>
							<Link to={movie.slug.current}>{movie.title}</Link>
				: null}

If querying successfully, you should now have a list of movies, with links to each individual page. But those links will 404, we need to make a dynamic route to load those!

A list of links!

Create a new route file using the code below, beginning with $ to indicate a parameter. We'll use that parameter in our query.

// ./web/app/routes/$slug.jsx

import { useLoaderData } from "remix";

import { getClient } from "~/lib/sanity/getClient";

export async function loader({ params }) {
	// Query for _all_ documents with this slug
	// There could be two: Draft and Published!
	const initialData = await getClient().fetch(
		`*[_type == "movie" && slug.current == $slug]`,
		{ slug: params.slug }

	return { initialData };

export default function Movie() {
	let { initialData } = useLoaderData();

	return (
		<div style={{ textAlign: "center", padding: 20 }}>

Phew! You should now be able to load this page or any other movie from the home page.

What have we achieved so far?

  • ✅ New Sanity project and a Sanity Studio running locally
  • ✅ New Remix project running locally
  • ✅ Queried content from Sanity and displayed in Remix

Now let's add a preview mode and watch changes as they happen!

Quick aside, CORS and tokens

So that our studio and website can talk to each other, we'll need to adjust some CORS settings.

In go to the API tab and add a new CORS origin for http://localhost:3000 with credentials allowed.

Add a CORS origin so your Studio and Remix project can talk to each other

Note: You'll need to repeat this step once your website is deployed to add any deployed URLs as well.

In the web folder, create a .env file for secrets. These can be read server-side by Remix. This file should not be checked into git.

Back in, create a "viewer" token for your project and paste it into your .env file for the SANITY_API_TOKEN key. The other SANITY_PREVIEW_SECRET can be any random string.

# ./web/.env

# Create a "Viewer" Token in

# Any random string

Reading secrets in Remix

Remix doesn't load .env files by default, we'll need to enqueue them. In the web folder install the dotenv package.

npm install dotenv

Then import and initialize the package in your site's entry.server.jsx file

import dotenv from "dotenv";

// ...leave the parameters in this function as-is
export default function handleRequest() {

	// ...and the rest

Activating preview mode for querying drafts

For any request to any page, we'll check the URL for a ?preview= search parameter that may contain a secret string. If it exists and is correct, the preview client will load with an authentication token server-side to read draft documents.

Back in $slug.jsx we can update our loader() function and the default export to:

  1. Check for the ?preview=asdf-1234 search parameter
  2. Use either the normal or preview client as a result
  3. Pass a prop down to the page so we can show whether preview mode is activated or not
export async function loader({ request, params }) {
	const requestUrl = new URL(request?.url);
	const preview =
		requestUrl?.searchParams?.get("preview") ===

	// Query for _all_ documents with this slug
	// There could be two: Draft and Published!
	const initialData = await getClient(preview).fetch(
		`*[_type == "movie" && slug.current == $slug]`,
		{ slug: params.slug }

	return { initialData, preview };

export default function Movie() {
	let { initialData, preview } = useLoaderData();

	// Bonus! A helper function checks the returned documents
	// To show Draft if in preview mode, otherwise Published
	const movie = filterDataToSingleItem(initialData, preview);

	return (
		<div style={{ textAlign: "center", padding: 20 }}>
			{preview ? <div>Preview Mode Enabled</div> : null}

The function filterDataToSingleItem() can be seen in the example repo here. It ensures we're loading the correct document – either draft or published – based on whether the preview is enabled or not.

That function can be copied from ./web/app/lib/sanity in the demo repository.

Now if you visit a page with the correct URL, you should see "Preview Mode Enabled" across the top. You should also see unsaved changes from the current draft document when the page loads, and after a page reload.

You should now see Draft content in your Remix app

However, the page doesn't update as changes are made. Let's make that happen!

Finally, "Live" preview

It's time to pull in groq-store, a package that will stream the dataset into the browser and update as changes are made. Install in the web folder:

npm install @sanity/groq-store

You'll also need to grab two more files from the example repository.

  • ./web/app/lib/sanity/usePreviewSubscription.js a React hook for taking in a query and importing groq-store
  • ./web/app/components/Preview.jsx a component that calls the hook, designed only to be mounted if preview mode is enabled.

Once you have these two documents, we need to update our $slug.jsx one more time. The major differences are passing the query and query parameters down into the document – as well as importing the <Preview /> component which will update its state as it receives changes.

import { useState } from "react";
import { useLoaderData } from "remix";

import { getClient } from "~/lib/sanity/getClient";
import { filterDataToSingleItem } from "~/lib/sanity/filterDataToSingleItem";
import Preview from "~/components/Preview";

export async function loader({ request, params }) {
	const requestUrl = new URL(request?.url);
	const preview =
		requestUrl?.searchParams?.get("preview") ===

	// Query for _all_ documents with this slug
	// There could be two: Draft and Published!
	const query = `*[_type == "movie" && slug.current == $slug]`;
	const queryParams = { slug: params.slug };
	const initialData = await getClient(preview).fetch(query, queryParams);

	return {
		// If `preview` mode is active, we'll need these for live updates
		query: preview ? query : null,
		queryParams: preview ? queryParams : null,

export default function Movie() {
	let { initialData, preview, query, queryParams } = useLoaderData();

	// If `preview` mode is active, its component update this state for us
	const [data, setData] = useState(initialData);

	// Bonus, a helper function checks the returned documents
	// To show Draft if in preview mode, otherwise Published
	const movie = filterDataToSingleItem(data, preview);

	return (
		<div style={{ textAlign: "center", padding: 20 }}>
			{preview ? (
			) : null}
			{/* When working with draft content, optional chain _everything_ */}
			{movie?.title ? <h1>{movie.title}</h1> : null}

Now if you have your studio and Remix site side-by-side, you should see changes as you type!

Per-keystroke live preview. Neat!

This works by using a token-authenticated request to make the initial request on the server. Then by using your own logged-in credentials in the browser to keep streaming updates.

So anyone with the link can view the initial draft version of the document. But only users with access to your Sanity project – logged in on the same browser – will see live updates.

Bonus round, preview in the studio

Instead of opening two browser windows, you can display your website directly in the studio by leveraging Structure Builder to export your own default document views.

First, install the iframe-pane plugin for displaying your Remix website. From the studio folder, run

sanity install iframe-pane

Next, setup Structure Builder and configure your desk structure file with the following config:

import S from "@sanity/desk-tool/structure-builder";
import Iframe from "sanity-plugin-iframe-pane";

import { resolveProductionUrl } from "./resolveProductionUrl";

// Here we declare which view panes show up for which schema types
export const getDefaultDocumentNode = ({ schemaType }) => {
	if (schemaType === `movie`) {
		return S.document().views([
			// Including the iframe pane, with a function to create the url
				.options({ url: (doc) => resolveProductionUrl(doc) })

	return S.document();

// Then we export the default list of menu items
export default () =>

The resolveProductionUrl() function takes in the document and outputs a URL which is inserted into the iframe.

It also appends the preview secret. This needs to be the same random string that the Remix site is aware of.

All going well, you've now got Remix right alongside the writing surface.

Sanity Studio and Remix together in harmony

Next steps...

You'll notice some additional code in the final version. Including helpers to display images and render Portable Text fields.

Also worth noting, the "preview secret" is not exactly a strict form of security. While it does not leak any credentials or tokens, you may wish to harden this to prevent unwanted access.

Lastly, there's the step of deploying your site live (Remix gives you many options) and ensuring your pages are cached for super-fast loading, and less stress on your API quota. Content Is Data is a platform to build websites and applications. It comes with great APIs that let you treat content like data. Give your team exactly what they need to edit and publish their content with the customizable Sanity Studio. Get real-time collaboration out of the box. comes with a hosted datastore for JSON documents, query languages like GROQ and GraphQL, CDNs, on-demand asset transformations, presentation agnostic rich text, plugins, and much more.

Don't compromise on developer experience. Join thousands of developers and trusted companies and power your content with Free to get started, pay-as-you-go on all plans.

Other guides by author