Tip
Upgrading from v4? See the migration guide for breaking changes and new features. 🎉
Sanity.io toolkit for Hydrogen. Requires @shopify/hydrogen >= 2025.5.0.
Learn more about getting started with Sanity.
Features:
- Drop-in preview mode handling with pre-built route and session management.
- Opinionated data fetching that automatically adapts to preview mode.
- Interactive live preview with automatic loader detection for Visual Editing in Sanity's Presentation tool.
- Optimized image URL generation hooks with Sanity's image URL builder.
- TypeScript support with Sanity TypeGen.
Tip
If you'd prefer a self-paced course on how to use Sanity and Hydrogen, check out the Sanity and Shopify with Hydrogen on Sanity Learn.
- Installation
- Usage
- Interacting with Sanity data
- Working with images
- Enable preview mode
- Using
@sanity/clientdirectly - Migration Guides
- License
- Develop & test
Note
This package builds on @sanity/react-loader and @sanity/visual-editing, providing Hydrogen-specific optimizations and caching integration. For non-Hydrogen React applications, consider using these packages directly.
Using this package isn't required for working with Sanity in a Hydrogen storefront. If you'd prefer to use @sanity/react-loader and/or @sanity/client directly, see Using @sanity/client directly below.
npm install hydrogen-sanity @sanity/clientyarn add hydrogen-sanity @sanity/clientpnpm install hydrogen-sanity @sanity/clientAdd the Vite plugin to your vite.config.ts:
import {defineConfig} from 'vite'
import {hydrogen} from '@shopify/hydrogen/vite'
import {sanity} from 'hydrogen-sanity/vite'
export default defineConfig({
plugins: [hydrogen(), sanity() /** ... */],
// ... other config
})Create the Sanity context and pass it through to your application, and optionally, configure the preview mode if you plan to setup Visual Editing
Note
The examples below are up-to-date as of npm create @shopify/[email protected]
// ./lib/context.ts
// ...all other imports
+ import {createSanityContext, type SanityContext} from 'hydrogen-sanity'
// Define the additional context object
const additionalContext = {
// Additional context for custom properties, CMS clients, 3P SDKs, etc.
// These will be available as both context.propertyName and context.get(propertyContext)
// Example of complex objects that could be added:
// cms: await createCMSClient(env),
// reviews: await createReviewsClient(env),
} as const;
// Automatically augment HydrogenAdditionalContext with the additional context type
type AdditionalContextType = typeof additionalContext;
declare global {
interface HydrogenAdditionalContext extends AdditionalContextType {
+
+ // Augment `HydrogenAdditionalContext` with the Sanity context
+ sanity: SanityContext;
}
}
export async function createHydrogenRouterContext(
request: Request,
env: Env,
executionContext: ExecutionContext,
) {
// ... Leave all other functions as-is
const waitUntil = executionContext.waitUntil.bind(executionContext)
const [cache, session] = await Promise.all([
caches.open('hydrogen'),
AppSession.init(request, [env.SESSION_SECRET]),
])
+ // Initialize the Sanity context
+ const sanity = await createSanityContext({
+ request,
+
+ // To use the Hydrogen cache for queries
+ cache,
+ waitUntil,
+
+ // Sanity client configuration
+ client: {
+ projectId: env.SANITY_PROJECT_ID,
+ dataset: env.SANITY_DATASET || 'production',
+ apiVersion: env.SANITY_API_VERSION || 'v2024-08-08',
+ useCdn: process.env.NODE_ENV === 'production',
+ },
+
+ // You can also initialize a client and pass it instead
+ // client: createClient({
+ // projectId: env.SANITY_PROJECT_ID,
+ // dataset: env.SANITY_DATASET,
+ // apiVersion: env.SANITY_API_VERSION,
+ // useCdn: process.env.NODE_ENV === 'production',
+ // }),
+
+ // Optionally, set a default cache strategy, defaults to `CacheLong`
+ // strategy: CacheShort() | null,
+ })
+ // Make `sanity` available to loaders and actions in the request context
const hydrogenContext = createHydrogenContext(
{
env,
request,
cache,
waitUntil,
session,
i18n: {language: 'EN', country: 'US'},
cart: {
queryFragment: CART_QUERY_FRAGMENT,
},
},
+ {
+ ...additionalContext
+ sanity,
+ } as const,
+ )
+
return hydrogenContext
}
Learn more about Sanity's JavaScript client configuration.
Update your environment variables with settings from your Sanity project.
- Copy these from sanity.io/manage.
- Or run
npx sanity@latest init --envto fill the minimum required values from a new or existing project.
# Project ID
SANITY_PROJECT_ID=""
# Dataset name
SANITY_DATASET=""
# (Optional) Sanity API version
SANITY_API_VERSION=""
# Sanity token to authenticate requests in "preview" mode
# must have `viewer` role or higher access
# Create in sanity.io/manage
SANITY_PREVIEW_TOKEN=""Update the environment variables in Env to include the ones you created above:
Note
If you plan to reference any environment variables in the client bundle, say for your embedded Studio configuration, you must prefix them with either PUBLIC_ or SANITY_STUDIO_
// ./env.d.ts
declare global {
// ...other types
interface Env extends HydrogenEnv {
// ...other environment variables
+ SANITY_PROJECT_ID: string
+ SANITY_DATASET?: string
+ SANITY_API_VERSION?: string
+ SANITY_PREVIEW_TOKEN: string
}
}You now need to wrap your app with the Sanity provider to make Sanity context available to client-side hooks and components like useImageUrl and Query.
Update entry.server.tsx
Wrap your app with the Sanity provider in your server entry point:
// ./app/entry.server.tsx
export default async function handleRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
reactRouterContext: EntryContext,
- context: AppLoadContext,
+ context: HydrogenRouterContextProvider,
) {
+ const {SanityProvider} = context.sanity
// ... CSP setup etc ...
const body = await renderToReadableStream(
<NonceProvider>
+ <SanityProvider>
<ServerRouter context={reactRouterContext} url={request.url} nonce={nonce} />
+ </SanityProvider>
</NonceProvider>,
// ... render options
)
}Update root.tsx
Add the Sanity component to your root layout:
// ./app/root.tsx
+ import {Sanity} from 'hydrogen-sanity'
export function Layout({children}: {children?: React.ReactNode}) {
const nonce = useNonce()
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<Meta />
<Links />
</head>
<body>
{children}
+ {/* Add Sanity client-side script */}
+ <Sanity nonce={nonce} />
<ScrollRestoration nonce={nonce} />
<Scripts nonce={nonce} />
</body>
</html>
)
}The query method and Query component work together to provide an optimized data fetching and rendering experience that automatically adapts based on whether Sanity preview mode is active:
Best for: Most applications wanting opinionated best practices with automatic optimization
- Opinionated approach: Curated patterns that codify Hydrogen + Sanity best practices
- Automatic preview mode detection and switching
- Built-in Visual Editing with click-to-edit overlays
- Bundle optimization: Preview-related code is conditionally loaded only when needed
- Configuration decisions made for you: Cache strategies, session management, and Visual Editing setup follow recommended approaches
How it works:
- When preview mode is active: Dynamically imports
@sanity/react-loaderwithloadQueryfor loader integration and client-side re-rendering withuseQueryfor real-time Studio updates - When preview mode is inactive: Uses lightweight direct client
fetchfor optimal performance with static rendering - Bundle efficiency: The
Querycomponent uses React'slazy()to conditionally load client-side preview components only when needed, while server-sideloadQueryimports happen at runtime only when preview mode is detected
Step 1: Fetch data in your loader with query
import {defineQuery} from 'groq'
const HOMEPAGE_QUERY = defineQuery(`*[_id == "home"][0]{
_id,
title,
hero {
title,
description,
image {
asset->{
_id,
url
},
alt
}
},
modules[] {
_type,
_type == "productShowcase" => {
products[]-> {
_id,
store {
title,
slug,
previewImageUrl
}
}
}
}
}`)
export async function loader({context}: LoaderFunctionArgs) {
const initial = await context.sanity.query(HOMEPAGE_QUERY, undefined, {
tag: 'homepage',
hydrogen: {debug: {displayName: 'query Homepage'}},
})
return {initial}
}Step 2: Render with the Query component
import {Query} from 'hydrogen-sanity'
export default function HomePage({loaderData}: {loaderData: {initial: any}}) {
const {initial} = loaderData
return (
<Query query={HOMEPAGE_QUERY} options={{initial}}>
{(homepage, encodeDataAttribute) => (
<div>
<h1>{homepage?.hero?.title}</h1>
<p>{homepage?.hero?.description}</p>
{homepage?.modules?.map((module) => {
switch (module._type) {
case 'productShowcase':
return (
<div
key={module._key}
data-sanity={encodeDataAttribute(['modules', {_key: module._key}, '_type'])}
>
<div className="products">
{module.products?.map((product) => (
<div key={product._id}>
<h3>{product.store?.title}</h3>
{product.store?.slug && (
<Link to={`/products/${product.store.slug}`}>View Product</Link>
)}
</div>
))}
</div>
</div>
)
default:
return null
}
})}
</div>
)}
</Query>
)
}Note
The encodeDataAttribute function enables click-to-edit functionality in Sanity Studio's Presentation tool. It's only available when Sanity preview mode is active (managed via preview session) and returns undefined otherwise.
Advanced Options
Both methods accept the same options as their underlying implementations:
// In your loader
const page = await context.sanity.query<HomePage>(queryString, params, {
// Hydrogen caching options
hydrogen: {
cache: CacheShort(),
debug: {displayName: 'query Homepage'},
},
// Sanity request options
tag: 'home',
})
// In your component
<Query
query={queryString}
params={params}
options={initial}
fallback={<div>Loading...</div>} // React Suspense props
>
{(data) => <YourComponent data={data} />}
</Query>Best for: Full control over preview mode behavior and loader integration (alternative to query/Query)
- Manual loader integration with real-time Studio updates when preview mode is active
- Uses
useQueryhooks for client-side re-rendering with Sanity Studio
Query Sanity's API and use Hydrogen's cache to store the response (defaults to CacheLong caching strategy). When Sanity preview mode is active, loadQuery automatically bypasses the cache.
Learn more about configuring caching in Hydrogen.
Sanity queries appear in Hydrogen's Subrequest Profiler. By default, they're titled Sanity query. You can give your queries more descriptive titles by using the request option below.
export async function loader({context, params}: LoaderFunctionArgs) {
const query = `*[_type == "page" && _id == $id][0]`
const params = {id: 'home'}
const initial = await context.sanity.loadQuery(query, params)
return {initial}
}If you need to pass any additional options to the request provide queryOptions like so:
const page = await context.sanity.loadQuery<HomePage>(query, params, {
// Optionally customize the cache strategy for this request
hydrogen: {
cache: CacheShort(),
// Or disable caching for this request
// cache: CacheNone(),
// If you'd like to add a custom display title that will
// display in the Subrequest Profiler, you can pass that here:
// debug: {
// displayName: 'query Homepage'
// },
// You can also pass a function do determine whether or not to cache the response
// shouldCacheResult(value){
// return true
// },
},
// ...as well as other request options
// tag: 'home',
// headers: {
// 'Accept-Encoding': 'br, gzip, *',
// },
})Tip
Learn more about request tagging.
Best for: When preview mode is not needed and bundle optimization is priority
- Lightweight with direct client results
- No preview or loader integration
For Sanity queries that don't need loader integration, there is a fetch method that also integrates with Hydrogen's cache:
export async function loader({context, params}: LoaderFunctionArgs) {
const query = `*[_type == "page" && _id == $id][0]`
const params = {id: 'home'}
const page = await context.sanity.fetch<HomePage>(query, params, {
hydrogen: {
cache: CacheShort(),
debug: {displayName: 'fetch Homepage'},
},
tag: 'home',
})
return {page}
}The Sanity client (either instantiated from your configuration or passed through directly) is also available in your app's context. It is recommended to use query for data fetching; but the Sanity client can be used for mutations within actions, for example:
export async function action({context, request}: ActionFunctionArgs) {
if (!isAuthenticated(request)) {
return redirect('/login')
}
return context.sanity
.withConfig({
token: context.env.SANITY_WRITE_TOKEN,
})
.client.create({
_type: 'comment',
text: request.body.get('text'),
})
}Sanity TypeGen generates TypeScript definitions for your GROQ queries. To use TypeGen with hydrogen-sanity, install groq as a dependency:
npm install groqTip
Refer to TypeGen steps covered in the Sanity TypeGen documentation. TypeGen with overloadClientMethods: true uses declaration merging to automatically generate types for your GROQ queries.
Now your queries will have automatic type inference:
import {defineQuery} from 'groq'
const HOMEPAGE_QUERY = defineQuery(`*[_id == "home"][0]`)
export async function loader({context, params}: LoaderFunctionArgs) {
const params = {id: 'home'}
const initial = await context.sanity.loadQuery(HOMEPAGE_QUERY, params)
return {initial}
}The useImageUrl hook provides a convenient way to generate optimized image URLs from Sanity image assets with the image URL builder.
import {useImageUrl} from 'hydrogen-sanity'
function HeroBanner({hero}: {hero: {image: SanityImageSource}}) {
const imageUrl = useImageUrl(hero.image)
return (
<div className="hero-banner">
<img
src={imageUrl.width(1200).height(600).format('auto').url()}
alt="Hero banner"
width={1200}
height={600}
/>
</div>
)
}Enabling preview mode provides real-time content editing with visual overlays inside the Presentation tool. hydrogen-sanity includes everything needed to make your storefront Presentation-aware.
Note
These instructions assume some familiarity with Sanity's Visual Editing concepts, like loaders and overlays. To learn more, please visit the Visual Editing documentation.
For Visual Editing to work, you need to configure Sanity preview mode in your context. Preview mode is a session-based state that gets activated when users visit your storefront through Sanity Studio's Presentation tool or through a preview link shared from Studio. First, initialize the preview session:
// ./lib/context.ts
// ...all other imports
import {createSanityContext, type SanityContext} from 'hydrogen-sanity'
+ import {PreviewSession} from 'hydrogen-sanity/preview/session'
+ import {isPreviewEnabled} from 'hydrogen-sanity/preview'
export async function createHydrogenRouterContext(
request: Request,
env: Env,
executionContext: ExecutionContext,
) {
// ... Leave all other functions as-is
const waitUntil = executionContext.waitUntil.bind(executionContext)
- const [cache, session] = await Promise.all([
+ const [cache, session, previewSession] = await Promise.all([
caches.open('hydrogen'),
AppSession.init(request, [env.SESSION_SECRET]),
+ // Initialize the preview session
+ PreviewSession.init(request, [env.SESSION_SECRET]),
])
const sanity = await createSanityContext({
request,
cache,
waitUntil,
client: {
projectId: env.SANITY_PROJECT_ID,
dataset: env.SANITY_DATASET || 'production',
apiVersion: env.SANITY_API_VERSION || 'v2024-08-08',
useCdn: process.env.NODE_ENV === 'production',
+ // Enable stega encoding only when in preview mode
+ stega: {
+ enabled: isPreviewEnabled(env.SANITY_PROJECT_ID, previewSession),
+ },
},
+ // Preview configuration
+ preview: {
+ token: env.SANITY_PREVIEW_TOKEN,
+ session: previewSession,
+ },
})
}Note
Stega encoding enables click-to-edit functionality by encoding content source information directly into strings. Learn more about Content Source Maps and stega.
Set up your root route to enable Visual Editing across the entire application when preview mode is active:
// ./app/root.tsx
// ...other imports
+ import {usePreviewMode} from 'hydrogen-sanity/preview'
+ import {VisualEditing} from 'hydrogen-sanity/visual-editing'
export function Layout({children}: {children?: React.ReactNode}) {
const nonce = useNonce()
const data = useRouteLoaderData<RootLoader>('root')
+ const previewMode = usePreviewMode()
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<Meta />
<Links />
</head>
<body>
{/* ...rest of the root layout */}
+ {/* Conditionally render `VisualEditing` component only when in preview mode */}
+ {previewMode ? <VisualEditing action="/api/preview" /> : null}
<ScrollRestoration nonce={nonce} />
<Scripts nonce={nonce} />
</body>
</html>
)
}The VisualEditing component provides flexible configuration for different data loading patterns:
Server-Only Setup (default):
Best when using only server-side data fetching (direct fetch or loadQuery without client components).
<VisualEditing /> // Overlays only with server revalidationWith Client-Side Loaders (recommended for Query component or useQuery hooks):
The component automatically detects when you're using client-side loaders (Query components or useQuery hooks) and enables live mode accordingly:
// Default usage - automatically enables live mode when needed
<VisualEditing action="/api/preview" />Note
Automatic Detection: Live mode automatically activates when:
Querycomponents are rendereduseQueryhooks are called
For advanced use cases, you can use the individual components:
import {Overlays, LiveMode} from 'hydrogen-sanity/visual-editing'
// Overlays only (server-only setups)
<Overlays action="/api/preview" />
// Live mode only (client-side data sync)
<LiveMode />
// Both (hybrid setups)
<Overlays action="/api/preview" />
<LiveMode />This Visual Editing component provides a complete Visual Editing experience, including:
- Context-aware behavior: Auto-detects Studio vs standalone preview contexts
- Real-time preview: Updates content as you edit in Studio
- Visual overlays: Click-to-edit functionality with element highlighting
- Perspective switching: Draft/published content switching
- Server revalidation: Smart refresh logic for server-side data
- Custom revalidation: Customizable refresh logic for more control
For Sanity's Presentation tool to activate preview mode, you need to set up a route that handles authentication and session management. When users work in Sanity Studio's Presentation tool, it automatically calls this endpoint to enable preview mode.
hydrogen-sanity comes with a preconfigured route for this purpose. When Sanity's Presentation tool loads your storefront, it automatically makes requests to this route with a secret token. If the secret is valid, the route activates preview mode by writing the projectId to the preview session.
Note
For Visual Editing overlays and click-to-edit functionality to work, you must configure stega.enabled: true in your Sanity client configuration.
You can learn more about Content Source Maps and working with stega-encoded strings.
Add this route to your project like below, or view the source to copy and modify it in your project.
// ./app/routes/api.preview.ts
export {action, loader} from 'hydrogen-sanity/preview/route'If your Sanity Studio is not embedded in your Hydrogen App, you will need to add a Cross-Origin Resource Sharing (CORS) origin to your project for every URL where your app is hosted or running in development.
Add http://localhost:3000 to the CORS origins in your Sanity project settings at sanity.io/manage. Learn more about CORS configuration in Sanity.
Since Sanity Studio's Presentation tool displays the storefront inside an iframe, you will need to adjust the Content Security Policy (CSP) in entry.server.tsx.
Tip
Review Hydrogen's content security policy documentation to ensure your storefront is secure.
// ./app/entry.server.tsx
// ...all other imports
export default async function handleRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
reactRouterContext: EntryContext,
- context: AppLoadContext,
+ context: HydrogenRouterContextProvider,
) {
+ const {env, sanity} = context
+ const projectId = env.SANITY_PROJECT_ID
+ const studioHostname = env.SANITY_STUDIO_HOSTNAME || 'http://localhost:3333'
+ const isPreviewEnabled = sanity.preview?.enabled
const {nonce, header, NonceProvider} = createContentSecurityPolicy({
// If your storefront and Studio are on separate domains...
// ...allow Sanity assets loaded from the CDN to be loaded in your storefront
+ defaultSrc: ['https://cdn.sanity.io'],
// ...allow Studio to load your storefront in Presentation's iframe
+ frameAncestors: isPreviewEnabled ? [studioHostname] : [],
// If you've embedded your Studio in your storefront...
// ...allow Sanity assets to be loaded in your storefront and allow user avatars in Studio
+ defaultSrc: ['https://cdn.sanity.io', 'https://lh3.googleusercontent.com'],
// ...allow client-side requests for Studio to do realtime collaboration
+ connectSrc: [`https://${projectId}.api.sanity.io`, `wss://${projectId}.api.sanity.io`],
// ...allow embedded Studio to load storefront
+ frameAncestors: [`'self'`],
})
// ...and the rest
}Now in your Sanity Studio config, import the Presentation Tool with the preview URL set to the preview route you created.
Tip
Consult the Visual Editing documentation for how to configure the Presentation tool.
// ./sanity.config.ts
// Add this import
+ import {presentationTool} from 'sanity/presentation'
export default defineConfig({
// ...all other settings
plugins: [
+ presentationTool({
+ previewUrl: {
+ // If you're hosting your storefront on a separate domain, you'll need to provide an `origin`:
+ // origin: process.env.SANITY_STUDIO_STOREFRONT_ORIGIN
+ previewMode: {
+ // This path is relative to the origin above and should match the route in your storefront that you've setup above
+ enable: '/api/preview',
+ },
+ },
+ }),
// ..all other plugins
],
})You should now be able to view your Hydrogen app in the Presentation Tool, click to edit any Sanity content and see live updates as you make changes.
Note
If you're able to see Presentation working locally, but not when you've deployed your application, check that your session cookie is using sameSite: 'none' and secure: true.
Since Presentation displays your site in an iframe, the session cookie by default won't be sent through. You can learn more about session cookie configuation in MDN's documentation.
Are you getting the following error when trying to load your storefront in the Presentation Tool?
Unable to connect to Visual Editing. Make sure you've setup '@sanity/visual-editing' correctly
Presentation will throw this error if it can't establish a connection to your storefront. Here are a few things to double-check in your setup to try and troubleshoot:
- Confirm that you've provided
previewconfiguration to the Sanity context. - Confirm that you've added the
VisualEditingcomponent to your root layout. - If you've followed the instructions above, the
VisualEditingcomponent will be conditionally rendered if the app has been successfully put into preview mode. - If you're using a session cookie, check your browser devtools and confirm that the cookie has been set as expected.
- Since Presentation loads your storefront in an
iframe, double check your cookie and CSP configuration.
If you prefer not to use hydrogen-sanity, you can configure @sanity/client directly in your Hydrogen storefront.
The following example configures Sanity Client and provides it in the request context.
// ./lib/context.ts
// ...all other imports
+ import {createClient} from '@sanity/client'
+ import {createSanityContext, type SanityContext} from 'hydrogen-sanity'
// Define the additional context object
const additionalContext = {
// Additional context for custom properties, CMS clients, 3P SDKs, etc.
// These will be available as both context.propertyName and context.get(propertyContext)
// Example of complex objects that could be added:
// cms: await createCMSClient(env),
// reviews: await createReviewsClient(env),
} as const;
// Automatically augment HydrogenAdditionalContext with the additional context type
type AdditionalContextType = typeof additionalContext;
declare global {
interface HydrogenAdditionalContext extends AdditionalContextType {
+
+ // Augment `HydrogenAdditionalContext` with the Sanity context
+ sanity: SanityContext;
+ withCache: WithCache;
}
}
export async function createHydrogenRouterContext(
request: Request,
env: Env,
executionContext: ExecutionContext,
) {
// ... all other functions
+ const withCache = createWithCache({cache, waitUntil, request})
+ // Create the Sanity Client
+ const sanity = createClient({
+ projectId: env.SANITY_PROJECT_ID,
+ dataset: env.SANITY_DATASET,
+ apiVersion: env.SANITY_API_VERSION ?? 'v2025-02-19',
+ useCdn: process.env.NODE_ENV === 'production',
+ })
const hydrogenContext = createHydrogenContext(
{
env,
request,
cache,
waitUntil,
session,
i18n: {language: 'EN', country: 'US'},
cart: {
queryFragment: CART_QUERY_FRAGMENT,
},
},
+ {
+ ...additionalContext,
+ sanity,
+ withCache,
+ } as const,
+ )
+
+ return hydrogenContext
}Then, in your loaders and actions you'll have access to Sanity Client in context:
export async function loader({context, params}: LoaderArgs) {
const {sanity} = context
const homepage = await sanity.fetch(`*[_type == "page" && _id == $id][0]`, {id: 'home'})
return {homepage}
}To cache your query responses in Hydrogen, use the withCache utility:
export async function loader({context, params}: LoaderArgs) {
const {withCache, sanity} = context
const homepage = await withCache('home', CacheLong(), () =>
sanity.fetch(`*[_type == "page" && _id == $id][0]`, {id: 'home'}),
)
return {homepage}
}MIT © Sanity.io [email protected]
This plugin uses @sanity/pkg-utils with default configuration for build & watch scripts.
Run "CI & Release" workflow. Make sure to select the main branch and check "Release new version".
Semantic release will only release on configured branches, so it is safe to run release on any branch.