
Connecting Sanity to Remix
To connect Sanity to Remix, create a Sanity client with @sanity/client in a server-only module, call client.fetch() with a GROQ query inside a route's loader, and read the result in your component from loaderData. Route params such as the slug are passed to the query as GROQ parameters, and a null result becomes a 404 with throw data(null, { status: 404 }). Remix v2's features now live in React Router framework mode, so new projects start with npx create-react-router@latest, and Sanity's own docs call this setup "React Router (Remix)". The loader pattern is the same one Remix developers already know.
Loaders are a good fit for headless content. They run on the server, so tokens stay private, and they return data before the page renders, so there are no loading spinners for content. This article explains where Remix stands today, then walks through configuring the client, loading lists and detail pages, handling 404s and metadata, caching with HTTP headers, writing to Sanity from actions, adding visual editing and moving an older Remix v2 project across.
Remix in 2026: What the Name Means Now
The Remix name has covered a few different things over the years, which matters when you search for examples:
| Name | What it is | Sanity guidance |
|---|---|---|
| Remix v1 and v2 | The original React framework, @remix-run/* packages | Older tutorials and templates |
| React Router v7 | Remix v2 merged into React Router as "framework mode" (late 2024) | Same patterns, new imports |
| React Router v8 | Current major version (June 2026), ESM-only, Node 22.22+, React 19.2.7+ | Sanity's "React Router (Remix)" quick start |
| Remix 3 | A separate, newer framework from the Remix team | Not covered here |
This guide targets React Router framework mode, because that's where Remix v2 apps have moved and where Sanity's quick start and visual editing guides point. If you're on Remix v2, the code translates almost line for line, and there's a migration section near the end. Remix 3 is a different framework with its own APIs; @sanity/client works in any JavaScript runtime, but the route conventions below won't apply to it.
Step 1: Create the App and Install Dependencies
If you're starting fresh, create a React Router app outside your Studio folder:
npx create-react-router@latest my-sanity-site
cd my-sanity-site
Then install the Sanity libraries:
npm install @sanity/client @sanity/image-url @portabletext/react groq
You'll use @sanity/client for queries, @sanity/image-url for image URLs, @portabletext/react for rich text and groq for defineQuery. If you don't have a Studio yet, set up your first Sanity project and add a post type with title, slug, publishedAt, mainImage and body.
Step 2: Configure the Sanity Client
React Router keeps any module ending in .server.ts out of the browser bundle. Putting the client there means a token can never leak into client code, even by accident:
// app/sanity/client.server.ts
import { createClient } from '@sanity/client';
export const client = createClient({
projectId: process.env.SANITY_PROJECT_ID!,
dataset: process.env.SANITY_DATASET ?? 'production',
apiVersion: '2026-10-01',
useCdn: true,
});
// For drafts, private datasets or writes
export const writeClient = client.withConfig({
token: process.env.SANITY_API_WRITE_TOKEN,
useCdn: false,
});
Add the values to a .env file:
# .env
SANITY_PROJECT_ID="abc123xy"
SANITY_DATASET="production"
SANITY_API_WRITE_TOKEN=""
Pin apiVersion to a date. From 2025-02-19 onwards, the default perspective is published, so queries return only published documents. useCdn: true serves responses from Sanity's API CDN, which is the right default for public pages; Sanity CDN vs Live API explains when to turn it off.
Step 3: Define Your Queries
Keep queries together in their own module:
// app/sanity/queries.ts
import { defineQuery } from 'groq';
export const POSTS_QUERY = defineQuery(`
*[_type == "post" && defined(slug.current)] | order(publishedAt desc)[0...20] {
_id,
title,
"slug": slug.current,
publishedAt
}
`);
export const POST_QUERY = defineQuery(`
*[_type == "post" && slug.current == $slug][0] {
_id,
title,
publishedAt,
mainImage,
body,
"author": author->name
}
`);
Projections keep the payload small, which matters because everything a loader returns is serialised into the HTML for hydration. If projections or the -> operator are new to you, see using projections in GROQ queries and how to dereference documents in GROQ.
Step 4: Load a List of Posts
Register your routes in app/routes.ts:
// app/routes.ts
import { type RouteConfig, index, route } from '@react-router/dev/routes';
export default [
index('routes/home.tsx'),
route('blog/:slug', 'routes/post.tsx'),
route('newsletter', 'routes/newsletter.tsx'),
] satisfies RouteConfig;
Then fetch posts in the home route's loader. The generated Route types give you typed loaderData in the component:
// app/routes/home.tsx
import { Link } from 'react-router';
import { client } from '~/sanity/client.server';
import { POSTS_QUERY } from '~/sanity/queries';
import type { Route } from './+types/home';
type PostSummary = {
_id: string;
title: string;
slug: string;
publishedAt: string;
};
export async function loader() {
const posts = await client.fetch<PostSummary[]>(POSTS_QUERY);
return { posts };
}
export default function Home({ loaderData }: Route.ComponentProps) {
return (
<main>
<h1>Blog</h1>
<ul>
{loaderData.posts.map((post) => (
<li key={post._id}>
<Link to={`/blog/${post.slug}`}>{post.title}</Link>{' '}
<time dateTime={post.publishedAt}>
{new Date(post.publishedAt).toLocaleDateString('en-GB')}
</time>
</li>
))}
</ul>
</main>
);
}
The +types/home file is generated by React Router's type generation when you run the dev server or react-router typegen. If you use Sanity TypeGen, you can drop the hand-written PostSummary type, because defineQuery lets client.fetch infer the result.
Step 5: Load a Single Post and Handle 404s
The :slug segment arrives in params. Pass it as a GROQ parameter rather than building the query string yourself, and throw a 404 response when nothing matches:
// app/routes/post.tsx
import { data } from 'react-router';
import { PortableText, type PortableTextBlock } from '@portabletext/react';
import { createImageUrlBuilder, type SanityImageSource } from '@sanity/image-url';
import { client } from '~/sanity/client.server';
import { POST_QUERY } from '~/sanity/queries';
import type { Route } from './+types/post';
type Post = {
_id: string;
title: string;
publishedAt: string;
mainImage?: SanityImageSource;
body?: PortableTextBlock[];
author?: string;
};
const builder = createImageUrlBuilder({
projectId: 'abc123xy',
dataset: 'production',
});
export async function loader({ params }: Route.LoaderArgs) {
const post = await client.fetch<Post | null>(POST_QUERY, { slug: params.slug });
if (!post) {
throw data(null, { status: 404 });
}
return { post };
}
export function meta({ loaderData }: Route.MetaArgs) {
return [{ title: loaderData?.post.title ?? 'Post not found' }];
}
export default function PostPage({ loaderData }: Route.ComponentProps) {
const { post } = loaderData;
return (
<article>
<h1>{post.title}</h1>
{post.author && <p>By {post.author}</p>}
{post.mainImage && (
<img
src={builder.image(post.mainImage).width(1200).height(675).auto('format').url()}
alt=""
width={1200}
height={675}
/>
)}
{post.body && <PortableText value={post.body} />}
</article>
);
}
A few details worth noting:
- The image builder lives outside the loader: it only needs the project ID and dataset, which are public, so it can run in the browser too. Using Sanity's image URL builder covers sizing and cropping.
- meta uses loaderData: React Router v8 removed the old
dataargument frommeta, so useloaderData. It can be undefined when the loader throws, hence the optional chaining. - Portable Text needs components for custom types: images and embeds inside the body need a
componentsprop, which how to render Portable Text in React explains.
Add an Error Boundary
When the loader throws a 404, React Router renders the nearest ErrorBoundary. Add one to the route so readers see a helpful page instead of the generic root error:
// app/routes/post.tsx (continued)
import { isRouteErrorResponse } from 'react-router';
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
if (isRouteErrorResponse(error) && error.status === 404) {
return <h1>Post not found</h1>;
}
return <h1>Something went wrong loading this post</h1>;
}
Step 6: Cache Responses With HTTP Headers
Server rendering on every request is fine for small sites, but caching the HTML at your host's CDN cuts response times and Sanity API calls. Return headers from the loader with the data() helper, then forward them with a headers export:
// app/routes/post.tsx
export async function loader({ params }: Route.LoaderArgs) {
const post = await client.fetch<Post | null>(POST_QUERY, { slug: params.slug });
if (!post) throw data(null, { status: 404 });
return data(
{ post },
{
headers: {
'Cache-Control': 'public, max-age=60, s-maxage=300, stale-while-revalidate=86400',
},
}
);
}
export function headers({ loaderHeaders }: Route.HeadersArgs) {
return loaderHeaders;
}
With these headers, browsers reuse the page for a minute, shared caches for five minutes, and a CDN can serve a stale copy for up to a day while it refetches in the background. Component code doesn't change, because data() only wraps the response; loaderData is still { post }.
If you'd rather serve fully static HTML, React Router can prerender routes at build time with the prerender option in react-router.config.ts. You'd then rebuild when editors publish, using a webhook as described in triggering site rebuilds with Sanity webhooks.
Step 7: Write Content From Actions
Remix-style routes also make writing to Sanity straightforward. An action handles form submissions on the server, so you can create documents without exposing a token. Here's a newsletter sign-up that stores each submission as a subscriber document:
// app/routes/newsletter.tsx
import { Form, data } from 'react-router';
import { writeClient } from '~/sanity/client.server';
import type { Route } from './+types/newsletter';
export async function action({ request }: Route.ActionArgs) {
const formData = await request.formData();
const email = String(formData.get('email') ?? '').trim();
if (!email.includes('@')) {
return data({ ok: false, error: 'Please enter a valid email address.' }, { status: 400 });
}
await writeClient.create({
_type: 'subscriber',
email,
subscribedAt: new Date().toISOString(),
});
return { ok: true, error: null };
}
export default function Newsletter({ actionData }: Route.ComponentProps) {
if (actionData?.ok) return <p>Thanks, you're on the list.</p>;
return (
<Form method="post">
<label>
Email <input type="email" name="email" required />
</label>
<button type="submit">Subscribe</button>
{actionData?.error && <p role="alert">{actionData.error}</p>}
</Form>
);
}
Use a token with the Editor role or a custom role limited to the subscriber type, and keep it in .env, never in client code. For high-volume writes such as analytics events, a dedicated database is usually a better fit than your content dataset, since every document counts towards your plan's limits. Managing user roles and permissions covers scoping tokens.
Adding Visual Editing
Sanity's Presentation tool can load your React Router site inside the Studio and let editors click text to edit it. The setup has more moving parts than plain fetching:
- Install the preview packages:
@sanity/visual-editing,@sanity/preview-url-secretand@sanity/react-loader. - Enable stega on the client: set
stega.studioUrlso strings carry invisible references back to their source documents. - Add preview routes: an enable route validates the request with
validatePreviewUrl()and stores preview mode in a cookie session fromcreateCookieSessionStorage; a disable route clears it. - Switch perspective in loaders: when the preview cookie is set, fetch with the
draftsperspective andstega: true; otherwise usepublishedandstega: false. - Render the overlays:
@sanity/visual-editinghas areact-routerentry point with aVisualEditingcomponent that you render inroot.tsxonly in preview mode. - Configure the Studio: add
presentationToolwith your site's URL aspreviewUrl.initialand the enable route aspreviewMode.enable.
Sanity's "Visual Editing with React Router" guide has the complete code. The underlying ideas, including stega, perspectives and the Presentation tool, are the same as in setting up Sanity visual editing in Next.js, which explains them step by step.
Migrating a Remix v2 Sanity Project
If your Sanity integration was built on Remix v2, the Sanity side barely changes. @sanity/client, GROQ queries and Portable Text rendering stay the same. The framework changes are:
| Remix v2 | React Router v8 |
|---|---|
@remix-run/node, @remix-run/react | react-router (and @react-router/node for Node adapters) |
json(data) from @remix-run/node | Return a plain object, or data(value, init) for status and headers |
useLoaderData<typeof loader>() | loaderData prop typed with Route.ComponentProps |
| File-based routes only | app/routes.ts config, with @react-router/fs-routes for file-based routing |
meta reads data | meta reads loaderData |
The usual path is to upgrade Remix v2 to React Router v7 with its future flags enabled, then move to v8. Your client.server.ts module and query files can be copied across unchanged.
Practical Tips
- Keep loaders lean: fetch only what the route renders. Large Portable Text bodies are serialised twice, once as HTML and once as hydration data.
- Parallelise independent queries: use
Promise.all()for a page that needs both a post and site settings, or combine them into one GROQ query with multiple keys. - Use a root loader for global content: navigation and footer data from a singleton document belong in
root.tsx, so every route shares one request. - Tag requests: pass
{ tag: 'post-page' }as the third argument toclient.fetchto make usage easier to trace in your project's API logs. - Set CORS only if needed: loaders run on the server and don't need a CORS origin. Add one only for browser-side requests, such as visual editing.
FAQ: Sanity and Remix
Yes. Sanity's documentation now targets React Router framework mode, which is where Remix v2's features moved, and labels its quick start as React Router (Remix). The @sanity/client library works the same way in old Remix v2 apps, React Router v7 and React Router v8.
Fetch it in route loaders. Loaders run on the server before the page renders, so you can use tokens safely, avoid CORS configuration and send fully rendered HTML. Put the client in a .server.ts module so it never reaches the browser bundle.
Check the query result in the loader and, when it's null, throw the data() helper with a status of 404. React Router then renders the nearest ErrorBoundary, where isRouteErrorResponse lets you show a dedicated not found message with the correct status code.
Yes. Create a client with a write token in a server-only module and call client.create, client.patch or client.createOrReplace inside the action. Validate the form input first, and use a token whose role is limited to the document types the action needs.
Return Cache-Control headers from your loader with the data() helper and forward them with a headers export, so your host's CDN can cache the HTML. Keep useCdn set to true on the client for public content, and use webhooks or short max-age values to control freshness.
No. Remix 3 is a separate framework with different APIs from React Router framework mode. You can still use @sanity/client to fetch content there, since it works in any JavaScript runtime, but the loader, route and type conventions in this guide are specific to React Router.
Conclusion
Connecting Sanity to Remix today means connecting it to React Router framework mode, and the core pattern hasn't changed: a server-only client, GROQ queries with parameters, and loaders that return exactly the data each route needs. Typed loaderData, data() for status codes and headers, and error boundaries for missing documents give you a robust content site with very little code.
From there, actions let you write to Sanity safely, HTTP caching keeps pages fast and API usage low, and the Presentation tool adds click-to-edit previews for editors when you need them. If you're maintaining an older Remix v2 project, your Sanity code moves across almost untouched, so the upgrade is mostly about framework imports rather than your content layer.


