Visual Editing with 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 Remix front end.
Following this guide, you'll create a new Remix application and a new Sanity project with a preconfigured Sanity Studio. You may be able to follow along with an existing project to add this functionality.
- The final code created in this Guide is available as a repository on GitHub.
- Want a fresh start? See the official Remix + Sanity clean template that implements the same patterns.
- Looking for a complete example project? This complete Remix and Sanity template can be installed from the command line and is fully configured with an embedded Sanity Studio.
- TypeScript is optional. All the code examples here are authored in TypeScript, but using it is not necessary for this functionality. You can still use JavaScript but may need to remove the typings from the example code.
- You already have an account with Sanity
- You’re somewhat familiar with Sanity Studio and Remix
- You’re comfortable JavaScript, React, and the command line.
Using the below command, initialize a new Remix application from the command line and follow the prompts.
This command will install Remix in a new directory named remix-live-preview.
With a template that is just plain Remix with Typescript and Tailwind CSS already configured, and will immediately install dependencies.
# from the command line
npx create-remix@latest remix-live-preview --template SimeonGriggs/remix-tailwind-typography --install
# enter the Remix application's directory
cd remix-live-preview
# run the development server
npm run dev
If you’re stuck with the installation process, see the Remix 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.
Next, you’ll create a new Sanity Studio for a new Sanity project in a separate folder.
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.
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.
import {
Links,
Meta,
Outlet,
Scripts,
ScrollRestoration,
useLoaderData,
} from "@remix-run/react";
import { VisualEditing } from "@sanity/visual-editing/remix";
import type { LinksFunction, LoaderFunction } from "@remix-run/node";
import stylesheet from "~/tailwind.css?url";
export const links: LinksFunction = () => [
{ rel: "stylesheet", href: stylesheet },
];
export const loader: LoaderFunction = async () => {
return {
preview: process.env.NODE_ENV === "development",
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,
},
};
};
export function Layout({ children }: { children: React.ReactNode }) {
const { preview, ENV } = useLoaderData<typeof loader>();
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
</head>
<body>
{children}
{preview ? <VisualEditing /> : null}
<script
dangerouslySetInnerHTML={{
__html: `window.ENV = ${JSON.stringify(ENV)}`,
}}
/>
<ScrollRestoration />
<Scripts />
</body>
</html>
);
}
export default function App() {
return <Outlet />;
}
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.
// ./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 a basic content fetch, however when working with live preview, a mix of server/client side fetching and stega encoding it is recommended to use the React loader package.
Create a new server-only file to export a loadQuery
function. See the Remix documentation on .server.ts
files.
// ./app/sanity/loader.server.ts
import * as queryStore from "@sanity/react-loader";
import { client } from "~/sanity/client";
import { studioUrl } from "~/sanity/projectDetails";
queryStore.setServerClient(client);
export const { loadQuery } = queryStore;
This loader takes the basic client setup in the previous file, but extends it with a read token and enables "stega" to return information required for visual editing along with your content.
Create one more file to store and reuse your GROQ queries:
// ./app/sanity/queries.ts
import groq from "groq"
export const POSTS_QUERY = groq`*[_type == "post" && defined(slug.current)] | order(_createdAt desc)`
export const POST_QUERY = groq`*[_type == "post" && slug.current == $slug][0]`
Check you have the following files set in your Remix 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 { Link } from "@remix-run/react";
import type { SanityDocument } from "@sanity/client";
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/_index.tsx
import { useLoaderData } from "@remix-run/react";
import type { SanityDocument } from "@sanity/client";
import Posts from "~/components/Posts";
import { loadQuery } from "~/sanity/loader.server";
import { POSTS_QUERY } from "~/sanity/queries";
export const loader = async () => {
const {data} = await loadQuery<SanityDocument[]>(POSTS_QUERY);
return { data };
};
export default function Index() {
const { data } = useLoaderData<typeof loader>();
return <Posts posts={data} />;
}
Visit your home page at http://localhost:3000; 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 Remix application.
// ./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 builder = 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={builder.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.
Create a new route to fix the 404's:
// ./app/routes/$slug.tsx
import type { LoaderFunctionArgs } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
import type { SanityDocument } from "@sanity/client";
import Post from "~/components/Post";
import { loadQuery } from "~/sanity/loader.server";
import { POST_QUERY } from "~/sanity/queries";
export const loader = async ({ params }: LoaderFunctionArgs) => {
const {data} = await loadQuery<SanityDocument>(POST_QUERY, params)
return { data };
};
export default function PostRoute() {
const { data } = useLoaderData<typeof loader>();
return <Post post={data} />;
}
Now, when you click a link on the home page, you should be taken to a page just like this:
You now have successfully created:
- A new Sanity Studio with some placeholder content
- A new Remix application with a home page that lists published blog posts with links to a unique page for each post – displaying rich text and an image.
The next step is to make the Remix application Presentation-ready to render interactive live previews!
The connection between Sanity and Remix is currently simple requests for published (and so publicly queryable) documents. You'll need to configure two items in Sanity Manage, which can easily be accessed from this menu in the Studio:
Because our Remix 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 Remix 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_PROEJCT_ID" SANITY_STUDIO_DATASET="production" SANITY_STUDIO_URL="http://localhost:3333" SANITY_READ_TOKEN="sk...."
You may need to restart your Remix 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
import * as queryStore from "@sanity/react-loader";
import { client } from "~/sanity/client";
import { studioUrl } from "~/sanity/projectDetails";
const token = process.env.SANITY_READ_TOKEN
if (!token) {
throw new Error('Missing SANITY_READ_TOKEN in .env')
}
const clientWithToken = client.withConfig({
token,
perspective: 'previewDrafts',
stega: {
enabled: true,
studioUrl,
},
});
queryStore.setServerClient(clientWithToken);
export const { loadQuery } = queryStore;
With these steps done, your Remix 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 Remix front end.
You now have Visual Editing! This is great!
But, it's always on. This is not great. You don't want every visitor to see draft content, or
Following the documentation on setting up locations in Presentation will create links from any document to all the places an author could expect to find them in the front end.
In your Studio project:
Create a new file for the locate
function
// ./presentation/locate.ts
import { DocumentLocationResolver } from "sanity/presentation";
import { map } from "rxjs";
// Pass 'context' as the second argument
export const locate: DocumentLocationResolver = (params, context) => {
// Set up locations for post documents
if (params.type === "post") {
// Subscribe to the latest slug and title
const doc$ = context.documentStore.listenQuery(
`*[_id == $id][0]{slug,title}`,
params,
{ perspective: "previewDrafts" } // returns a draft article if it exists
);
// Return a streaming list of locations
return doc$.pipe(
map((doc) => {
// If the document doesn't exist or have a slug, return null
if (!doc || !doc.slug?.current) {
return null;
}
return {
locations: [
{
title: doc.title || "Untitled",
href: `/${doc.slug.current}`,
},
{
title: "Posts",
href: "/",
},
],
};
})
);
}
return null;
}
Update your sanity.config.ts
file to import the locate function into the Presentation plugin.
// ./sanity.config.ts
// Add this import
import { locate } from './presentation/locate'
export default defineConfig({
// ...all other settings
plugins: [
presentationTool({
previewUrl: 'http://localhost:5173',
locate
}),
// ..all other plugins
],
})
You should now see the locations at the top of all post type documents: