Gathering detailed insights and metrics for @sanity/preview-kit
Gathering detailed insights and metrics for @sanity/preview-kit
Gathering detailed insights and metrics for @sanity/preview-kit
Gathering detailed insights and metrics for @sanity/preview-kit
@sanity/preview-kit-compat
[![npm stat](https://img.shields.io/npm/dm/@sanity/preview-kit-compat.svg?style=flat-square)](https://npm-stat.com/charts.html?package=@sanity/preview-kit-compat) [![npm version](https://img.shields.io/npm/v/@sanity/preview-kit-compat.svg?style=flat-squar
@sanity-typed/preview-kit
@sanity/preview-kit with typed GROQ Results
@sanity/preview-url-secret
[![npm stat](https://img.shields.io/npm/dm/@sanity/preview-url-secret.svg?style=flat-square)](https://npm-stat.com/charts.html?package=@sanity/preview-url-secret) [![npm version](https://img.shields.io/npm/v/@sanity/preview-url-secret.svg?style=flat-squar
next-sanity
Sanity.io toolkit for Next.js
General purpose live previews, like next-sanity
npm install @sanity/preview-kit
Module System
Min. Node Version
Typescript Support
Node Version
NPM Version
116 Stars
1,868 Commits
3 Forks
13 Watching
14 Branches
31 Contributors
Updated on 27 Nov 2024
Minified
Minified + Gzipped
TypeScript (94.87%)
JavaScript (4.7%)
HTML (0.43%)
Cumulative downloads
Total Downloads
Last day
14.7%
19,180
Compared to previous day
Last week
13.6%
95,638
Compared to previous week
Last month
20.4%
377,141
Compared to previous month
Last year
119.8%
3,429,733
Compared to previous year
2
2
29
[!IMPORTANT]
You're looking at the README for v5, the README for v4 is available here.
Sanity.io toolkit for building live-as-you-type content preview experiences and visual editing.
@sanity/preview-kit
@sanity/preview-kit/client
@sanity/preview-kit/csm
1npm i @sanity/preview-kit @sanity/client
1pnpm i @sanity/preview-kit @sanity/client
1yarn add @sanity/preview-kit @sanity/client
@sanity/preview-kit
Note
This is the new docs for
@sanity/preview-kit
v2. If you're looking for docs for v1 APIs, likedefinePreview
andusePreview
, they're available on the v1 branch..There's a full migration guide available here.
If you're looking for React Server Component and Next.js docs, they're in the
next-sanity
readme.
Note
The examples in this README use Remix, you can find Next.js specific examples in the
next-sanity
README. Including information on how to build live previews in React Server Components with the new app-router.
Write GROQ queries like @sanity/client and have them resolve in-memory, locally. Updates from Content Lake are streamed in real-time with sub-second latency.
Requires React 18, support for other libraries like Solid, Svelte, Vue etc are planned. For now you can use @sanity/groq-store directly.
Get started in 3 steps:
client
instance of @sanity/client
that can be shared on the server and browser.<LiveQueryProvider />
configuration.<LiveQueryProvider />
when it's asked to preview drafts.useLiveQuery
hook in components that you want to re-render in real-time as your documents are edited.client
instanceAs <LiveQueryProvider />
is configured with a @sanity/client
instance it makes sense to create a utility for it. Doing so makes it easy to ensure the server-side and client-side client are configured the same way.
app/lib/sanity.ts
1import {createClient} from '@sanity/client' 2import type {QueryParams} from '@sanity/client' 3 4// Shared on the server and the browser 5export const client = createClient({ 6 projectId: 'your-project-id', 7 dataset: 'production', 8 apiVersion: '2023-06-20', 9 useCdn: false, 10 perspective: 'published', 11}) 12 13// Only defined on the server, passed to the browser via a `loader` 14export const token = typeof process === 'undefined' ? '' : process.env.SANITY_API_READ_TOKEN! 15 16const DEFAULT_PARAMS = {} as QueryParams 17 18// Utility for fetching data on the server, that can toggle between published and preview drafts 19export async function sanityFetch<QueryResponse>({ 20 previewDrafts, 21 query, 22 params = DEFAULT_PARAMS, 23}: { 24 previewDrafts?: boolean 25 query: string 26 params?: QueryParams 27}): Promise<QueryResponse> { 28 if (previewDrafts && !token) { 29 throw new Error('The `SANITY_API_READ_TOKEN` environment variable is required.') 30 } 31 return client.fetch<QueryResponse>( 32 query, 33 params, 34 previewDrafts 35 ? { 36 token, 37 perspective: 'previewDrafts', 38 } 39 : {}, 40 ) 41}
<LiveQueryProvider />
componentCreate a new file for the provider, so it can be loaded with React.lazy
and avoid increasing the bundle size in production. Ensuring code needed for live previewing drafts are only loaded when needed.
app/PreviewProvider.tsx
1import {LiveQueryProvider} from '@sanity/preview-kit' 2import {client} from '~/lib/sanity' 3 4export default function PreviewProvider({ 5 children, 6 token, 7}: { 8 children: React.ReactNode 9 token: string 10}) { 11 if (!token) throw new TypeError('Missing token') 12 return ( 13 <LiveQueryProvider client={client} token={token}> 14 {children} 15 </LiveQueryProvider> 16 ) 17}
Only the client
and token
props are required. For debugging you can pass a logger={console}
prop.
You can also use the useIsEnabled
hook to debug wether you have a parent <LiveQueryProvider />
in your React tree or not.
Here's the Remix route we'll be adding live preview of drafts, it's pretty basic:
1// app/routes/index.tsx 2import type {LoaderArgs} from '@vercel/remix' 3import {useLoaderData} from '@remix-run/react' 4 5import {client} from '~/lib/sanity' 6import type {UsersResponse} from '~/UsersList' 7import {UsersList, usersQuery} from '~/UsersList' 8import {Layout} from '~/ui' 9 10export async function loader({request}: LoaderArgs) { 11 const url = new URL(request.url) 12 const lastId = url.searchParams.get('lastId') || '' 13 14 const users = await client.fetch<UsersResponse>(usersQuery, {lastId}) 15 16 return {users, lastId} 17} 18 19export default function Index() { 20 const {users, lastId} = useLoaderData<typeof loader>() 21 22 return ( 23 <Layout> 24 <UsersList data={users} lastId={lastId} /> 25 </Layout> 26 ) 27}
Now let's import the PreviewProvider
component we created in the previous step. To ensure we don't increase the production bundle size, we'll use React.lazy
to code-split the component. The React.lazy
API requires a React.Suspense
boundary, so we'll add that too.
1import {lazy, Suspense} from 'react' 2 3const PreviewProvider = lazy(() => import('~/PreviewProvider'))
Before we can add <PreviewProvider />
to the layout we need to update the loader
to include the props it needs. We'll use an environment variable called SANITY_API_PREVIEW_DRAFTS
to control when to live preview drafts, and store a viewer
API token in SANITY_API_READ_TOKEN
.
Update the client.fetch
call to use the new sanityFetch
utility we created earlier, as well as the token
:
1import {token, sanityFetch} from '~/lib/sanity' 2 3const previewDrafts = process.env.SANITY_API_PREVIEW_DRAFTS === 'true' 4const users = await sanityFetch<UsersResponse>({ 5 previewDrafts, 6 query: usersQuery, 7 params: {lastId}, 8})
Update the loader
return statement from return {users, lastId}
to:
1return {previewDrafts, token, users, lastId}
And add previewDrafts
, and token
, to useLoaderData
:
1const {previewDrafts, token, users, lastId} = useLoaderData<typeof loader>()
Then make the render conditional based on wether previewDrafts
is set:
1const children = <UsersList data={users} lastId={lastId} /> 2 3return ( 4 <Layout> 5 {previewDrafts ? ( 6 <Suspense fallback={children}> 7 <PreviewProvider token={token}>{children}</PreviewProvider> 8 </Suspense> 9 ) : ( 10 children 11 )} 12 </Layout> 13)
After putting everything together the route should now look like this:
1// app/routes/index.tsx 2import type {LoaderArgs} from '@vercel/remix' 3import {useLoaderData} from '@remix-run/react' 4import {lazy, Suspense} from 'react' 5 6import {token, sanityFetch} from '~/lib/sanity' 7import type {UsersResponse} from '~/UsersList' 8import {UsersList, usersQuery} from '~/UsersList' 9import {Layout} from '~/ui' 10 11const PreviewProvider = lazy(() => import('~/PreviewProvider')) 12 13export async function loader({request}: LoaderArgs) { 14 const previewDrafts = process.env.SANITY_API_PREVIEW_DRAFTS === 'true' ? {token} : undefined 15 const url = new URL(request.url) 16 const lastId = url.searchParams.get('lastId') || '' 17 18 const users = await sanityFetch<UsersResponse>({ 19 previewDrafts, 20 query: usersQuery, 21 params: {lastId}, 22 }) 23 24 return {previewDrafts, token, users, lastId} 25} 26 27export default function Index() { 28 const {previewDrafts, token, users, lastId} = useLoaderData<typeof loader>() 29 30 const children = <UsersList data={users} lastId={lastId} /> 31 32 return ( 33 <Layout> 34 {previewDrafts ? ( 35 <Suspense fallback={children}> 36 <PreviewProvider token={token!}>{children}</PreviewProvider> 37 </Suspense> 38 ) : ( 39 children 40 )} 41 </Layout> 42 ) 43}
useLiveQuery
hook to components that need to re-render in real-timeLet's look at what the <UsersList>
component looks like, before we add the hook:
1// app/UsersList.tsx 2import groq from 'groq' 3 4import {ListView, ListPagination} from '~/ui' 5 6export const usersQuery = groq`{ 7 "list": *[_type == "user" && _id > $lastId] | order(_id) [0...20], 8 "total": count(*[_type == "user"]), 9}` 10 11export interface UsersResponse { 12 list: User[] 13 total: number 14} 15 16export interface UsersListProps { 17 data: UsersResponse 18 lastId: string 19} 20 21export function UsersList(props: UsersListProps) { 22 const {data, lastId} = props 23 24 return ( 25 <> 26 <ListView list={data.list} /> 27 <ListPagination total={data.total} lastId={lastId} /> 28 </> 29 ) 30}
To make this component connect to your preview provider you need to add the useLiveQuery
. You don't have to refactor your components so that the hook is only called when there's a parent <LiveQueryProvider />
, it's safe to call it unconditionally.
If there's no <LiveQueryProvider />
it behaves as if the hook had this implementation:
1export function useLiveQuery(initialData) { 2 return [initialData, false] 3}
Thus it's fairly easy to add conditional live preview capabilities to UsersList
, simply add hook to your imports:
1import {useLiveQuery} from '@sanity/preview-kit'
And replace this:
1const {data, lastId} = props
With this:
1const {data: initialData, lastId} = props 2const [data] = useLiveQuery(initialData, usersQuery, {lastId})
All together now:
1// app/UsersList.tsx 2import {useLiveQuery} from '@sanity/preview-kit' 3import groq from 'groq' 4 5import {ListView, ListPagination} from '~/ui' 6 7export const usersQuery = groq`{ 8 "list": *[_type == "user" && _id > $lastId] | order(_id) [0...20], 9 "total": count(*[_type == "user"]), 10}` 11 12export interface UsersResponse { 13 list: User[] 14 total: number 15} 16 17export interface UsersListProps { 18 data: UsersResponse 19 lastId: string 20} 21 22export function UsersList(props: UsersListProps) { 23 const {data: initialData, lastId} = props 24 const [data] = useLiveQuery(initialData, usersQuery, {lastId}) 25 26 return ( 27 <> 28 <ListView list={data.list} /> 29 <ListPagination total={data.total} lastId={lastId} /> 30 </> 31 ) 32}
And done! You can optionally optimize it further by adding a loading UI while it loads, or improve performance by adding a custom isEqual
function to reduce React re-renders if there's a lot of data that changes but isn't user visible (SEO metadata and such).
useLiveQuery
The best way to do this is to add a wrapper component that is only used in preview mode that calls the useLiveQuery
hook.
1export function UsersList(props: UsersListProps) { 2 const {data, lastId} = props 3 4 return ( 5 <> 6 <ListView list={data.list} /> 7 <ListPagination total={data.total} lastId={lastId} /> 8 </> 9 ) 10} 11 12export function PreviewUsersList(props: UsersListProps) { 13 const {data: initialData, lastId} = props 14 const [data, loading] = useLiveQuery(initialData, usersQuery, {lastId}) 15 16 return ( 17 <> 18 <PreviewStatus loading={loading} /> 19 <UsersList data={users} lastId={lastId} /> 20 </> 21 ) 22}
Change the layout from:
1const children = <UsersList data={users} lastId={lastId} /> 2 3return ( 4 <Layout> 5 {preview ? ( 6 <Suspense fallback={children}> 7 <PreviewProvider token={preview.token!}>{children}</PreviewProvider> 8 </Suspense> 9 ) : ( 10 children 11 )} 12 </Layout> 13)
To this:
1return ( 2 <Layout> 3 {preview ? ( 4 <Suspense fallback={children}> 5 <PreviewProvider token={preview.token!}> 6 <PreviewUsersList data={users} lastId={lastId} /> 7 </PreviewProvider> 8 </Suspense> 9 ) : ( 10 <UsersList data={users} lastId={lastId} /> 11 )} 12 </Layout> 13)
Out of the box it'll only trigger a re-render of UsersList
if the query response changed, using react-fast-compare
under the hood. You can tweak this behavior by passing a custom isEqual
function as the third argument to useLiveQuery
if there's only some changes you want to trigger a re-render.
1const [data] = useLiveQuery(
2 initialData,
3 usersQuery,
4 {lastId},
5 {
6 // Only re-render in real-time if user ids and names changed, ignore all other differences
7 isEqual: (a, b) =>
8 a.list.every((aItem, index) => {
9 const bItem = b.list[index]
10 return aItem._id === bItem._id && aItem.name === bItem.name
11 }),
12 },
13)
You can also use the React.useDeferredValue
hook and a React.memo
wrapper to further optimize performance by letting React give other state updates higher priority than the preview updates. It prevents the rest of your app from slowing down should there be too much Studio activity for the previews to keep up with:
1import {memo, useDeferredValue} from 'react' 2 3export function PreviewUsersList(props: UsersListProps) { 4 const {data: initialData, lastId} = props 5 const [snapshot] = useLiveQuery(initialData, usersQuery, {lastId}) 6 const data = useDeferredValue(snapshot) 7 8 return <UsersList data={data} lastId={lastId} /> 9} 10 11export const UsersList = memo(function UsersList(props: UsersListProps) { 12 const {data, lastId} = props 13 14 return ( 15 <> 16 <ListView list={data.list} /> 17 <ListPagination total={data.total} lastId={lastId} /> 18 </> 19 ) 20})
LiveQuery
wrapper component instead of the useLiveQuery
hookThe main benefit of the LiveQuery
wrapper, over the useLiveQuery
hook, is that it implements lazy loading. Unless enabled
the code for useLiveQuery
isn't loaded and your application's bundlesize isn't increased in production.
1import {LiveQuery} from '@sanity/preview-kit/live-query' 2 3const UsersList = memo(function UsersList(props: UsersListProps) { 4 const {data, lastId} = props 5 6 return ( 7 <> 8 <ListView list={data.list} /> 9 <ListPagination total={data.total} lastId={lastId} /> 10 </> 11 ) 12}) 13 14export default function Layout(props: LayoutProps) { 15 return ( 16 <LiveQuery 17 enabled={props.preview} 18 query={usersQuery} 19 params={{lastId: props.lastId}} 20 initialData={props.data} 21 > 22 <UsersList 23 // LiveQuery will override the `data` prop with the real-time data when live previews are enabled 24 data={props.data} 25 // But other props will be passed through 26 lastId={props.lastId} 27 /> 28 </LiveQuery> 29 ) 30}
For React Server Components it's important to note that the children
of LiveQuery
must be a use client
component. Otherwise it won't be able to re-render as the data
prop changes. The as
prop can be used to make sure the component is only used as a client component when live previews are enabled, below is an example of how this is done in the Next.js App Router, using 3 separate files:
app/users/[lastId]/UsersList.tsx
:
1// This component in itself doesn't have any interactivity and can be rendered on the server, and avoid adding to the browser bundle. 2 3export default function UsersList(props: UsersListProps) { 4 const {data, lastId} = props 5 6 return ( 7 <> 8 <ListView list={data.list} /> 9 <ListPagination total={data.total} lastId={lastId} /> 10 </> 11 ) 12}
app/users/[lastId]/UsersListPreview.tsx
:
1'use client' 2 3import dynamic from 'next/dynamic' 4 5// Re-exported components using next/dynamic ensures they're not bundled 6// and sent to the browser unless actually used, with draftMode().enabled. 7 8export default dynamic(() => import('./UsersList'))
app/users/[lastId]/page.tsx
1import {createClient} from '@sanity/client' 2import {LiveQuery} from '@sanity/preview-kit/live-query' 3import {draftMode} from 'next/headers' 4import UsersList from './UsersList' 5import UsersListPreview from './UsersListPreview' 6 7const client = createClient({ 8 // standard client config 9}) 10 11export default async function UsersPage(params) { 12 const {lastId} = params 13 const data = await client.fetch( 14 usersQuery, 15 {lastId}, 16 {perspective: draftMode().isEnabled ? 'previewDrafts' : 'published'}, 17 ) 18 19 return ( 20 <LiveQuery 21 enabled={draftMode().isEnabled} 22 query={usersQuery} 23 params={{lastId}} 24 initialData={data} 25 as={UsersListPreview} 26 > 27 <UsersList 28 data={data} 29 // LiveQuery ensures that the `lastId` prop used here is applied to `UsersListPreview` as well 30 lastId={lastId} 31 /> 32 </LiveQuery> 33 ) 34}
What's great about this setup is that UsersList
is rendering only on the server by default, but when live previews are enabled the UsersListPreview
repackages it to a client component so it's able to re-render in the browser in real-time as the data changes. It's the closest thing to having your cake and eating it too.
As the nature of live queries is that they're real-time, it can be hard to debug issues. Is nothing happening because no edits happened? Or because something isn't setup correctly?
To aid in understanding what's going on, you can pass a logger
prop to LiveQueryProvider
:
1<LiveQueryProvider client={client} token={token} logger={console}> 2 {children} 3</LiveQueryProvider>
You'll now get detailed reports on how it's setup and what to expect in terms of how it responds to updates. For example in large datasets it may use a polling interval instead of running GROQ queries on a complete local cache of your dataset.
You can also use the useIsEnabled
hook to determine of a live component (something that uses useLiveQuery
) has a LiveQueryProvider
in the parent tree or not:
1import {useLiveQuery, useIsEnabled} from '@sanity/preview-kit'
2
3export function PreviewUsersList(props) {
4 const [data] = useLiveQuery(props.data, query, params)
5 const isLive = useIsEnabled()
6
7 if (!isLive) {
8 throw new TypeError('UsersList is not wrapped in a LiveQueryProvider')
9 }
10
11 return <UsersList data={data} />
12}
If it's always false
it's an indicator that you may need to lift your LiveQueryProvider
higher up in the tree. Depending on the framework it's recommended that you put it in:
src/app/routes/index.tsx
src/app/layout.tsx
src/pages/_app.tsx
For data that can't be traced with Content Source Maps there's a background refresh interval. Depending on your queries you might want to tweak this interval to get the best performance.
1import {LiveQueryProvider} from '@sanity/preview-kit' 2 3return ( 4 <LiveQueryProvider 5 client={client} 6 token={token} 7 // Refetch all queries every minute instead of the default 10 seconds 8 refreshInterval={1000 * 60} 9 // Passing a logger gives you more information on what to expect based on your configuration 10 logger={console} 11 > 12 {children} 13 </LiveQueryProvider> 14)
@sanity/preview-kit/client
Note
Content Source Maps are available as an API for select Sanity enterprise customers. Contact our sales team for more information.
You can use Visual Editing with any framework, not just React. Read our guide for how to get started.
createClient
Preview Kit's enhanced Sanity client is built on top of @sanity/client
and is designed to be a drop-in replacement. It extends the client configuration with options for returning encoded metadata from Content Source Maps.
1// Remove your vanilla `@sanity/client` import 2// import {createClient, type ClientConfig} from '@sanity/client' 3 4// Use the enhanced client instead 5import {createClient, type ClientConfig} from '@sanity/preview-kit/client' 6 7const config: ClientConfig = { 8 // ...base config options 9 10 // Required: when "encodeSourceMap" is enabled 11 // Set it to relative or absolute URL of your Sanity Studio 12 studioUrl: '/studio', // or 'https://your-project-name.sanity.studio' 13 14 // Required: for encoded metadata from Content Source Maps 15 // 'auto' is the default, you can also use `true` or `false` 16 encodeSourceMap: 'auto', 17} 18 19const client = createClient(config)
studioUrl
Required when encodeSourceMap
is active, and can either be an absolute URL:
1import {createClient} from '@sanity/preview-kit/client'
2
3const client = createClient({
4 ...config,
5 studioUrl: 'https://your-company.com/studio',
6})
Or a relative path if the Studio is hosted on the same deployment, or embedded in the same app:
1import {createClient} from '@sanity/preview-kit/client' 2 3const client = createClient({ 4 ...config, 5 studioUrl: '/studio', 6})
encodeSourceMap
Accepts "auto"
, the default, or a boolean
. Controls when to encode the content source map into strings using @vercel/stega
encoding. When "auto"
is used a best-effort environment detection is used to see if the environment is a Vercel Preview deployment. On a different hosting provider, or in local development, configure this option to make sure it is only enabled in non-production deployments.
1import {createClient} from '@sanity/preview-kit/client' 2 3const client = createClient({ 4 ...config, 5 encodeSourceMap: process.env.VERCEL_ENV === 'preview', 6})
encodeSourceMapAtPath
By default source maps are encoded into all strings that can be traced back to a document field, except for URLs and ISO dates. We also make some exceptions for fields like, document._type
, document._id
and document.slug.current
, that we've seen leading to breakage if the string is altered as well as for Portable Text.
You can customize this behavior using encodeSourceMapAtPath
:
1import {createClient} from '@sanity/preview-kit/client' 2 3const client = createClient({ 4 ...config, 5 encodeSourceMapAtPath: (props) => { 6 if (props.path[0] === 'externalUrl') { 7 return false 8 } 9 // The default behavior is packaged into `filterDefault`, allowing you enable encoding fields that are skipped by default 10 return props.filterDefault(props) 11 }, 12})
logger
Pass a console
into logger
to get detailed debug info and reports on which fields are encoded and which are skipped:
1import {createClient} from '@sanity/preview-kit/client' 2 3const client = createClient({ 4 ...config, 5 logger: console, 6})
An example report:
1[@sanity/preview-kit/client]: Creating source map enabled client 2[@sanity/client/stega]: Stega encoding source map into result 3 [@sanity/client/stega]: Paths encoded: 3, skipped: 17 4 [@sanity/client/stega]: Table of encoded paths 5 ┌─────────┬──────────────────────────────┬───────────────────────────┬────────┐ 6 │ (index) │ path │ value │ length │ 7 ├─────────┼──────────────────────────────┼───────────────────────────┼────────┤ 8 │ 0 │ "footer[0].children[0].text" │ '"The future is alrea...' │ 67 │ 9 │ 1 │ "footer[1].children[0].text" │ 'Robin Williams' │ 14 │ 10 │ 2 │ "title" │ 'Visual Editing' │ 14 │ 11 └─────────┴──────────────────────────────┴───────────────────────────┴────────┘ 12 [@sanity/client/stega]: List of skipped paths [ 13 'footer[]._key', 14 'footer[].children[]._key', 15 'footer[].children[]._type', 16 'footer[]._type', 17 'footer[].style', 18 '_type', 19 'slug.current', 20 ]
resultSourceMap
This option is always enabled if encodeSourceMap
. It's exposed here to be compatible with @sanity/client
and custom use cases where you want content source maps, but not the encoding.
1const client = createClient({
2 ...config,
3 // This option can only enable content source maps, not disable it when `encodeSourceMap` resolves to `true`
4 resultSourceMap: true,
5})
6
7const {result, resultSourceMap} = await client.fetch(query, params, {
8 filterResponse: false,
9})
10
11console.log(resultSourceMap) // `resultSourceMap` is now available, even if `encodeSourceMap` is `false`
The perspective
option can be used to specify special filtering behavior for queries. The default value is raw
, which means no special filtering is applied, while published
and previewDrafts
can be used to optimize for specific use cases. Read more about this option:
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.
MIT-licensed. See LICENSE.
No vulnerabilities found.
No security vulnerabilities found.