Unlock seamless workflows and faster delivery with our latest releases – get the details

Visual Editing with React Router/Remix

This guide will get you started with Sanity Visual Editing in a new or existing React Router (Remix) application.

Following this guide will enable you to:

  • Render overlays in your application, allowing content editors to jump directly from Sanity content to its source in Sanity Studio.
  • Edit your content and see changes reflected in an embedded preview of your application in Sanity’s Presentation tool.
  • Optional: Provide instant updates and seamless switching between draft and published content.

Prerequisites

  • A Sanity project with a hosted or embedded Studio. Read more about hosting here.
  • A React Router application. Follow this guide to set one up.

React Router application setup

The following steps should be performed in your React Router application.

Install dependencies

Install the dependencies that will provide your application with data fetching and Visual Editing capabilities.

npm install @sanity/client @sanity/visual-editing @sanity/preview-url-secret

Add environment variables

Create a .env file in your application’s root directory to provide Sanity specific configuration.

You can use Manage to find your project ID and dataset, and to create a token with Viewer permissions which will be used to fetch preview content.

The URL of your Sanity Studio will depend on where it is hosted or embedded.

# .env

# Public
PUBLIC_SANITY_PROJECT_ID="YOUR_PROJECT_ID"
PUBLIC_SANITY_DATASET="YOUR_DATASET"
PUBLIC_SANITY_STUDIO_URL="https://YOUR_PROJECT.sanity.studio"
# Private
SANITY_VIEWER_TOKEN="YOUR_VIEWER_TOKEN"

Application setup

Configure the Sanity client

Create a Sanity client instance to handle fetching data from Content Lake.

Configuring the stega option enables automatic overlays for basic data types when preview mode is enabled. You can read more about how stega works here.

// src/sanity/client.ts

import { createClient } from "@sanity/client";

declare global {
  interface Window {
    ENV: {
      PUBLIC_SANITY_PROJECT_ID: string;
      PUBLIC_SANITY_DATASET: string;
      PUBLIC_SANITY_STUDIO_URL: string;
    };
  }
}

const env = typeof document === "undefined" ? process.env : window.ENV;

export const client = createClient({
  projectId: env.PUBLIC_SANITY_PROJECT_ID,
  dataset: env.PUBLIC_SANITY_DATASET,
  apiVersion: "2024-12-01",
  useCdn: true,
  stega: {
    studioUrl: env.PUBLIC_SANITY_STUDIO_URL,
  },
});

Add preview mode

Preview mode allows authorized content editors to view and interact with draft content.

Create a preview helper file to manage preview sessions and return context about the current preview state.

// app/sanity/preview.ts

import { createCookieSessionStorage } from "react-router";
import type { FilteredResponseQueryOptions } from "@sanity/client";
import crypto from "node:crypto";

const { getSession, commitSession, destroySession } =
  createCookieSessionStorage({
    cookie: {
      httpOnly: true,
      name: "__sanity_preview",
      path: "/",
      sameSite: !import.meta.env.DEV ? "none" : "lax",
      secrets: [crypto.randomBytes(16).toString("hex")],
      secure: !import.meta.env.DEV,
    },
  });

async function previewContext(
  headers: Headers
): Promise<{ preview: boolean; options: FilteredResponseQueryOptions }> {
  const previewSession = await getSession(headers.get("Cookie"));

  const preview =
    previewSession.get("projectId") === process.env.PUBLIC_SANITY_PROJECT_ID;

  return {
    preview,
    options: preview
      ? {
          perspective: "previewDrafts",
          stega: true,
          token: process.env.SANITY_VIEWER_TOKEN,
        }
      : {
          perspective: "published",
          stega: false,
        },
  };
}

export { commitSession, destroySession, getSession, previewContext };

Create an API endpoint to enable preview mode when viewing your application in Presentation tool.

// app/routes/api/preview-mode/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_VIEWER_TOKEN) {
    throw new Response("Preview mode missing token", { status: 401 });
  }

  const clientWithToken = client.withConfig({
    token: process.env.SANITY_VIEWER_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),
    },
  });
};

