Visual Editing with React Router (Remix) and Sanity Studio
To give your content creators the best possible experience, let them see what their content looks like before they press publish. In this guide, you'll setup Presentation in the Studio to get interactive live previews of your React Router front end.
This guide deliberately focuses on the experience of manually creating a new React Router 7 (formerly known as Remix) application and creating a new Sanity project with a separate Studio.
All the instructions below could also be adapted to an existing React Router 7 application.
- Need reference code sooner? The final code created in this Guide is available as a repository on GitHub.
- Looking for a complete example project? This complete React Router and Sanity template can be installed from the command line and is fully configured with an embedded Sanity Studio.
- TypeScript is not required. The code examples in this guide are all in TypeScript; However, TypeScript is not necessary for any of this to work. You must remove the types from these examples if you work with plain JavaScript.
- You already have an account with Sanity
- You’re somewhat familiar with Sanity Studio and React Router
- You’re comfortable JavaScript, React, and the command line
The following terms describe the functions that combine to create an interactive live preview, known as Visual Editing.
Visual Editing can be enabled on any hosting platform or front end framework.
- Perspectives modify queries to return either draft or published content. These are especially useful for server-side fetching to display draft content on the initial load when previewing drafts.
- Content Source Maps aren't something you'll need to interact with directly, but they are used by Stega encoding when enabled. They are an extra response from the Content Lake that notes the full path of every field of returned content.
- Stega encoding is when the Sanity Client takes Content Source Maps and combines every field of returned content with an invisible string of characters which contains the full path from the content to the field within its source document.
- Overlays are created by a dedicated package that looks through the DOM for these stega encoded strings and creates clickable links to edit documents.
- Presentation is a plugin included with Sanity Studio to simplify displaying a front end inside an iframe with an adjacent document editor. It communicates directly with the front end instead of making round-trips to the Content Lake for faster live preview.
Using the below command, initialize a new React Router application from the command line and follow the prompts.
This command will create a new directory named react-router-live-preview
using default settings and immediately install dependencies.
# from the command line
npx create-react-router@latest react-router-live-preview -y
# enter the React Router application's directory
cd react-router-live-preview
# run the development server
npm run dev
If you’re stuck with the installation process, see the React Router documentation for further instructions on how to get started.
Visit http://localhost:5173 in your web browser, and you should see this landing screen to show it’s been installed correctly.
Now—in a separate folder—you’ll create a new Sanity Studio for a new Sanity project .
The command below uses the preconfigured schema from the blog template.
# from the command line
npm create sanity@latest -- --template blog --create-project "Sanity Live Preview" --dataset production --typescript --output-path sanity-live-preview
# follow the prompts to install dependencies
# enter the new project's directory
cd sanity-live-preview
# run the development server
npm run dev
For more complete instructions and troubleshooting, our documentation covers how to create a Sanity project.
You should now have your Studio and React Router apps in two separate, adjacent folders:
/sanity-live-preview -> contains a Sanity Studio /react-router-live-preview -> contains a React Router app
From which you can separately run both application’s development servers:
- http://localhost:5173 for the React Router application
- http://localhost:3333 for the Sanity Studio
Open http://localhost:3333 in your browser, and after logging in, you should see a new Studio with the Blog template schema already created.
There are currently no documents!
Create and publish a few new post
type documents.
Work inside the react-router-live-preview
directory for this section
To query and display Sanity content inside the React Router application, you must install a few packages first.
# in /react-router-live-preview
npm install @sanity/client @sanity/react-loader @sanity/visual-editing @sanity/image-url @portabletext/react groq
This command installs:
@sanity/client
: A package to simplify interacting with the Content Lake@sanity/react-loader
: Functions that simplify querying and live-reloading data from Sanity@sanity/visual-editing
: Functions for rendering clickable links when in preview mode to enable visual editing@sanity/image-url
: Functions to create complete image URLs from just the ID of a Sanity image asset.@portabletext/react
: A component to render block content from a Portable Text field with configuration options.groq
provides syntax highlighting in your code editor for GROQ queries
To orchestrate these together, you'll need to create a few files.
Create a file for your environment variables. None of these are secrets that must be protected, but it will help you to customize them whenever you deploy your app.
# .env SANITY_STUDIO_PROJECT_ID="REPLACE_WITH_YOUR_PROJECT_ID" SANITY_STUDIO_DATASET="production" SANITY_STUDIO_URL="http://localhost:3333"
You can find your Project ID in the sanity.config.ts
file of your Studio.
Update your app's root.tsx
file with the code below to do the following:
1. Update the imports, adding an import for the VisualEditing
component from @sanity/visual-editing/react-router
// app/root.tsx
import {
// ..all other imports
useRouteLoaderData,
} from "react-router";
import { VisualEditing } from "@sanity/visual-editing/react-router";
2. On the server, within the loader
, determine if the app is in "preview mode" or not. As well as send along the Sanity Project details from environment variables.
// app/root.tsx
export async function loader() {
return {
preview: import.meta.env.DEV,
ENV: {
SANITY_STUDIO_PROJECT_ID: process.env.SANITY_STUDIO_PROJECT_ID,
SANITY_STUDIO_DATASET: process.env.SANITY_STUDIO_DATASET,
SANITY_STUDIO_URL: process.env.SANITY_STUDIO_URL,
SANITY_STUDIO_API_VERSION: process.env.SANITY_STUDIO_API_VERSION,
},
};
}
3. Within the Layout component, retrieve the values from the loader
// app/root.tsx
export function Layout({ children }: { children: React.ReactNode }) {
const { preview, ENV } = useRouteLoaderData("root");
// ...the rest of the component
4. Within the HTML returned by the Layout
component, add the VisualEditing
component and a script to write the environment variables to the window.
// app/root.tsx
<body>
{/* everything else in the body */}
{preview ? <VisualEditing /> : null}
<script
dangerouslySetInnerHTML={{
__html: `window.ENV = ${JSON.stringify(ENV)}`,
}}
/>
</body>
Gotcha
For now you'll just enable preview mode in development, later in this guide you'll setup a session cookie to activate preview mode dynamically.
Create a new file to retrieve these project details throughout your app.
These will be used to configure a Sanity Client, the Loader functions, and build image URLs.
// app/sanity/projectDetails.ts
declare global {
interface Window {
ENV: {
SANITY_STUDIO_PROJECT_ID: string;
SANITY_STUDIO_DATASET: string;
SANITY_STUDIO_URL: string;
SANITY_STUDIO_API_VERSION: string;
};
}
}
const {
SANITY_STUDIO_PROJECT_ID,
SANITY_STUDIO_DATASET,
SANITY_STUDIO_URL,
SANITY_STUDIO_API_VERSION,
} = typeof document === "undefined" ? process.env : window.ENV;
if (!SANITY_STUDIO_PROJECT_ID)
throw new Error("Missing SANITY_STUDIO_PROJECT_ID in .env");
if (!SANITY_STUDIO_DATASET)
throw new Error("Missing SANITY_STUDIO_DATASET in .env");
if (!SANITY_STUDIO_URL) throw new Error("Missing SANITY_STUDIO_URL in .env");
export const projectId = SANITY_STUDIO_PROJECT_ID;
export const dataset = SANITY_STUDIO_DATASET;
export const studioUrl = SANITY_STUDIO_URL;
export const apiVersion = SANITY_STUDIO_API_VERSION || "2024-11-01";
Create a file to create a new Sanity Client. A library for interacting with all of Sanity's API's.
// app/sanity/client.ts
import { createClient } from "@sanity/client";
import { apiVersion, dataset, projectId } from "~/sanity/projectDetails";
export const client = createClient({
projectId,
dataset,
apiVersion,
useCdn: true,
perspective: "published",
});
Sanity Client can be used for basic content fetching. However, for the fastest live preview experience, a mix of server and client-side fetching with stega encoding it is recommended to use the React loader package.
Create a new server-only file to export a loadQuery
function. See the React Router documentation on .server.ts
files.
// app/sanity/loader.server.ts
import * as queryStore from "@sanity/react-loader";
import { client } from "~/sanity/client";
queryStore.setServerClient(client);
export const { loadQuery } = queryStore;
This Sanity "loader" takes the basic client setup in the previous file, but extends it with a read token (which we'll setup later) to set the correct perspective when in preview mode.
Create one more file to store and reuse your GROQ queries:
// app/sanity/queries.ts
import { defineQuery } from "groq";
export const POSTS_QUERY = defineQuery(
`*[_type == "post" && defined(slug.current)] | order(_createdAt desc)`
);
export const POST_QUERY = defineQuery(
`*[_type == "post" && slug.current == $slug][0]`
);
Protip
Install the Sanity VS Code extension to get GROQ syntax highlighting when using the defineQuery
helper function.
Check you have the following files set in your React Router project:
app/ └─ sanity/ ├─ client.ts ├─ loader.server.ts ├─ projectDetails.ts └─ queries.ts
You’ll now confirm that you can query published documents from Sanity before setting up Presentation to display draft content.
Create a new component to display a list of Posts:
// app/components/Posts.tsx
import type { SanityDocument } from "@sanity/client";
import { Link } from "react-router";
export default function Posts({ posts }: { posts: SanityDocument[] }) {
return (
<main className="container mx-auto grid grid-cols-1 divide-y divide-blue-100">
{posts?.length > 0 ? (
posts.map((post) => (
<Link key={post._id} to={post.slug.current}>
<h2 className="p-4 hover:bg-blue-50">{post.title}</h2>
</Link>
))
) : (
<div className="p-4 text-red-500">No posts found</div>
)}
</main>
);
}
Update the index route to include a loader
that will use the Sanity Client to query all post
documents with a slug.
// app/routes/home.tsx
import type { SanityDocument } from "@sanity/client";
import Posts from "~/components/Posts";
import { loadQuery } from "~/sanity/loader.server";
import { POSTS_QUERY } from "~/sanity/queries";
import type { Route } from "./+types/home";
export async function loader() {
const { data } = await loadQuery<SanityDocument[]>(POSTS_QUERY);
return { data };
}
export default function Home({ loaderData }: Route.ComponentProps) {
return <Posts posts={loaderData.data} />;
}
Visit your home page at http://localhost:5173 it should now look like the image below. If not, your Studio likely has no published posts yet!
Open up your Sanity Studio, create and publish a few new post
type documents and refresh your React Router application.
You're now fetching and rendering Sanity content! However, you’ll get a "404 Not Found" page if you click one of these links.
Create a new Post component to display a single Post.
// app/components/Post.tsx
import { PortableText } from "@portabletext/react";
import imageUrlBuilder from "@sanity/image-url";
import type { SanityDocument } from "@sanity/client";
import { projectId, dataset } from "~/sanity/projectDetails";
const urlFor = imageUrlBuilder({ projectId, dataset });
export default function Post({ post }: { post: SanityDocument }) {
const { title, mainImage, body } = post;
return (
<main className="container mx-auto prose prose-lg p-4">
{title ? <h1>{title}</h1> : null}
{mainImage ? (
<img
className="float-right m-0 w-1/3 ml-8 mt-2 rounded-lg"
src={urlFor.image(mainImage).width(300).height(300).quality(80).url()}
width={300}
height={300}
alt={title}
/>
) : null}
{body ? <PortableText value={body} /> : null}
</main>
);
}
Protip
Notice how the code in this component checks first if a value exists before displaying any data? This is necessary when working later with live preview, where you cannot guarantee the existence of any value.
Update the routes.ts
file to add a route with a dynamic segment, where part of the URL will be provided to the route as params.slug
.
// app/routes.ts
import { type RouteConfig, index, route } from "@react-router/dev/routes";
export default [
index("routes/home.tsx"),
route(":slug", "routes/post.tsx"),
] satisfies RouteConfig;
Create the new route to render individual post pages:
// app/routes/post.tsx
import type { SanityDocument } from "@sanity/client";
import type { Route } from "./+types/post";
import Post from "~/components/Post";
import { loadQuery } from "~/sanity/loader.server";
import { POST_QUERY } from "~/sanity/queries";
export const loader = async ({ params }: Route.LoaderArgs) => {
const { data } = await loadQuery<SanityDocument>(POST_QUERY, params);
return { data };
};
export default function PostRoute({ loaderData }: Route.ComponentProps) {
return <Post post={loaderData.data} />;
}
Now, when you click a link on the home page, you should be taken to a page just like this:
On single post pages, the Portable Text field from the Studio is being rendered into HTML by the <PortableText />
component.
Install the Tailwind CSS Typography package to quickly apply beautiful default styling:
npm install -D @tailwindcss/typography
Update your tailwind.config.js
file's plugins to include it:
// tailwind.config.ts
import type { Config } from "tailwindcss";
import typography from "@tailwindcss/typography";
const config: Config = {
// ...other settings
plugins: [typography],
}
export default config;
This package styles the prose
class names in the <Post />
component.
You now have successfully created:
- A new Sanity Studio with some placeholder content
- A new React Router application with a home page that lists published blog posts with links to an individual page for each post—displaying rich text and an image.
The next step is to make the React Router application Presentation-ready to render interactive live previews!
The connection between Sanity and React Router is currently fetch requests for published (and so, publicly queryable) documents.
For draft documents you'll need to make authenticated fetches. You'll need to configure two items in Sanity Manage, which can easily be accessed from this menu in the Studio:
Or you can run this from the terminal inside your Sanity Studio directory.
npx sanity manage
Because our React Router application will make client-side requests to the Sanity Studio across domains, its URL must be added as a valid CORS origin.
This can be done inside sanity.io/manage
- Navigate to the API tab and enter
http://localhost:5173
- Check ”Allow credentials.”
- Save
Important:
- Only set up CORS origins for URLs where you control the code.
- You must repeat this when you deploy your application to a hosting provider with its URL.
In the same section of Manage:
- Create a token with Viewer permissions
- Copy it to your
.env
file inside your React Router application
Gotcha
This token is can query for draft documents and considered secret and should not be committed to your repository or shared!
Your .env
file should now contain the following values:
# .env SANITY_STUDIO_PROJECT_ID="REPLACE_WITH_YOUR_PROJECT_ID" SANITY_STUDIO_DATASET="production" SANITY_STUDIO_URL="http://localhost:3333" SANITY_READ_TOKEN="sk...."
You may need to restart your React Router application's development server in order to use the token.
Update the loader configuration with the code below, which will allow it to:
- Use a token to query for draft content
- Query with the
previewDrafts
perspective to return draft content over published content - Return stega encoding as invisible characters inside of the content to power the Visual Editing overlays
// app/sanity/loader.server.ts
import * as queryStore from "@sanity/react-loader";
import { client } from "~/sanity/client";
import { studioUrl } from "./projectDetails";
const token = process.env.SANITY_READ_TOKEN;
if (!token) {
throw new Error("Missing SANITY_READ_TOKEN in .env");
}
const clientWithToken = client.withConfig({
token,
stega: { enabled: import.meta.env.DEV, studioUrl },
});
queryStore.setServerClient(clientWithToken);
export const { loadQuery } = queryStore;
With these steps done, your React Router application should still largely work the same.
Update your Studio's config file inside the sanity-live-preview
directory to include the Presentation plugin:
// sanity.config.ts
// Add this import
import {presentationTool} from 'sanity/presentation'
export default defineConfig({
// ...all other settings
plugins: [
presentationTool({
previewUrl: 'http://localhost:5173'
}),
// ..all other plugins
],
})
You should now see the Presentation Tool available at http://localhost:3333/presentation.
You should now be able to click any of the text on screen, make edits, and momentarily see the changes render in the React Router front end.
You now have Visual Editing! This is great!
But, it's always on, and only in development. You could continue with this setup into deployment, and only activate Visual Editing on preview builds, and leave it deactivated in production.
This is not ideal, and can be fixed with a little more work.
Ideally you want the Presentation tool to enable Visual Editing, and let the user disable it themselves.
We'll take care of this in a moment. But first let's improve the link between documents and their locations in the front end.
Work in the Sanity Studio directory for this section.
The content of a document can be used in multiple places. In this simple example, even a post’s title is shown both on the individual post route and in the post listing on the home page. The Visual Editing mode enables preview across the whole site.
To show where its content is used and can be previewed within a document form, you must pass a configuration that tells the presentation tool where it can open any document.
Create a new file for the resolve
option in the Presentation plugin options:
// presentation/resolve.ts
import {
defineLocations,
PresentationPluginOptions,
} from "sanity/presentation";
export const resolve: PresentationPluginOptions["resolve"] = {
locations: {
// Add more locations for other post types
post: defineLocations({
select: {
title: "title",
slug: "slug.current",
},
resolve: (doc) => ({
locations: [
{
title: doc?.title || "Untitled",
href: `/${doc?.slug}`,
},
{ title: "Home", href: `/` },
],
}),
}),
},
};
Update your sanity.config.ts
file to import the locate function into the Presentation plugin.
You should now see the locations at the top of all post type documents:
// sanity.config.ts
// Add this import
import { resolve } from './presentation/locate'
export default defineConfig({
// ...all other settings
plugins: [
presentationTool({
previewUrl: 'http://localhost:5173',
resolve
}),
// ..all other plugins
],
})
You should now see the locations at the top of all post type documents:
To dynamically enable and disable Visual Editing, you'll need to prepare a session cookie in the application.
The Presentation tool can be configured to visit a predetermined route, pass along a secret in the URL, which your app can then validate. When validated, the session cookie will be set in your application, and Visual Editing will be enabled.
Create a file to configure the session cookie
// app/sessions.ts
import { createCookieSessionStorage } from "react-router";
export const PREVIEW_SESSION_NAME = "__preview";
if (!process.env.SANITY_SESSION_SECRET) {
throw new Error(`Missing SANITY_SESSION_SECRET in .env`);
}
const { getSession, commitSession, destroySession } =
createCookieSessionStorage({
cookie: {
name: PREVIEW_SESSION_NAME,
secrets: [process.env.SANITY_SESSION_SECRET],
sameSite: "lax",
},
});
export { commitSession, destroySession, getSession };
Update your .env
file to include SANITY_SESSION_SECRET
, which can be any random string.
# .env # ...all other variables SANITY_SESSION_SECRET="REPLACE_ME_WITH_A_RANDOM_STRING"
Update your routes.ts
file to include two new routes, one to enable and one to disable Visual Editing.
// app/routes.ts
import { type RouteConfig, index, route } from "@react-router/dev/routes";
export default [
index("routes/home.tsx"),
route(":slug", "routes/post.tsx"),
route("resource/preview/enable", "routes/resource/preview/enable.ts"),
route("resource/preview/disable", "routes/resource/preview/disable.ts"),
] satisfies RouteConfig;
Create the route which Presentation will visit, and pass a secret to, to either enable the session cookie, or reject the request.
// app/routes/resource/preview/enable.ts
import { redirect } from "react-router";
import { validatePreviewUrl } from "@sanity/preview-url-secret";
import { client } from "~/sanity/client";
import { commitSession, getSession } from "~/sessions";
import { projectId } from "~/sanity/projectDetails";
import { Route } from "./+types/enable";
export const loader = async ({ request }: Route.LoaderArgs) => {
if (!process.env.SANITY_READ_TOKEN) {
throw new Response("Preview mode missing token", { status: 401 });
}
const clientWithToken = client.withConfig({
token: process.env.SANITY_READ_TOKEN,
});
const { isValid, redirectTo = "/" } = await validatePreviewUrl(
clientWithToken,
request.url
);
if (!isValid) {
throw new Response("Invalid secret", { status: 401 });
}
const session = await getSession(request.headers.get("Cookie"));
await session.set("projectId", projectId);
return redirect(redirectTo, {
headers: {
"Set-Cookie": await commitSession(session),
},
});
};
Create another route to disable preview mode, when the route is visited.
// app/routes/resource/preview/disable.ts
import { redirect } from "react-router";
import { destroySession, getSession } from "~/sessions";
import { Route } from "./+types/disable";
export const loader = async ({ request }: Route.LoaderArgs) => {
const session = await getSession(request.headers.get("Cookie"));
return redirect("/", {
headers: {
"Set-Cookie": await destroySession(session),
},
});
};
Create a new component which will only be rendered when preview mode is active, to allow the user to disable preview mode.
// app/components/DisablePreviewMode.tsx
export function DisablePreviewMode() {
return (
<a
href="/resource/preview-mode/disable"
className="fixed bottom-4 right-4 bg-gray-50 px-4 py-2"
>
Disable Preview Mode
</a>
);
}
Now back in your Sanity Studio, the Presentation tool configuration can be updated to visit this resource route.
Update your Studio's sanity.config.ts
file
// sanity.config.ts
export default defineConfig({
// ... all other config settings
plugins: [
// ...all other plugins
presentationTool({
previewUrl: {
previewMode: {
enable: 'http://localhost:5173/resource/preview/enable',
},
},
resolve
}),
],
})
Using the Presentation tool should now set a session cookie successfully, but the app doesn't actually function any differently. This means inspecting the request
in every route loader
, and applying the correct query options depending on whether the request is authenticated or not.
Create a new file to check if preview mode is active, and to return the correct query options.
// app/sanity/loadQueryOptions.ts
import type { loadQuery } from "@sanity/react-loader";
import { getSession } from "~/sessions";
import { projectId, studioUrl } from "./projectDetails";
export async function loadQueryOptions(
headers: Headers
): Promise<{ preview: boolean; options: Parameters<typeof loadQuery>[2] }> {
const previewSession = await getSession(headers.get("Cookie"));
const preview = previewSession.get("projectId") === projectId;
return {
preview,
options: {
perspective: preview ? "previewDrafts" : "published",
stega: preview ? { enabled: true, studioUrl } : undefined,
},
};
}
Update the root.tsx
layout route file to use the request headers to check if preview mode is active, and pass the value down. Also render the button to disable preview mode, if it is active.
// app/root.tsx
// ...all your imports
import { loadQueryOptions } from "~/sanity/loadQueryOptions";
import { DisablePreviewMode } from "~/components/DisablePreviewMode";
// Update your loader
export async function loader({ request }: Route.LoaderArgs) {
const { preview } = await loadQueryOptions(request.headers);
return {
preview,
// ...and the rest
}
}
// Update the default export
{preview ? (
<>
<DisablePreviewMode />
<VisualEditing />
</>
) : null}
Update loader.server.ts
with the minimum required Client configuration, so that Stega is not enabled by default.
// app/sanity/loader.server.ts
const clientWithToken = client.withConfig({
token,
stega: { studioUrl },
});
You can now confirm this is working, because in preview mode you'll see the "Disable Preview Mode" button in the bottom right. And in an incognito browser window, you won't see the button.
However, because the loader is still set to retrieve drafts by default, you're still seeing draft content. You'll need to update each route loader to fix this.
Update the home page loader to use the correct query options
// app/routes/home.tsx
// ...all your imports
import { loadQueryOptions } from "~/sanity/loadQueryOptions";
import type { Route } from "./+types/home";
export async function loader({ request }: Route.LoaderArgs) {
const { options } = await loadQueryOptions(request.headers);
const { data } = await loadQuery<SanityDocument[]>(POSTS_QUERY, {}, options);
return { data };
}
Update the individual post loader to use the correct query options.
// app/routes/post.tsx
// ...all your imports
import { loadQueryOptions } from "~/sanity/loadQueryOptions";
export const loader = async ({ request, params }: Route.LoaderArgs) => {
const { options } = await loadQueryOptions(request.headers);
const { data } = await loadQuery<SanityDocument>(POST_QUERY, params, options);
return { data };
};
Great! You now have dynamically activated Visual Editing on both the home page and individual post page routes.
The Next.js Visual Editing guide demonstrates (in a framework-agnostic way) how to add drag-and-drop support for an array of "related posts."