Type something to search...
Connecting Sanity to Remix

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:

NameWhat it isSanity guidance
Remix v1 and v2The original React framework, @remix-run/* packagesOlder tutorials and templates
React Router v7Remix v2 merged into React Router as "framework mode" (late 2024)Same patterns, new imports
React Router v8Current major version (June 2026), ESM-only, Node 22.22+, React 19.2.7+Sanity's "React Router (Remix)" quick start
Remix 3A separate, newer framework from the Remix teamNot 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 data argument from meta, so use loaderData. 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 components prop, 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:

  1. Install the preview packages: @sanity/visual-editing, @sanity/preview-url-secret and @sanity/react-loader.
  2. Enable stega on the client: set stega.studioUrl so strings carry invisible references back to their source documents.
  3. Add preview routes: an enable route validates the request with validatePreviewUrl() and stores preview mode in a cookie session from createCookieSessionStorage; a disable route clears it.
  4. Switch perspective in loaders: when the preview cookie is set, fetch with the drafts perspective and stega: true; otherwise use published and stega: false.
  5. Render the overlays: @sanity/visual-editing has a react-router entry point with a VisualEditing component that you render in root.tsx only in preview mode.
  6. Configure the Studio: add presentationTool with your site's URL as previewUrl.initial and the enable route as previewMode.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 v2React Router v8
@remix-run/node, @remix-run/reactreact-router (and @react-router/node for Node adapters)
json(data) from @remix-run/nodeReturn a plain object, or data(value, init) for status and headers
useLoaderData<typeof loader>()loaderData prop typed with Route.ComponentProps
File-based routes onlyapp/routes.ts config, with @react-router/fs-routes for file-based routing
meta reads datameta 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 to client.fetch to 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.

Tags :
Share :

Related Posts

Building a Static Site With Astro and Sanity

Building a Static Site With Astro and Sanity

To build a static site with Astro and Sanity, keep Astro's default output: 'static', configure @sanity/astro with useCdn: false, and fetch

Dive Deeper
Common Sanity Mistakes and How to Fix Them

Common Sanity Mistakes and How to Fix Them

The most common Sanity mistakes are using the CDN where you need fresh data, fetching whole documents with ..., leaving slugs unvalidated, ship

Dive Deeper
How to Create a Custom Sanity Plugin

How to Create a Custom Sanity Plugin

To create a custom Sanity plugin, wrap a piece of Studio configuration in definePlugin from the sanity package and add it to the plugins ar

Dive Deeper