Similarly, create an API endpoint to disable draft mode.

// app/routes/api/preview-mode/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),
    },
  });
};

Add these routes as new entries in your application’s routes file.

// app/routes.ts

import { type RouteConfig, index, route } from "@react-router/dev/routes";

export default [
  // Other routes
  route("api/preview-mode/enable", "routes/api/preview-mode/enable.ts"),
  route("api/preview-mode/disable", "routes/api/preview-mode/disable.ts"),
] satisfies RouteConfig;

Create a new component with a link to the disable endpoint. We add conditional logic to only render this for content authors when viewing draft content in a non-Presentation context.

// src/components/DisablePreviewMode.tsx

import { useEffect, useState } from "react";

export function DisablePreviewMode() {
  const [show, setShow] = useState(false);

  useEffect(() => {
    setShow(window === window.parent && !window.opener);
  }, []);

  return show && <a href="/api/preview-mode/disable">Disable Preview Mode</a>;
}

Enable Visual Editing

Create a Visual Editing wrapper component.

The imported <VisualEditing> component handles rendering overlays, enabling click to edit, and refreshing pages in your application when content changes. Render it alongside the <DisablePreviewMode> component you created above.

// app/components/SanityVisualEditing.tsx

import { VisualEditing } from "@sanity/visual-editing/react-router";
import { DisablePreviewMode } from "./DisablePreviewMode";

export function SanityVisualEditing() {
  return (
    <>
      <SanityVisualEditing />
      <DisablePreviewMode />
    </>
  );
}

In the root layout, use the loader to pass preview mode context and your public environment variables so that they can be accessed on the client.

Render the <SanityVisualEditing> wrapper component when preview mode is enabled.

// app/root.tsx

import {
  Outlet,
  Scripts,
  ScrollRestoration,
  useRouteLoaderData,
} from "react-router";
import type { Route } from "./+types/root";
import { SanityVisualEditing } from "~/components/SanityVisualEditing";
import { previewContext } from "~/sanity/previewContext";

export async function loader({ request }: Route.LoaderArgs) {
  const { preview } = await previewContext(request.headers);

  const ENV = {
    PUBLIC_SANITY_PROJECT_ID: process.env.PUBLIC_SANITY_PROJECT_ID,
    PUBLIC_SANITY_DATASET: process.env.PUBLIC_SANITY_DATASET,
    PUBLIC_SANITY_STUDIO_URL: process.env.PUBLIC_SANITY_STUDIO_URL,
  };

  return { preview, ENV };
}

export function Layout({ children }: { children: React.ReactNode }) {
  const { preview, ENV } = useRouteLoaderData("root");

  return (
    <html lang="en">
      <head>
        <meta charSet="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
      </head>
      <body>
        {children}
        <ScrollRestoration />
        <Scripts />
        {preview && <SanityVisualEditing />}
         <script
          dangerouslySetInnerHTML={{
            __html: `window.ENV = ${JSON.stringify(ENV)}`,
          }}
        />
      </body>
    </html>
  );
}

export default function App() {
  return <Outlet />;
}

Render a page in preview mode

Update your existing client.fetch calls with the options returned by the previewContext function. This ensures published content is served to end-users, and draft content with overlays is served when in preview mode.

// app/routes/home.tsx

import type { Route } from "./+types/home";
import type { SanityDocument } from "@sanity/client";
import { client } from "~/sanity/client";
import { previewContext } from "~/sanity/previewContext";

const query = `*[_type == "page"][0]{title}`;
type Response = SanityDocument<{title?: string }>

export async function loader({ request }: Route.LoaderArgs) {
  const { options } = await previewContext(request.headers);
  const data = await client.fetch<Response>(query, {}, options);
  return data;
}

export default function Home({ loaderData }: Route.ComponentProps) {
  return <h1>{loaderData.title}</h1>;
}

Studio setup

To setup Presentation tool in your Sanity Studio, import the tool from sanity/presentation, add it to your plugins array, and set previewUrl to the base URL of your application.

We similarly recommend using environment variables loaded via a .env file to support development and production environments.

// sanity.config.ts

import { defineConfig } from "sanity";
import { presentationTool } from "sanity/presentation";

export default defineConfig({
  // ... project configuration
  plugins: [
    presentationTool({
      previewUrl: {
        origin: process.env.SANITY_STUDIO_PREVIEW_ORIGIN,
        preview: "/",
        previewMode: {
          enable: "/api/preview-mode/enable",
          disable: "/api/preview-mode/disable",
        },
      },
    }),
    // ... other plugins
  ],
});

Optional extras

Enable instant updates and perspective switching

Instant updates and perspective switching require opting into using loaders to fetch data.

Add the React specific loader to your application dependencies:

Install dependencies

npm install @sanity/react-loader

Set up loaders

Create a new loader file which calls setServerClient and sets up the loadQuery helper function which will be used for fetching content on the server.

// src/sanity/loader.ts

import * as serverOnly from "@sanity/react-loader";
import { client } from "~/sanity/client";

const { loadQuery, setServerClient } = serverOnly;

setServerClient(client.withConfig({ token: process.env.SANITY_VIEWER_TOKEN }));

export { loadQuery };

Update your preview helper file to support loadQuery and remove the token option, as this is now configured at the server client level.

// app/sanity/preview.ts

import { createCookieSessionStorage } from "react-router";
import type { loadQuery } from "~/sanity/loader.server";
import crypto from "node:crypto"; const { getSession, commitSession, destroySession } = createCookieSessionStorage({ cookie: { httpOnly: true, name: "__sanity_preview", path: "/", sameSite: !import.meta.env.DEV ? "none" : "lax", secrets: [crypto.randomBytes(16).toString("hex")], secure: !import.meta.env.DEV, }, }); async function previewContext( headers: Headers
): Promise<{ preview: boolean; options: Parameters<typeof loadQuery>[2] }> {
const previewSession = await getSession(headers.get("Cookie")); const preview = previewSession.get("projectId") === process.env.PUBLIC_SANITY_PROJECT_ID; return { preview, options: preview ? { perspective: "previewDrafts", stega: true, } : { perspective: "published", stega: false, }, }; } export { commitSession, destroySession, getSession, previewContext };

Render a page in preview mode

In loader use the loadQuery function created above. The initial data returned here is passed to useQuery in the page component.

When in Presentation, useQuery will handle live updates as content is edited.

// app/routes/home.tsx

import type { Route } from "./+types/home";
import type { SanityDocument } from "@sanity/client";
import { loadQuery } from "~/sanity/loader.server";
import { previewContext } from "~/sanity/previewContext"; const query = `*[_type == "page"][0]{title}`; type Response = SanityDocument<{title?: string }> export async function loader({ request }: Route.LoaderArgs) { const { options } = await previewContext(request.headers);
const data = await loadQuery<Response>(query, {}, options);
return data; } export default function Home({ loaderData }: Route.ComponentProps) {
const { data } = useQuery(query, {}, { initial: loaderData });
return <h1>{data.title}</h1>;
}

Add data attributes for extended overlay support

useQuery also returns an encodeDataAttribute helper method for generating data-sanity attributes. These attributes give you direct control over rendering overlays in your application, and are especially useful if not using stega encoding.

// app/routes/home.tsx

import type { Route } from "./+types/home";
import type { SanityDocument } from "@sanity/client";
import { loadQuery } from "~/sanity/loader.server";
import { previewContext } from "~/sanity/previewContext";

const query = `*[_type == "page"][0]{title}`;
type Response = SanityDocument<{title?: string }>

export async function loader({ request }: Route.LoaderArgs) {
  const { options } = await previewContext(request.headers);
  const data = await loadQuery<Response>(query, {}, options);
  return data;
}

export default function Home({ loaderData }: Route.ComponentProps) {
  const { data, encodeDataAttribute } = useQuery(
    query,
    {},
    { initial: loaderData }
  );

return <h1 data-sanity={encodeDataAttribute(["title"])}>{data.title}</h1>;
}

Was this article helpful?