├── README.md
├── apps
├── static
│ ├── .gitignore
│ ├── postcss.config.cjs
│ ├── app
│ │ ├── layout.tsx
│ │ ├── studio
│ │ │ └── page.tsx
│ │ ├── sanity.fetch.ts
│ │ ├── sanity.client.ts
│ │ ├── globals.css
│ │ ├── page.tsx
│ │ ├── Image.tsx
│ │ └── PostsLayout.tsx
│ ├── next.config.ts
│ ├── turbo.json
│ ├── groqd-client.ts
│ ├── .env.local.example
│ ├── tsconfig.json
│ ├── sanity.cli.ts
│ ├── sanity.config.ts
│ └── package.json
└── mvp
│ ├── .gitignore
│ ├── app
│ ├── (sanity)
│ │ ├── studio
│ │ │ └── page.tsx
│ │ └── layout.tsx
│ ├── (website)
│ │ ├── FormStatus.tsx
│ │ ├── live.ts
│ │ ├── actions.ts
│ │ ├── RefreshButton.tsx
│ │ ├── DebugStatus.tsx
│ │ ├── layout.tsx
│ │ ├── Image.tsx
│ │ ├── only-production
│ │ │ └── page.tsx
│ │ ├── no-resolve-perspective
│ │ │ └── page.tsx
│ │ ├── page.tsx
│ │ └── PostsLayout.tsx
│ ├── api
│ │ ├── draft-mode
│ │ │ └── enable
│ │ │ │ └── route.ts
│ │ └── revalidate-tag
│ │ │ └── route.ts
│ ├── sanity.client.ts
│ └── globals.css
│ ├── postcss.config.cjs
│ ├── groqd-client.ts
│ ├── next.config.ts
│ ├── .env.local.example
│ ├── turbo.json
│ ├── tsconfig.json
│ ├── sanity.cli.ts
│ ├── sanity.config.ts
│ └── package.json
├── packages
├── next-sanity
│ ├── src
│ │ ├── visual-editing
│ │ │ ├── index.ts
│ │ │ ├── client-component
│ │ │ │ ├── index.ts
│ │ │ │ ├── VisualEditingLazy.tsx
│ │ │ │ ├── utils.ts
│ │ │ │ └── VisualEditing.tsx
│ │ │ ├── server-actions
│ │ │ │ └── index.ts
│ │ │ └── VisualEditing.tsx
│ │ ├── draft-mode
│ │ │ ├── index.ts
│ │ │ └── define-enable-draft-mode.ts
│ │ ├── experimental
│ │ │ ├── constants.ts
│ │ │ ├── types.ts
│ │ │ └── client-components
│ │ │ │ └── PresentationComlink.tsx
│ │ ├── image
│ │ │ ├── index.ts
│ │ │ ├── imageLoader.ts
│ │ │ └── Image.tsx
│ │ ├── hooks
│ │ │ └── index.ts
│ │ ├── live
│ │ │ ├── client-components
│ │ │ │ ├── live
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── RefreshOnReconnect.tsx
│ │ │ │ │ ├── RefreshOnMount.tsx
│ │ │ │ │ ├── RefreshOnFocus.tsx
│ │ │ │ │ ├── PresentationComlink.tsx
│ │ │ │ │ └── SanityLive.tsx
│ │ │ │ └── live-stream
│ │ │ │ │ ├── index.ts
│ │ │ │ │ ├── SanityLiveStreamLazy.tsx
│ │ │ │ │ └── SanityLiveStream.tsx
│ │ │ ├── hooks
│ │ │ │ ├── index.ts
│ │ │ │ ├── useIsPresentationTool.ts
│ │ │ │ ├── useIsLivePreview.ts
│ │ │ │ ├── useDraftMode.ts
│ │ │ │ ├── context.ts
│ │ │ │ └── usePresentationQuery.ts
│ │ │ ├── resolveCookiePerspective.ts
│ │ │ ├── utils.ts
│ │ │ └── server-actions
│ │ │ │ └── index.ts
│ │ ├── client.ts
│ │ ├── create-data-attribute.ts
│ │ ├── studio
│ │ │ ├── client-component
│ │ │ │ ├── index.ts
│ │ │ │ ├── useIsMounted.ts
│ │ │ │ ├── NextStudioLazy.tsx
│ │ │ │ ├── createHashHistoryForStudio.ts
│ │ │ │ └── NextStudio.tsx
│ │ │ ├── index.ts
│ │ │ ├── NextStudioLayout.tsx
│ │ │ ├── NextStudioWithBridge.tsx
│ │ │ ├── NextStudioNoScript.tsx
│ │ │ └── head.tsx
│ │ ├── index.ts
│ │ ├── live.ts
│ │ ├── isCorsOriginError.ts
│ │ ├── live.server-only.ts
│ │ └── webhook
│ │ │ └── index.ts
│ ├── tsconfig.build.json
│ ├── turbo.json
│ ├── vite.config.ts
│ ├── tsconfig.json
│ ├── tsconfig.base.json
│ ├── test
│ │ ├── verifyHistoryVersion.test.ts
│ │ └── imageLoader.test.ts
│ ├── MIGRATE-v11-to-v12.md
│ ├── MIGRATE-v6-to-v7.md
│ ├── MIGRATE-v5-to-v6.md
│ ├── tsdown.config.ts
│ ├── MIGRATE-v10-to-v11.md
│ ├── MIGRATE-v4-to-v5-app-router.md
│ ├── EXPERIMENTAL-CACHE-COMPONENTS.md
│ ├── MIGRATE-v4-to-v5-pages-router.md
│ ├── MIGRATE-v7-to-v8.md
│ ├── package.json
│ ├── MIGRATE-v8-to-v9.md
│ ├── MIGRATE-v1-to-v4.md
│ └── MIGRATE-v9-to-v10.md
├── sanity-config
│ ├── tsconfig.json
│ ├── src
│ │ ├── schemas
│ │ │ ├── index.ts
│ │ │ ├── category.ts
│ │ │ ├── author.ts
│ │ │ ├── post.ts
│ │ │ └── blockContent.ts
│ │ └── index.tsx
│ └── package.json
└── typescript-config
│ ├── package.json
│ └── base.json
├── fixtures
├── fail
│ └── server-only-live
│ │ ├── next.config.ts
│ │ ├── src
│ │ └── app
│ │ │ ├── layout.tsx
│ │ │ └── page.tsx
│ │ ├── package.json
│ │ ├── .gitignore
│ │ └── tsconfig.json
└── pass
│ └── server-only-live
│ ├── next.config.ts
│ ├── src
│ └── app
│ │ ├── layout.tsx
│ │ └── page.tsx
│ ├── package.json
│ ├── .gitignore
│ └── tsconfig.json
├── .changeset
└── config.json
├── .oxfmtrc.json
├── .github
├── workflows
│ ├── format-if-needed.yml
│ ├── renovate.yml
│ ├── release.yml
│ ├── lock.yml
│ └── ci.yml
└── renovate.json
├── turbo.json
├── .oxlintrc.json
├── package.json
├── LICENSE
├── pnpm-workspace.yaml
└── .gitignore
/README.md:
--------------------------------------------------------------------------------
1 | ./packages/next-sanity/README.md
--------------------------------------------------------------------------------
/apps/static/.gitignore:
--------------------------------------------------------------------------------
1 | public/analyze
2 | next-env.d.ts
3 |
--------------------------------------------------------------------------------
/packages/next-sanity/src/visual-editing/index.ts:
--------------------------------------------------------------------------------
1 | export * from './VisualEditing'
2 |
--------------------------------------------------------------------------------
/packages/next-sanity/src/draft-mode/index.ts:
--------------------------------------------------------------------------------
1 | export * from './define-enable-draft-mode'
2 |
--------------------------------------------------------------------------------
/apps/mvp/.gitignore:
--------------------------------------------------------------------------------
1 | .vercel
2 | .env*.local
3 | public/studio/static
4 | public/analyze
5 | next-env.d.ts
6 |
--------------------------------------------------------------------------------
/packages/next-sanity/src/experimental/constants.ts:
--------------------------------------------------------------------------------
1 | export const PUBLISHED_SYNC_TAG_PREFIX = 'sp:'
2 | export const DRAFT_SYNC_TAG_PREFIX = 'sd:'
3 |
--------------------------------------------------------------------------------
/packages/next-sanity/src/image/index.ts:
--------------------------------------------------------------------------------
1 | 'use client'
2 | export {Image, type ImageProps} from './Image'
3 | export {imageLoader} from './imageLoader'
4 |
--------------------------------------------------------------------------------
/packages/next-sanity/src/hooks/index.ts:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | export * from '../live/hooks'
4 | export {useOptimistic} from '@sanity/visual-editing/react'
5 |
--------------------------------------------------------------------------------
/packages/sanity-config/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@repo/typescript-config/base.json",
3 | "include": ["src/**/*.ts", "src/**/*.tsx"],
4 | "exclude": ["node_modules"]
5 | }
6 |
--------------------------------------------------------------------------------
/packages/next-sanity/src/live/client-components/live/index.ts:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | export type {SanityLiveProps} from './SanityLive'
4 | export {SanityLive as default} from './SanityLive'
5 |
--------------------------------------------------------------------------------
/fixtures/fail/server-only-live/next.config.ts:
--------------------------------------------------------------------------------
1 | import type {NextConfig} from 'next'
2 |
3 | const nextConfig: NextConfig = {
4 | cacheComponents: true,
5 | }
6 |
7 | export default nextConfig
8 |
--------------------------------------------------------------------------------
/fixtures/pass/server-only-live/next.config.ts:
--------------------------------------------------------------------------------
1 | import type {NextConfig} from 'next'
2 |
3 | const nextConfig: NextConfig = {
4 | cacheComponents: true,
5 | }
6 |
7 | export default nextConfig
8 |
--------------------------------------------------------------------------------
/packages/next-sanity/src/client.ts:
--------------------------------------------------------------------------------
1 | export type * from '@sanity/client'
2 | export {createClient, unstable__adapter, unstable__environment} from '@sanity/client'
3 | export {stegaClean} from '@sanity/client/stega'
4 |
--------------------------------------------------------------------------------
/packages/next-sanity/src/create-data-attribute.ts:
--------------------------------------------------------------------------------
1 | export {
2 | type CreateDataAttribute,
3 | createDataAttribute,
4 | type CreateDataAttributeProps,
5 | } from '@sanity/visual-editing/create-data-attribute'
6 |
--------------------------------------------------------------------------------
/packages/next-sanity/src/studio/client-component/index.ts:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | export type {NextStudioProps} from './NextStudio'
4 | export {NextStudioLazyClientComponent as NextStudio} from './NextStudioLazy'
5 |
--------------------------------------------------------------------------------
/packages/next-sanity/src/visual-editing/client-component/index.ts:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | export type {VisualEditingProps} from './VisualEditing'
4 | export {VisualEditingLazyClientComponent as default} from './VisualEditingLazy'
5 |
--------------------------------------------------------------------------------
/apps/mvp/app/(sanity)/studio/page.tsx:
--------------------------------------------------------------------------------
1 | import {NextStudio} from 'next-sanity/studio'
2 |
3 | import config from '@/sanity.config'
4 |
5 | export default function StudioPage() {
6 | return
7 | }
8 |
--------------------------------------------------------------------------------
/apps/mvp/postcss.config.cjs:
--------------------------------------------------------------------------------
1 | // If you want to use other PostCSS plugins, see the following:
2 | // https://tailwindcss.com/docs/using-with-preprocessors
3 | module.exports = {
4 | plugins: {
5 | '@tailwindcss/postcss': {},
6 | },
7 | }
8 |
--------------------------------------------------------------------------------
/packages/next-sanity/src/live/client-components/live-stream/index.ts:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | export type {SanityLiveStreamProps} from './SanityLiveStream'
4 | export {SanityLiveStreamLazyClientComponent as default} from './SanityLiveStreamLazy'
5 |
--------------------------------------------------------------------------------
/apps/static/postcss.config.cjs:
--------------------------------------------------------------------------------
1 | // If you want to use other PostCSS plugins, see the following:
2 | // https://tailwindcss.com/docs/using-with-preprocessors
3 | module.exports = {
4 | plugins: {
5 | '@tailwindcss/postcss': {},
6 | },
7 | }
8 |
--------------------------------------------------------------------------------
/packages/next-sanity/src/index.ts:
--------------------------------------------------------------------------------
1 | export * from './client'
2 | export * from './create-data-attribute'
3 | export * from '@portabletext/react'
4 | export {defineQuery, default as groq} from 'groq'
5 | export {isCorsOriginError} from './isCorsOriginError'
6 |
--------------------------------------------------------------------------------
/packages/typescript-config/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@repo/typescript-config",
3 | "private": true,
4 | "exports": {
5 | "./base.json": "./base.json"
6 | },
7 | "dependencies": {
8 | "typescript": "catalog:"
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/packages/sanity-config/src/schemas/index.ts:
--------------------------------------------------------------------------------
1 | import author from './author'
2 | import blockContent from './blockContent'
3 | import category from './category'
4 | import post from './post'
5 |
6 | export const schemaTypes = [post, author, category, blockContent]
7 |
--------------------------------------------------------------------------------
/packages/next-sanity/tsconfig.build.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": ["./tsconfig.base", "@sanity/tsconfig/isolated-declarations"],
3 | "include": ["src/**/*.ts", "src/**/*.tsx"],
4 | "exclude": ["dist", "node_modules", "./src/**/*.test.ts", "./src/**/*.test.tsx"]
5 | }
6 |
--------------------------------------------------------------------------------
/.changeset/config.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://unpkg.com/@changesets/config/schema.json",
3 | "changelog": ["@changesets/changelog-github", {"repo": "sanity-io/next-sanity"}],
4 | "access": "public",
5 | "baseBranch": "main",
6 | "privatePackages": false
7 | }
8 |
--------------------------------------------------------------------------------
/apps/static/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import './globals.css'
2 |
3 | export default function RootLayout({children}: {children: React.ReactNode}) {
4 | return (
5 |
6 |
{children}
8 |
9 | )
10 | }
11 |
--------------------------------------------------------------------------------
/fixtures/fail/server-only-live/src/app/layout.tsx:
--------------------------------------------------------------------------------
1 | export default function RootLayout({
2 | children,
3 | }: Readonly<{
4 | children: React.ReactNode
5 | }>) {
6 | return (
7 |
8 | {children}
9 |
10 | )
11 | }
12 |
--------------------------------------------------------------------------------
/fixtures/pass/server-only-live/src/app/layout.tsx:
--------------------------------------------------------------------------------
1 | export default function RootLayout({
2 | children,
3 | }: Readonly<{
4 | children: React.ReactNode
5 | }>) {
6 | return (
7 |
8 | {children}
9 |
10 | )
11 | }
12 |
--------------------------------------------------------------------------------
/packages/next-sanity/turbo.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://turbo.build/schema.json",
3 | "extends": ["//"],
4 | "tasks": {
5 | "build": {
6 | "outputs": ["dist/**"]
7 | },
8 | "test": {
9 | "env": ["GITHUB_ACTIONS"]
10 | }
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/packages/next-sanity/vite.config.ts:
--------------------------------------------------------------------------------
1 | import {configDefaults, defineConfig} from 'vitest/config'
2 |
3 | export default defineConfig({
4 | test: {
5 | // don't use vitest to run Bun and Deno tests
6 | exclude: [...configDefaults.exclude, 'test.cjs', 'test.mjs'],
7 | },
8 | })
9 |
--------------------------------------------------------------------------------
/packages/next-sanity/src/live.ts:
--------------------------------------------------------------------------------
1 | export {
2 | type DefineSanityLiveOptions,
3 | type DefinedSanityFetchType,
4 | type DefinedSanityLiveProps,
5 | type DefinedSanityLiveStreamType,
6 | defineLive,
7 | } from './live/defineLive'
8 | export {isCorsOriginError} from './isCorsOriginError'
9 |
--------------------------------------------------------------------------------
/packages/next-sanity/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.base",
3 | "include": ["**/*.ts", "**/*.tsx"],
4 | "exclude": ["dist", "node_modules"],
5 | "compilerOptions": {
6 | "plugins": [
7 | {
8 | "name": "next"
9 | }
10 | ]
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/apps/mvp/app/(website)/FormStatus.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import {useFormStatus} from 'react-dom'
4 |
5 | export function FormStatusLabel({idle, pending}: {idle: string; pending: string}) {
6 | const status = useFormStatus()
7 |
8 | return status.pending ? pending : idle
9 | }
10 |
--------------------------------------------------------------------------------
/apps/static/app/studio/page.tsx:
--------------------------------------------------------------------------------
1 | import {NextStudio} from 'next-sanity/studio'
2 |
3 | import config from '@/sanity.config'
4 |
5 | export {metadata, viewport} from 'next-sanity/studio'
6 |
7 | export default function Studio() {
8 | return
9 | }
10 |
--------------------------------------------------------------------------------
/apps/static/next.config.ts:
--------------------------------------------------------------------------------
1 | import type {NextConfig} from 'next'
2 |
3 | const nextConfig: NextConfig = {
4 | output: 'export',
5 | logging: {
6 | fetches: {
7 | fullUrl: false,
8 | },
9 | },
10 | productionBrowserSourceMaps: true,
11 | }
12 |
13 | export default nextConfig
14 |
--------------------------------------------------------------------------------
/packages/next-sanity/tsconfig.base.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@sanity/tsconfig/strictest",
3 | "compilerOptions": {
4 | "rootDir": ".",
5 | "outDir": "dist",
6 | "noUncheckedIndexedAccess": false,
7 | "exactOptionalPropertyTypes": false,
8 | "jsx": "react-jsx"
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/apps/static/turbo.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://turbo.build/schema.json",
3 | "extends": ["//"],
4 | "tasks": {
5 | "build": {
6 | "env": ["NEXT_PUBLIC_SANITY_PROJECT_ID", "NEXT_PUBLIC_SANITY_DATASET"],
7 | "outputs": [".next/**", "!.next/cache/**", "out/**"]
8 | }
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/packages/next-sanity/src/studio/index.ts:
--------------------------------------------------------------------------------
1 | export {metadata, viewport} from './head'
2 | export * from './NextStudioLayout'
3 | export * from './NextStudioNoScript'
4 | export {NextStudioWithBridge as NextStudio} from './NextStudioWithBridge'
5 | export {type NextStudioProps} from 'next-sanity/studio/client-component'
6 |
--------------------------------------------------------------------------------
/apps/mvp/app/api/draft-mode/enable/route.ts:
--------------------------------------------------------------------------------
1 | import {defineEnableDraftMode} from 'next-sanity/draft-mode'
2 |
3 | import {client} from '@/app/sanity.client'
4 |
5 | export const {GET} = defineEnableDraftMode({
6 | client: client.withConfig({
7 | token: process.env.SANITY_API_READ_TOKEN,
8 | }),
9 | })
10 |
--------------------------------------------------------------------------------
/packages/next-sanity/src/isCorsOriginError.ts:
--------------------------------------------------------------------------------
1 | import type {CorsOriginError} from '@sanity/client'
2 |
3 | /** @public */
4 | export function isCorsOriginError(error: unknown): error is CorsOriginError {
5 | return error instanceof Error && error.name === 'CorsOriginError'
6 | }
7 |
8 | export type {CorsOriginError}
9 |
--------------------------------------------------------------------------------
/packages/next-sanity/src/live/hooks/index.ts:
--------------------------------------------------------------------------------
1 | export * from './useDraftMode'
2 | export type {DraftPerspective, DraftEnvironment} from './context'
3 | export type {ClientPerspective} from '@sanity/client'
4 | export * from './useIsPresentationTool'
5 | export * from './useIsLivePreview'
6 | export * from './usePresentationQuery'
7 |
--------------------------------------------------------------------------------
/apps/mvp/app/(sanity)/layout.tsx:
--------------------------------------------------------------------------------
1 | import '../globals.css'
2 |
3 | export {metadata, viewport} from 'next-sanity/studio'
4 |
5 | export default function RootLayout({children}: {children: React.ReactNode}) {
6 | return (
7 |
8 | {children}
10 |
11 | )
12 | }
13 |
--------------------------------------------------------------------------------
/apps/mvp/app/(website)/live.ts:
--------------------------------------------------------------------------------
1 | import {defineLive} from 'next-sanity/experimental/live'
2 |
3 | import {client} from '@/app/sanity.client'
4 |
5 | const token = process.env.SANITY_API_READ_TOKEN!
6 |
7 | export const {sanityFetch, SanityLive} = defineLive({
8 | client,
9 | serverToken: token,
10 | browserToken: token,
11 | })
12 |
--------------------------------------------------------------------------------
/packages/next-sanity/src/studio/client-component/useIsMounted.ts:
--------------------------------------------------------------------------------
1 | import {useSyncExternalStore} from 'react'
2 |
3 | /** @internal */
4 | export function useIsMounted(): boolean {
5 | return useSyncExternalStore(
6 | emptySubscribe,
7 | () => true,
8 | () => false,
9 | )
10 | }
11 | const emptySubscribe = () => () => {}
12 |
--------------------------------------------------------------------------------
/.oxfmtrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "./node_modules/oxfmt/configuration_schema.json",
3 | "printWidth": 100,
4 | "semi": false,
5 | "singleQuote": true,
6 | "bracketSpacing": false,
7 | "quoteProps": "consistent",
8 | "experimentalSortImports": {},
9 | "ignorePatterns": ["dist/**", "pnpm-lock.yaml", ".changeset/*.md", "CHANGELOG.md"]
10 | }
11 |
--------------------------------------------------------------------------------
/apps/mvp/groqd-client.ts:
--------------------------------------------------------------------------------
1 | import {createGroqBuilder} from 'groqd'
2 |
3 | import type {AllSanitySchemaTypes, internalGroqTypeReferenceTo} from './sanity.types.ts'
4 |
5 | type SchemaConfig = {
6 | schemaTypes: AllSanitySchemaTypes
7 | referenceSymbol: typeof internalGroqTypeReferenceTo
8 | }
9 | export const q = createGroqBuilder()
10 |
--------------------------------------------------------------------------------
/.github/workflows/format-if-needed.yml:
--------------------------------------------------------------------------------
1 | name: Auto format
2 |
3 | on:
4 | push:
5 | branches: [main]
6 |
7 | concurrency: ${{ github.workflow }}-${{ github.ref }}
8 |
9 | permissions:
10 | contents: read # for checkout
11 |
12 | jobs:
13 | if-needed:
14 | uses: sanity-io/.github/.github/workflows/format.yml@main
15 | secrets: inherit
16 |
--------------------------------------------------------------------------------
/apps/static/groqd-client.ts:
--------------------------------------------------------------------------------
1 | import {createGroqBuilder} from 'groqd'
2 |
3 | import type {AllSanitySchemaTypes, internalGroqTypeReferenceTo} from './sanity.types.ts'
4 |
5 | type SchemaConfig = {
6 | schemaTypes: AllSanitySchemaTypes
7 | referenceSymbol: typeof internalGroqTypeReferenceTo
8 | }
9 | export const q = createGroqBuilder()
10 |
--------------------------------------------------------------------------------
/packages/next-sanity/src/experimental/types.ts:
--------------------------------------------------------------------------------
1 | import type {InitializedClientConfig} from '@sanity/client'
2 |
3 | export interface SanityClientConfig extends Pick<
4 | InitializedClientConfig,
5 | | 'projectId'
6 | | 'dataset'
7 | | 'apiHost'
8 | | 'apiVersion'
9 | | 'useProjectHostname'
10 | | 'token'
11 | | 'requestTagPrefix'
12 | > {}
13 |
--------------------------------------------------------------------------------
/apps/mvp/app/(website)/actions.ts:
--------------------------------------------------------------------------------
1 | 'use server'
2 |
3 | import {draftMode} from 'next/headers'
4 |
5 | export async function disableDraftMode() {
6 | 'use server'
7 | await Promise.allSettled([
8 | (await draftMode()).disable(),
9 | // Simulate a delay to show the loading state
10 | new Promise((resolve) => setTimeout(resolve, 1000)),
11 | ])
12 | }
13 |
--------------------------------------------------------------------------------
/apps/mvp/app/(website)/RefreshButton.tsx:
--------------------------------------------------------------------------------
1 | import {refresh} from 'next/cache'
2 |
3 | export function RefreshButton() {
4 | return (
5 |
14 | )
15 | }
16 |
--------------------------------------------------------------------------------
/apps/mvp/next.config.ts:
--------------------------------------------------------------------------------
1 | import type {NextConfig} from 'next'
2 |
3 | const nextConfig: NextConfig = {
4 | // basePath: process.env.NEXT_PUBLIC_TEST_BASE_PATH,
5 | // trailingSlash: true,
6 | cacheComponents: true,
7 | logging: {
8 | fetches: {
9 | fullUrl: false,
10 | },
11 | },
12 | productionBrowserSourceMaps: true,
13 | }
14 |
15 | export default nextConfig
16 |
--------------------------------------------------------------------------------
/apps/static/app/sanity.fetch.ts:
--------------------------------------------------------------------------------
1 | import {client, type QueryParams} from './sanity.client'
2 |
3 | export const token = process.env.SANITY_API_READ_TOKEN!
4 |
5 | export function sanityFetch({
6 | query,
7 | params = {},
8 | }: {
9 | query: string
10 | params?: QueryParams
11 | }) {
12 | return client.fetch(query, params, {cache: 'no-store'})
13 | }
14 |
--------------------------------------------------------------------------------
/fixtures/pass/server-only-live/src/app/page.tsx:
--------------------------------------------------------------------------------
1 | import {createClient} from 'next-sanity'
2 | import {defineLive} from 'next-sanity/live'
3 |
4 | const {SanityLive} = defineLive({
5 | client: createClient({
6 | projectId: 'pv8y60vp',
7 | dataset: 'production',
8 | apiVersion: '2025-10-24',
9 | useCdn: true,
10 | }),
11 | })
12 |
13 | export default function Home() {
14 | return
15 | }
16 |
--------------------------------------------------------------------------------
/apps/mvp/.env.local.example:
--------------------------------------------------------------------------------
1 | # Setup env by running `npx vercel link && npx vercel env pull` in your cli
2 | # If you don't have access to the Vercel deploy then `cp .env.local.example .env.local` and set these up manually
3 | NEXT_PUBLIC_SANITY_PROJECT_ID="pv8y60vp"
4 | NEXT_PUBLIC_SANITY_DATASET="production"
5 | # Used for Preview Mode, it's exposed clientside for those who launch the preview pages/api/preview
6 | # SANITY_API_READ_TOKEN=...
--------------------------------------------------------------------------------
/apps/static/.env.local.example:
--------------------------------------------------------------------------------
1 | # Setup env by running `npx vercel link && npx vercel env pull` in your cli
2 | # If you don't have access to the Vercel deploy then `cp .env.local.example .env.local` and set these up manually
3 | NEXT_PUBLIC_SANITY_PROJECT_ID="pv8y60vp"
4 | NEXT_PUBLIC_SANITY_DATASET="production"
5 | # Used for Preview Mode, it's exposed clientside for those who launch the preview pages/api/preview
6 | # SANITY_API_READ_TOKEN=...
--------------------------------------------------------------------------------
/packages/next-sanity/src/visual-editing/server-actions/index.ts:
--------------------------------------------------------------------------------
1 | 'use server'
2 | import {revalidatePath} from 'next/cache'
3 | import {draftMode} from 'next/headers'
4 |
5 | export async function revalidateRootLayout(): Promise {
6 | if (!(await draftMode()).isEnabled) {
7 | console.warn('Skipped revalidatePath request because draft mode is not enabled')
8 | return
9 | }
10 | revalidatePath('/', 'layout')
11 | }
12 |
--------------------------------------------------------------------------------
/packages/sanity-config/src/schemas/category.ts:
--------------------------------------------------------------------------------
1 | import {defineType} from 'sanity'
2 |
3 | export default defineType({
4 | name: 'category',
5 | title: 'Category',
6 | type: 'document',
7 | fields: [
8 | {
9 | name: 'title',
10 | title: 'Title',
11 | type: 'string',
12 | },
13 | {
14 | name: 'description',
15 | title: 'Description',
16 | type: 'text',
17 | },
18 | ],
19 | })
20 |
--------------------------------------------------------------------------------
/apps/mvp/turbo.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://turbo.build/schema.json",
3 | "extends": ["//"],
4 | "tasks": {
5 | "build": {
6 | "env": [
7 | "NEXT_PUBLIC_SANITY_PROJECT_ID",
8 | "NEXT_PUBLIC_SANITY_DATASET",
9 | "NEXT_PUBLIC_TEST_BASE_PATH",
10 | "SANITY_API_READ_TOKEN"
11 | ],
12 | "outputs": [".next/**", "!.next/cache/**", "public/studio/static/**"]
13 | }
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/apps/static/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@repo/typescript-config/base.json",
3 | "compilerOptions": {
4 | "plugins": [
5 | {
6 | "name": "next"
7 | }
8 | ],
9 | "module": "preserve",
10 | "paths": {
11 | "@/*": ["./*"]
12 | },
13 | "jsx": "react-jsx"
14 | },
15 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
16 | "exclude": ["node_modules"]
17 | }
18 |
--------------------------------------------------------------------------------
/apps/static/sanity.cli.ts:
--------------------------------------------------------------------------------
1 | import {loadEnvConfig} from '@next/env'
2 | import {defineCliConfig} from 'sanity/cli'
3 |
4 | const dev = process.env.NODE_ENV !== 'production'
5 | loadEnvConfig(__dirname, dev, {info: () => null, error: console.error})
6 |
7 | const projectId = process.env.NEXT_PUBLIC_SANITY_PROJECT_ID
8 | const dataset = process.env.NEXT_PUBLIC_SANITY_DATASET
9 |
10 | export default defineCliConfig({api: {projectId, dataset}})
11 |
--------------------------------------------------------------------------------
/fixtures/fail/server-only-live/src/app/page.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import {createClient} from 'next-sanity'
4 | import {defineLive} from 'next-sanity/live'
5 |
6 | const {SanityLive} = defineLive({
7 | client: createClient({
8 | projectId: 'pv8y60vp',
9 | dataset: 'production',
10 | apiVersion: '2025-10-24',
11 | useCdn: true,
12 | }),
13 | })
14 |
15 | export default function Home() {
16 | return
17 | }
18 |
--------------------------------------------------------------------------------
/apps/mvp/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@repo/typescript-config/base.json",
3 | "compilerOptions": {
4 | "plugins": [
5 | {
6 | "name": "next"
7 | }
8 | ],
9 | "module": "preserve",
10 | "paths": {
11 | "@/*": ["./*"]
12 | },
13 | "jsx": "react-jsx"
14 | },
15 | "include": ["**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "next-env.d.ts", "**/*.mts"],
16 | "exclude": ["node_modules"]
17 | }
18 |
--------------------------------------------------------------------------------
/apps/static/sanity.config.ts:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import sharedConfig from '@repo/sanity-config'
4 | import {defineConfig} from 'sanity'
5 |
6 | const projectId = process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!
7 | const dataset = process.env.NEXT_PUBLIC_SANITY_DATASET!
8 |
9 | export default defineConfig({
10 | projectId,
11 | dataset,
12 |
13 | plugins: [sharedConfig()],
14 |
15 | scheduledPublishing: {
16 | enabled: false,
17 | },
18 | })
19 |
--------------------------------------------------------------------------------
/packages/sanity-config/src/index.tsx:
--------------------------------------------------------------------------------
1 | import {assist} from '@sanity/assist'
2 | import {visionTool} from '@sanity/vision'
3 | import {definePlugin} from 'sanity'
4 | import {structureTool} from 'sanity/structure'
5 |
6 | import {schemaTypes} from './schemas'
7 |
8 | export default definePlugin({
9 | name: '@repo/sanity-config',
10 | plugins: [assist(), structureTool(), visionTool()],
11 | schema: {
12 | types: schemaTypes,
13 | },
14 | })
15 |
--------------------------------------------------------------------------------
/.github/workflows/renovate.yml:
--------------------------------------------------------------------------------
1 | name: Add changeset to Renovate updates
2 |
3 | on:
4 | pull_request_target:
5 | types: [opened, synchronize]
6 |
7 | concurrency: ${{ github.workflow }}-${{ github.ref }}
8 |
9 | permissions:
10 | contents: read # for checkout
11 |
12 | jobs:
13 | call:
14 | uses: sanity-io/.github/.github/workflows/changesets-from-conventional-commits.yml@main
15 | if: github.event.pull_request.user.login == 'renovate[bot]'
16 | secrets: inherit
17 |
--------------------------------------------------------------------------------
/packages/sanity-config/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@repo/sanity-config",
3 | "private": true,
4 | "exports": {
5 | ".": "./src/index.tsx"
6 | },
7 | "dependencies": {
8 | "@sanity/assist": "^5.0.3"
9 | },
10 | "devDependencies": {
11 | "@repo/typescript-config": "workspace:*",
12 | "@sanity/vision": "catalog:",
13 | "sanity": "catalog:"
14 | },
15 | "peerDependencies": {
16 | "@sanity/vision": "catalog:",
17 | "sanity": "catalog:"
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/apps/mvp/sanity.cli.ts:
--------------------------------------------------------------------------------
1 | import {loadEnvConfig} from '@next/env'
2 | import {defineCliConfig} from 'sanity/cli'
3 |
4 | const dev = process.env.NODE_ENV !== 'production'
5 | loadEnvConfig(__dirname, dev, {info: () => null, error: console.error})
6 |
7 | const projectId = process.env.NEXT_PUBLIC_SANITY_PROJECT_ID
8 | const dataset = process.env.NEXT_PUBLIC_SANITY_DATASET
9 |
10 | export default defineCliConfig({
11 | api: {projectId, dataset},
12 | studioHost: `https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}/studio`,
13 | })
14 |
--------------------------------------------------------------------------------
/apps/static/app/sanity.client.ts:
--------------------------------------------------------------------------------
1 | import {createClient} from 'next-sanity'
2 |
3 | export const client = createClient({
4 | projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!,
5 | dataset: process.env.NEXT_PUBLIC_SANITY_DATASET!,
6 | apiVersion: '2025-03-04',
7 | useCdn: false,
8 | perspective: 'published',
9 | resultSourceMap: 'withKeyArraySelector',
10 | stega: {
11 | enabled: true,
12 | studioUrl: '/studio/#',
13 | logger: console,
14 | },
15 | })
16 |
17 | export type {QueryOptions, QueryParams} from 'next-sanity'
18 |
--------------------------------------------------------------------------------
/turbo.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://turbo.build/schema.json",
3 | "globalDependencies": [".env", ".env.local"],
4 | "tasks": {
5 | "build": {
6 | "dependsOn": ["^build"]
7 | },
8 | "typegen": {},
9 | "test": {
10 | "dependsOn": ["^build"],
11 | "cache": false,
12 | "persistent": true
13 | },
14 | "dev": {
15 | "cache": false,
16 | "persistent": true
17 | },
18 | "start": {
19 | "dependsOn": ["build"],
20 | "cache": false,
21 | "persistent": true
22 | }
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: Release
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 | - cache-components
8 |
9 | concurrency: ${{ github.workflow }}-${{ github.ref }}
10 |
11 | permissions:
12 | contents: read # for checkout
13 |
14 | jobs:
15 | release:
16 | uses: sanity-io/.github/.github/workflows/changesets.yml@main
17 | permissions:
18 | contents: read # for checkout
19 | id-token: write # to enable use of OIDC for npm provenance
20 | with:
21 | TURBO_TEAM: ${{ vars.TURBO_TEAM }}
22 | secrets: inherit
23 |
--------------------------------------------------------------------------------
/packages/typescript-config/base.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "esnext",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": false,
8 | "strictNullChecks": true,
9 | "forceConsistentCasingInFileNames": true,
10 | "noEmit": true,
11 | "esModuleInterop": true,
12 | "module": "preserve",
13 | "moduleResolution": "bundler",
14 | "resolveJsonModule": true,
15 | "isolatedModules": true,
16 | "jsx": "preserve",
17 | "incremental": false
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/packages/next-sanity/test/verifyHistoryVersion.test.ts:
--------------------------------------------------------------------------------
1 | import sanityJson from 'sanity/package.json' assert {type: 'json'}
2 | import {expect, test} from 'vitest'
3 |
4 | import nextSanityJson from '../package.json' assert {type: 'json'}
5 |
6 | /**
7 | * It's important that the `history` package used by `sanity` to underpin its router is the same we use to implement hash history support
8 | */
9 |
10 | test('verify that next-sanity requires the same history version as sanity', () => {
11 | expect(nextSanityJson.dependencies.history).toBe(sanityJson.dependencies.history)
12 | })
13 |
--------------------------------------------------------------------------------
/apps/mvp/app/sanity.client.ts:
--------------------------------------------------------------------------------
1 | import {createClient} from 'next-sanity'
2 |
3 | export const client = createClient({
4 | projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!,
5 | dataset: process.env.NEXT_PUBLIC_SANITY_DATASET!,
6 | apiVersion: '2025-03-04',
7 | useCdn: false,
8 | perspective: 'published',
9 | resultSourceMap: 'withKeyArraySelector',
10 | stega: {
11 | enabled: true,
12 | studioUrl: `${process.env.NEXT_PUBLIC_TEST_BASE_PATH || ''}/studio#`,
13 | // logger: console,
14 | },
15 | })
16 |
17 | export type {QueryOptions, QueryParams} from 'next-sanity'
18 |
--------------------------------------------------------------------------------
/packages/next-sanity/src/live/client-components/live/RefreshOnReconnect.tsx:
--------------------------------------------------------------------------------
1 | import {useRouter} from 'next/navigation'
2 | import {useEffect} from 'react'
3 |
4 | export default function RefreshOnReconnect(): null {
5 | const router = useRouter()
6 |
7 | useEffect(() => {
8 | const controller = new AbortController()
9 | const {signal} = controller
10 | window.addEventListener('online', () => router.refresh(), {passive: true, signal})
11 | return () => controller.abort()
12 | }, [router])
13 |
14 | return null
15 | }
16 | RefreshOnReconnect.displayName = 'RefreshOnReconnect'
17 |
--------------------------------------------------------------------------------
/apps/mvp/app/globals.css:
--------------------------------------------------------------------------------
1 | @import 'tailwindcss';
2 |
3 | /*
4 | The default border color has changed to `currentcolor` in Tailwind CSS v4,
5 | so we've added these compatibility styles to make sure everything still
6 | looks the same as it did with Tailwind CSS v3.
7 |
8 | If we ever want to remove these styles, we need to add an explicit border
9 | color utility to any element that depends on these defaults.
10 | */
11 | @layer base {
12 | *,
13 | ::after,
14 | ::before,
15 | ::backdrop,
16 | ::file-selector-button {
17 | border-color: var(--color-gray-200, currentcolor);
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/apps/static/app/globals.css:
--------------------------------------------------------------------------------
1 | @import 'tailwindcss';
2 |
3 | /*
4 | The default border color has changed to `currentcolor` in Tailwind CSS v4,
5 | so we've added these compatibility styles to make sure everything still
6 | looks the same as it did with Tailwind CSS v3.
7 |
8 | If we ever want to remove these styles, we need to add an explicit border
9 | color utility to any element that depends on these defaults.
10 | */
11 | @layer base {
12 | *,
13 | ::after,
14 | ::before,
15 | ::backdrop,
16 | ::file-selector-button {
17 | border-color: var(--color-gray-200, currentcolor);
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/packages/next-sanity/src/studio/NextStudioLayout.tsx:
--------------------------------------------------------------------------------
1 | /** @public */
2 | export interface NextStudioLayoutProps {
3 | children: React.ReactNode
4 | }
5 |
6 | const style = {
7 | height: '100vh',
8 | maxHeight: '100dvh',
9 | overscrollBehavior: 'none',
10 | WebkitFontSmoothing: 'antialiased',
11 | overflow: 'auto',
12 | } satisfies React.CSSProperties
13 |
14 | /** @public */
15 | export const NextStudioLayout = ({children}: NextStudioLayoutProps): React.JSX.Element => {
16 | return (
17 |
18 | {children}
19 |
20 | )
21 | }
22 |
--------------------------------------------------------------------------------
/.github/workflows/lock.yml:
--------------------------------------------------------------------------------
1 | ---
2 | name: Lock Threads
3 |
4 | on:
5 | issues:
6 | types: [closed]
7 | pull_request:
8 | types: [closed]
9 | schedule:
10 | - cron: '0 0 * * *'
11 | workflow_dispatch:
12 |
13 | permissions:
14 | issues: write
15 | pull-requests: write
16 |
17 | concurrency:
18 | group: ${{ github.workflow }}
19 | cancel-in-progress: true
20 |
21 | jobs:
22 | action:
23 | runs-on: ubuntu-latest
24 | steps:
25 | - uses: dessant/lock-threads@7266a7ce5c1df01b1c6db85bf8cd86c737dadbe7 # v6
26 | with:
27 | issue-inactive-days: 0
28 | pr-inactive-days: 7
29 |
--------------------------------------------------------------------------------
/fixtures/pass/server-only-live/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@fixtures/pass/server-only-live",
3 | "private": true,
4 | "description": "defineLive can only be called from a server component",
5 | "scripts": {
6 | "test": "next build",
7 | "typegen": "next typegen"
8 | },
9 | "dependencies": {
10 | "next": "catalog:",
11 | "next-sanity": "workspace:*",
12 | "react": "catalog:",
13 | "react-dom": "catalog:"
14 | },
15 | "devDependencies": {
16 | "@repo/typescript-config": "workspace:*",
17 | "@types/node": "catalog:",
18 | "@types/react": "catalog:",
19 | "@types/react-dom": "catalog:"
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/fixtures/fail/server-only-live/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@fixtures/fail/server-only-live",
3 | "private": true,
4 | "description": "defineLive can not be called from a client component",
5 | "scripts": {
6 | "test": "next build && exit 1 || exit 0",
7 | "typegen": "next typegen"
8 | },
9 | "dependencies": {
10 | "next": "catalog:",
11 | "next-sanity": "workspace:*",
12 | "react": "catalog:",
13 | "react-dom": "catalog:"
14 | },
15 | "devDependencies": {
16 | "@repo/typescript-config": "workspace:*",
17 | "@types/node": "catalog:",
18 | "@types/react": "catalog:",
19 | "@types/react-dom": "catalog:"
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/packages/next-sanity/src/live/resolveCookiePerspective.ts:
--------------------------------------------------------------------------------
1 | import type {ClientPerspective} from '@sanity/client'
2 |
3 | import {perspectiveCookieName} from '@sanity/preview-url-secret/constants'
4 | import {cookies, draftMode} from 'next/headers'
5 |
6 | import {sanitizePerspective} from './utils'
7 |
8 | /**
9 | * @internal
10 | */
11 | export async function resolveCookiePerspective(): Promise> {
12 | return (await draftMode()).isEnabled
13 | ? (await cookies()).has(perspectiveCookieName)
14 | ? sanitizePerspective((await cookies()).get(perspectiveCookieName)?.value, 'drafts')
15 | : 'drafts'
16 | : 'published'
17 | }
18 |
--------------------------------------------------------------------------------
/packages/next-sanity/src/live/client-components/live-stream/SanityLiveStreamLazy.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * This file works around a new restriction in Next v15 where server components are not allowed
3 | * to use dynamic(() => import('...), {ssr: false})
4 | * only Client Components can set ssr: false.
5 | */
6 |
7 | import dynamic from 'next/dynamic'
8 |
9 | import type {SanityLiveStreamProps} from './SanityLiveStream'
10 |
11 | const SanityLiveStreamClientComponent = dynamic(() => import('./SanityLiveStream'), {ssr: false})
12 |
13 | export function SanityLiveStreamLazyClientComponent(props: SanityLiveStreamProps): React.ReactNode {
14 | return
15 | }
16 |
--------------------------------------------------------------------------------
/fixtures/fail/server-only-live/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.*
7 | .yarn/*
8 | !.yarn/patches
9 | !.yarn/plugins
10 | !.yarn/releases
11 | !.yarn/versions
12 |
13 | # testing
14 | /coverage
15 |
16 | # next.js
17 | /.next/
18 | /out/
19 |
20 | # production
21 | /build
22 |
23 | # misc
24 | .DS_Store
25 | *.pem
26 |
27 | # debug
28 | npm-debug.log*
29 | yarn-debug.log*
30 | yarn-error.log*
31 | .pnpm-debug.log*
32 |
33 | # env files (can opt-in for committing if needed)
34 | .env*
35 |
36 | # vercel
37 | .vercel
38 |
39 | # typescript
40 | *.tsbuildinfo
41 | next-env.d.ts
42 |
--------------------------------------------------------------------------------
/fixtures/pass/server-only-live/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.*
7 | .yarn/*
8 | !.yarn/patches
9 | !.yarn/plugins
10 | !.yarn/releases
11 | !.yarn/versions
12 |
13 | # testing
14 | /coverage
15 |
16 | # next.js
17 | /.next/
18 | /out/
19 |
20 | # production
21 | /build
22 |
23 | # misc
24 | .DS_Store
25 | *.pem
26 |
27 | # debug
28 | npm-debug.log*
29 | yarn-debug.log*
30 | yarn-error.log*
31 | .pnpm-debug.log*
32 |
33 | # env files (can opt-in for committing if needed)
34 | .env*
35 |
36 | # vercel
37 | .vercel
38 |
39 | # typescript
40 | *.tsbuildinfo
41 | next-env.d.ts
42 |
--------------------------------------------------------------------------------
/packages/next-sanity/src/live/utils.ts:
--------------------------------------------------------------------------------
1 | import {validateApiPerspective, type ClientPerspective} from '@sanity/client'
2 |
3 | /** @internal */
4 | export function sanitizePerspective(
5 | _perspective: unknown,
6 | fallback: 'drafts' | 'published',
7 | ): Exclude {
8 | const perspective =
9 | typeof _perspective === 'string' && _perspective.includes(',')
10 | ? _perspective.split(',')
11 | : _perspective
12 | try {
13 | validateApiPerspective(perspective)
14 | return perspective === 'raw' ? fallback : perspective
15 | } catch (err) {
16 | console.warn(`Invalid perspective:`, _perspective, perspective, err)
17 | return fallback
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/packages/next-sanity/MIGRATE-v11-to-v12.md:
--------------------------------------------------------------------------------
1 | ## Migrate
2 |
3 | ### Minimum required `sanity` is now `5.0.0`
4 |
5 | Upgrade to the latest v5 stable using the following command:
6 |
7 | ```bash
8 | npm install sanity@latest --save-exact
9 | ```
10 |
11 | [Read the changelog for v5](https://www.sanity.io/docs/changelog/fd3ab62e-9264-4e7b-825a-fd4f99abd481)
12 |
13 | ### Minimum required `react` and `react-dom` is now `19.2.3`
14 |
15 | [If you're still on React 18, you need to upgrade to React 19.2.3 or later.](https://react.dev/blog/2024/04/25/react-19-upgrade-guide)
16 |
17 | ### Minimum required `next` is now `16`
18 |
19 | v15 is no longer supported. [You need to upgrade to v16.](https://nextjs.org/docs/app/guides/upgrading/version-16)
20 |
--------------------------------------------------------------------------------
/packages/next-sanity/src/studio/NextStudioWithBridge.tsx:
--------------------------------------------------------------------------------
1 | import {NextStudio, type NextStudioProps} from 'next-sanity/studio/client-component'
2 | import {preloadModule} from 'react-dom'
3 |
4 | /**
5 | * Loads the bridge script the same way Sanity Studio does:
6 | * https://github.com/sanity-io/sanity/blob/bd5b1acb5015baaddd8d96c2abd1eaf579b3c904/packages/sanity/src/_internal/cli/server/renderDocument.tsx#L124-L139
7 | */
8 |
9 | const bridgeScript = 'https://core.sanity-cdn.com/bridge.js'
10 |
11 | export function NextStudioWithBridge(props: NextStudioProps): React.JSX.Element {
12 | preloadModule(bridgeScript, {as: 'script'})
13 |
14 | return (
15 | <>
16 |
17 |
18 | >
19 | )
20 | }
21 |
--------------------------------------------------------------------------------
/packages/next-sanity/src/studio/client-component/NextStudioLazy.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | *
3 | * If pages router supported `next/dynamic` imports (it wants `next/dynamic.js`),
4 | * or if turbopack in app router allowed `next/dynamic.js` (it doesn't yet)
5 | * we could use `dynamic(() => import('...), {ssr: false})` here.
6 | * Since we can't, we need to use a lazy import and Suspense ourself.
7 | */
8 |
9 | import {lazy, Suspense} from 'react'
10 |
11 | import type {NextStudioProps} from './NextStudio'
12 |
13 | const NextStudioClientComponent = lazy(() => import('./NextStudio'))
14 |
15 | export function NextStudioLazyClientComponent(props: NextStudioProps): React.ReactNode {
16 | return (
17 |
18 |
19 |
20 | )
21 | }
22 |
--------------------------------------------------------------------------------
/apps/mvp/sanity.config.ts:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import sharedConfig from '@repo/sanity-config'
4 | import {defineConfig} from 'sanity'
5 | import {presentationTool, type PreviewUrlResolverOptions} from 'sanity/presentation'
6 |
7 | const projectId = process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!
8 | const dataset = process.env.NEXT_PUBLIC_SANITY_DATASET!
9 |
10 | const previewMode = {
11 | enable: `${process.env.NEXT_PUBLIC_TEST_BASE_PATH || ''}/api/draft-mode/enable`,
12 | } satisfies PreviewUrlResolverOptions['previewMode']
13 |
14 | export default defineConfig({
15 | title: 'next-sanity',
16 | projectId,
17 | dataset,
18 |
19 | plugins: [
20 | presentationTool({
21 | previewUrl: {preview: `${process.env.NEXT_PUBLIC_TEST_BASE_PATH || ''}/`, previewMode},
22 | }),
23 | sharedConfig(),
24 | ],
25 | })
26 |
--------------------------------------------------------------------------------
/packages/next-sanity/src/live/client-components/live/RefreshOnMount.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | * Handles refreshing the page when the page is mounted,
3 | * in case the content changes at a high enough frequency that by
4 | * the time the page started streaming, and the component sets
5 | * up the EventSource connection, content might have changed.
6 | */
7 |
8 | import {useRouter} from 'next/navigation'
9 | import {useEffect, useReducer} from 'react'
10 |
11 | export default function RefreshOnMount(): null {
12 | const router = useRouter()
13 | const [mounted, mount] = useReducer(() => true, false)
14 |
15 | useEffect(() => {
16 | if (!mounted) {
17 | mount()
18 | router.refresh()
19 | }
20 | }, [mounted, router])
21 |
22 | return null
23 | }
24 | RefreshOnMount.displayName = 'RefreshOnMount'
25 |
--------------------------------------------------------------------------------
/packages/next-sanity/src/visual-editing/client-component/VisualEditingLazy.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | *
3 | * If pages router supported `next/dynamic` imports (it wants `next/dynamic.js`),
4 | * or if turbopack in app router allowed `next/dynamic.js` (it doesn't yet)
5 | * we could use `dynamic(() => import('...), {ssr: false})` here.
6 | * Since we can't, we need to use a lazy import and Suspense ourself.
7 | */
8 |
9 | import {lazy, Suspense} from 'react'
10 |
11 | import type {VisualEditingProps} from './VisualEditing'
12 |
13 | const VisualEditingClientComponent = lazy(() => import('./VisualEditing'))
14 |
15 | export function VisualEditingLazyClientComponent(props: VisualEditingProps): React.ReactNode {
16 | return (
17 |
18 |
19 |
20 | )
21 | }
22 |
--------------------------------------------------------------------------------
/fixtures/fail/server-only-live/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2017",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "noEmit": true,
9 | "esModuleInterop": true,
10 | "module": "esnext",
11 | "moduleResolution": "bundler",
12 | "resolveJsonModule": true,
13 | "isolatedModules": true,
14 | "jsx": "react-jsx",
15 | "incremental": true,
16 | "plugins": [
17 | {
18 | "name": "next"
19 | }
20 | ],
21 | "paths": {
22 | "@/*": ["./src/*"]
23 | }
24 | },
25 | "include": [
26 | "next-env.d.ts",
27 | "**/*.ts",
28 | "**/*.tsx",
29 | ".next/types/**/*.ts",
30 | ".next/dev/types/**/*.ts",
31 | "**/*.mts"
32 | ],
33 | "exclude": ["node_modules"]
34 | }
35 |
--------------------------------------------------------------------------------
/fixtures/pass/server-only-live/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2017",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "noEmit": true,
9 | "esModuleInterop": true,
10 | "module": "esnext",
11 | "moduleResolution": "bundler",
12 | "resolveJsonModule": true,
13 | "isolatedModules": true,
14 | "jsx": "react-jsx",
15 | "incremental": true,
16 | "plugins": [
17 | {
18 | "name": "next"
19 | }
20 | ],
21 | "paths": {
22 | "@/*": ["./src/*"]
23 | }
24 | },
25 | "include": [
26 | "next-env.d.ts",
27 | "**/*.ts",
28 | "**/*.tsx",
29 | ".next/types/**/*.ts",
30 | ".next/dev/types/**/*.ts",
31 | "**/*.mts"
32 | ],
33 | "exclude": ["node_modules"]
34 | }
35 |
--------------------------------------------------------------------------------
/packages/next-sanity/src/image/imageLoader.ts:
--------------------------------------------------------------------------------
1 | import type {ImageLoader} from 'next/image'
2 |
3 | /**
4 | * @alpha
5 | */
6 | export const imageLoader: ImageLoader = ({src, width, quality}) => {
7 | const url = new URL(src)
8 | url.searchParams.set('auto', 'format')
9 | if (!url.searchParams.has('fit')) {
10 | url.searchParams.set('fit', url.searchParams.has('h') ? 'min' : 'max')
11 | }
12 | if (url.searchParams.has('h') && url.searchParams.has('w')) {
13 | const originalHeight = parseInt(url.searchParams.get('h')!, 10)
14 | const originalWidth = parseInt(url.searchParams.get('w')!, 10)
15 | url.searchParams.set('h', Math.round((originalHeight / originalWidth) * width).toString())
16 | }
17 | url.searchParams.set('w', width.toString())
18 | if (quality) {
19 | url.searchParams.set('q', quality.toString())
20 | }
21 | return url.href
22 | }
23 |
--------------------------------------------------------------------------------
/packages/next-sanity/src/live.server-only.ts:
--------------------------------------------------------------------------------
1 | import type {
2 | DefineSanityLiveOptions,
3 | DefinedSanityFetchType,
4 | DefinedSanityLiveProps,
5 | DefinedSanityLiveStreamType,
6 | } from './live/defineLive'
7 |
8 | /**
9 | * @public
10 | */
11 | export function defineLive(_config: DefineSanityLiveOptions): {
12 | sanityFetch: DefinedSanityFetchType
13 | SanityLive: React.ComponentType
14 | SanityLiveStream: DefinedSanityLiveStreamType
15 | } {
16 | throw new Error('defineLive can only be used in React Server Components')
17 | }
18 |
19 | /**
20 | * @public
21 | */
22 | export type {
23 | DefineSanityLiveOptions,
24 | DefinedSanityFetchType,
25 | DefinedSanityLiveProps,
26 | DefinedSanityLiveStreamType,
27 | }
28 |
29 | // @TODO deprecate, so that we can simplify this branching and just use `import 'server-only'` instead
30 | export {isCorsOriginError} from './isCorsOriginError'
31 |
--------------------------------------------------------------------------------
/.oxlintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "./node_modules/oxlint/configuration_schema.json",
3 | "ignorePatterns": ["**/next-env.d.ts"],
4 | "plugins": [
5 | "react",
6 | "react-perf",
7 | "nextjs",
8 | "unicorn",
9 | "typescript",
10 | "oxc",
11 | "import",
12 | "node",
13 | "import"
14 | ],
15 | "categories": {
16 | "correctness": "error",
17 | "suspicious": "warn",
18 | "perf": "error"
19 | },
20 | "rules": {
21 | "typescript/ban-ts-comment": "error",
22 | "typescript/prefer-ts-expect-error": "error",
23 | "react-in-jsx-scope": "off",
24 | "react/rules-of-hooks": "error",
25 | "react/exhaustive-deps": "error",
26 | "react/jsx-key": "error",
27 | "eslint/no-console": ["error", {"allow": ["warn", "error"]}],
28 | "import/no-unassigned-import": ["error", {"allow": ["**/*.css"]}],
29 | "jsx-no-new-function-as-prop": "off",
30 | "jsx-no-new-object-as-prop": "off"
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/packages/next-sanity/src/live/hooks/useIsPresentationTool.ts:
--------------------------------------------------------------------------------
1 | import {useDraftModeEnvironment} from './useDraftMode'
2 |
3 | /**
4 | * Detects if the application is being previewed inside Sanity Presentation Tool.
5 | * Presentation Tool can open the application in an iframe, or in a new window.
6 | * When in this context there are some UI you usually don't want to show,
7 | * for example a Draft Mode toggle, or a "Viewing draft content" indicators, these are unnecessary and add clutter to
8 | * the editorial experience.
9 | * The hook returns `null` initially, when it's not yet sure if the application is running inside Presentation Tool,
10 | * then `true` if it is, and `false` otherwise.
11 | * @public
12 | */
13 | export function useIsPresentationTool(): boolean | null {
14 | const environment = useDraftModeEnvironment()
15 | return environment === 'checking'
16 | ? null
17 | : environment === 'presentation-iframe' || environment === 'presentation-window'
18 | }
19 |
--------------------------------------------------------------------------------
/packages/next-sanity/src/studio/NextStudioNoScript.tsx:
--------------------------------------------------------------------------------
1 | const styles: Record<'outer' | 'inner', React.CSSProperties> = {
2 | outer: {
3 | position: 'absolute',
4 | top: 0,
5 | right: 0,
6 | left: 0,
7 | bottom: 0,
8 | background: '#fff',
9 | zIndex: 1,
10 | },
11 | inner: {
12 | position: 'absolute',
13 | top: '50%',
14 | left: '50%',
15 | transform: 'translate(-50%, -50%)',
16 | textAlign: 'center',
17 | fontFamily: 'helvetica, arial, sans-serif',
18 | },
19 | }
20 |
21 | /** @internal */
22 | export const NextStudioNoScript = (): React.JSX.Element => (
23 |
34 | )
35 |
--------------------------------------------------------------------------------
/packages/next-sanity/src/live/client-components/live/RefreshOnFocus.tsx:
--------------------------------------------------------------------------------
1 | import {useRouter} from 'next/navigation'
2 | import {useEffect} from 'react'
3 |
4 | const focusThrottleInterval = 5_000
5 |
6 | export default function RefreshOnFocus(): null {
7 | const router = useRouter()
8 |
9 | useEffect(() => {
10 | const controller = new AbortController()
11 | let nextFocusRevalidatedAt = 0
12 | const callback = () => {
13 | const now = Date.now()
14 | if (now > nextFocusRevalidatedAt && document.visibilityState !== 'hidden') {
15 | router.refresh()
16 | nextFocusRevalidatedAt = now + focusThrottleInterval
17 | }
18 | }
19 | const {signal} = controller
20 | document.addEventListener('visibilitychange', callback, {passive: true, signal})
21 | window.addEventListener('focus', callback, {passive: true, signal})
22 | return () => controller.abort()
23 | }, [router])
24 |
25 | return null
26 | }
27 | RefreshOnFocus.displayName = 'RefreshOnFocus'
28 |
--------------------------------------------------------------------------------
/apps/mvp/app/(website)/DebugStatus.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import {
4 | useDraftModeEnvironment,
5 | useDraftModePerspective,
6 | useIsLivePreview,
7 | useIsPresentationTool,
8 | } from 'next-sanity/hooks'
9 |
10 | export function DebugStatus() {
11 | const environment = useDraftModeEnvironment()
12 | const perspective = useDraftModePerspective()
13 | const isLivePreview = useIsLivePreview()
14 | const isPresentationTool = useIsPresentationTool()
15 |
16 | // oxlint-disable-next-line no-console
17 | console.log({environment, perspective, isLivePreview, isPresentationTool})
18 |
19 | return (
20 | <>
21 | Environment: {environment}
22 | Perspective: {JSON.stringify(perspective)}
23 | Is Live Preview: {isLivePreview === null ? 'Maybe' : isLivePreview ? 'Yes' : 'No'}
24 |
25 | Is Presentation Tool:{' '}
26 | {isPresentationTool === null ? 'Maybe' : isPresentationTool ? 'Yes' : 'No'}
27 |
28 | >
29 | )
30 | }
31 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "next-sanity-monorepo",
3 | "version": "0.0.0",
4 | "private": true,
5 | "scripts": {
6 | "build": "turbo build --filter=./packages/*",
7 | "bump:canaries": "pnpm -r up @sanity/visual-editing@canary && pnpm dedupe",
8 | "bump:latest": "pnpm -r up @sanity/visual-editing@latest && pnpm dedupe",
9 | "dev": "turbo dev",
10 | "format": "oxfmt && pnpm lint --fix",
11 | "lint": "oxlint --type-check --type-aware --report-unused-disable-directives --deny-warnings",
12 | "prestart": "turbo build",
13 | "start": "turbo watch build start",
14 | "test": "turbo test",
15 | "release": "changeset publish",
16 | "typegen": "turbo typegen"
17 | },
18 | "dependencies": {
19 | "@changesets/changelog-github": "^0.5.2",
20 | "@changesets/cli": "^2.29.8",
21 | "oxfmt": "^0.19.0",
22 | "oxlint": "^1.34.0",
23 | "oxlint-tsgolint": "^0.9.2",
24 | "sanity": "catalog:",
25 | "styled-components": "catalog:",
26 | "turbo": "^2.6.3"
27 | },
28 | "packageManager": "pnpm@10.26.1"
29 | }
30 |
--------------------------------------------------------------------------------
/apps/static/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "static",
3 | "private": true,
4 | "type": "module",
5 | "imports": {
6 | "#groqd": "./groqd-client.ts"
7 | },
8 | "scripts": {
9 | "analyze": "next experimental-analyze",
10 | "build": "next build",
11 | "dev": "next -p 3001",
12 | "start": "pnpx serve@latest out -p 3001",
13 | "typegen": "next typegen && sanity schema extract && sanity typegen generate"
14 | },
15 | "dependencies": {
16 | "@repo/sanity-config": "workspace:*",
17 | "@sanity/image-url": "catalog:",
18 | "@sanity/vision": "catalog:",
19 | "groqd": "catalog:",
20 | "next": "catalog:",
21 | "next-sanity": "workspace:*",
22 | "react": "catalog:",
23 | "react-dom": "catalog:",
24 | "sanity": "catalog:"
25 | },
26 | "devDependencies": {
27 | "@next/env": "catalog:",
28 | "@repo/typescript-config": "workspace:*",
29 | "@tailwindcss/postcss": "^4.1.18",
30 | "@types/node": "catalog:",
31 | "@types/react": "catalog:",
32 | "postcss": "^8.5.6",
33 | "tailwindcss": "^4.1.18"
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/packages/next-sanity/MIGRATE-v6-to-v7.md:
--------------------------------------------------------------------------------
1 | ## Migrate
2 |
3 | ### `LiveQueryProvider` is now already lazy loaded
4 |
5 | If you previously imported it like this:
6 |
7 | ```tsx
8 | 'use client'
9 |
10 | import dynamic from 'next/dynamic'
11 | const LiveQueryProvider = dynamic(() => import('next-sanity/preview'))
12 | // or like this:
13 | import {lazy} from 'react'
14 | const LiveQueryProvider = lazy(() => import('next-sanity/preview'))
15 | ```
16 |
17 | Then you should now import it like this:
18 |
19 | ```tsx
20 | 'use client'
21 |
22 | import {LiveQueryProvider} from 'next-sanity/preview'
23 | ```
24 |
25 | Otherwise you'll see an error like this:
26 |
27 | ```
28 | Error: Element type is invalid. Received a promise that resolves to: [object Object]. Lazy element type must resolve to a class or function. Did you wrap a component in React.lazy() more than once?
29 | ```
30 |
31 | ### The deprecated `next-sanity/studio/head` export has been removed
32 |
33 | Migrate to using `export {metadata} from 'next-sanity/studio/metadata'` and `export {viewport} from 'next-sanity/studio/viewport'` in your `page.tsx` instead.
34 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2025 Sanity.io
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/apps/mvp/app/api/revalidate-tag/route.ts:
--------------------------------------------------------------------------------
1 | import {parseBody} from 'next-sanity/webhook'
2 | import {revalidateTag} from 'next/cache'
3 | import {type NextRequest, NextResponse} from 'next/server'
4 |
5 | // Triggers a revalidation of the static data in the example above
6 | export async function POST(req: NextRequest): Promise {
7 | try {
8 | const {body, isValidSignature} = await parseBody<{
9 | _type: string
10 | _id: string
11 | slug?: string | undefined
12 | }>(req, process.env.SANITY_REVALIDATE_SECRET)
13 | if (!isValidSignature) {
14 | const message = 'Invalid signature'
15 | return new Response(message, {status: 401})
16 | }
17 |
18 | if (!body?._type) {
19 | return new Response('Bad Request', {status: 400})
20 | }
21 |
22 | await Promise.all(
23 | [body.slug, body._type, body._id].map(
24 | (tag) => typeof tag === 'string' && revalidateTag(tag, 'max'),
25 | ),
26 | )
27 | return NextResponse.json({...body, router: 'app'})
28 | } catch (err: any) {
29 | console.error(err)
30 | return new Response(err.message, {status: 500})
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/pnpm-workspace.yaml:
--------------------------------------------------------------------------------
1 | packages:
2 | - apps/*
3 | - fixtures/*/*
4 | - packages/*
5 |
6 | catalog:
7 | '@next/env': 16.1.1-canary.0
8 | '@sanity/client': ^7.13.2
9 | '@sanity/image-url': ^2.0.2
10 | '@sanity/preview-url-secret': ^4.0.1
11 | '@sanity/tsconfig': ^2.1.0
12 | '@sanity/vision': 5.0.1
13 | '@types/node': ^24.10.4
14 | '@types/react': ^19.2.7
15 | '@types/react-dom': ^19.2.3
16 | '@types/react-is': ^19.2.0
17 | groqd: ^1.7.1
18 | next: 16.1.1-canary.0
19 | react: ^19.2.3
20 | react-dom: ^19.2.3
21 | react-is: ^19.2.3
22 | sanity: 5.0.1
23 | styled-components: ^6.1.19
24 | typescript: 5.9.3
25 |
26 | linkWorkspacePackages: deep
27 |
28 | overrides:
29 | '@next/env': 'catalog:'
30 | '@types/react': 'catalog:'
31 | '@types/react-dom': 'catalog:'
32 | '@types/react-is': 'catalog:'
33 | next: 'catalog:'
34 | react: 'catalog:'
35 | react-dom: 'catalog:'
36 | react-is: 'catalog:'
37 | sanity: 'catalog:'
38 | styled-components: npm:@sanity/styled-components@6.1.23
39 |
40 | peerDependencyRules:
41 | allowAny:
42 | - react
43 | - react-dom
44 |
45 | preferWorkspacePackages: true
46 |
--------------------------------------------------------------------------------
/apps/mvp/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "mvp",
3 | "private": true,
4 | "type": "module",
5 | "imports": {
6 | "#groqd": "./groqd-client.ts"
7 | },
8 | "scripts": {
9 | "analyze": "next experimental-analyze",
10 | "build": "next build --profile && sanity manifest extract --path public/studio/static",
11 | "dev": "next dev",
12 | "start": "next start",
13 | "type-check": "next typegen && tsc --noEmit",
14 | "typegen": "sanity schema extract && sanity typegen generate"
15 | },
16 | "dependencies": {
17 | "@repo/sanity-config": "workspace:*",
18 | "@sanity/image-url": "catalog:",
19 | "@sanity/preview-url-secret": "catalog:",
20 | "@sanity/vision": "catalog:",
21 | "groqd": "catalog:",
22 | "next": "catalog:",
23 | "next-sanity": "workspace:*",
24 | "react": "catalog:",
25 | "react-dom": "catalog:",
26 | "sanity": "catalog:"
27 | },
28 | "devDependencies": {
29 | "@next/env": "catalog:",
30 | "@repo/typescript-config": "workspace:*",
31 | "@tailwindcss/postcss": "^4.1.18",
32 | "@types/node": "catalog:",
33 | "@types/react": "catalog:",
34 | "@types/react-dom": "catalog:",
35 | "postcss": "^8.5.6",
36 | "tailwindcss": "^4.1.18",
37 | "typescript": "catalog:"
38 | },
39 | "engines": {
40 | "node": "20 || 22 || 24"
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/packages/sanity-config/src/schemas/author.ts:
--------------------------------------------------------------------------------
1 | import {defineType} from 'sanity'
2 |
3 | export default defineType({
4 | name: 'author',
5 | title: 'Author',
6 | type: 'document',
7 | fields: [
8 | {
9 | name: 'name',
10 | title: 'Name',
11 | type: 'string',
12 | },
13 | {
14 | name: 'slug',
15 | title: 'Slug',
16 | type: 'slug',
17 | options: {
18 | source: 'name',
19 | maxLength: 96,
20 | },
21 | },
22 | {
23 | name: 'image',
24 | title: 'Image',
25 | type: 'image',
26 | fields: [
27 | {
28 | name: 'alt',
29 | type: 'string',
30 | title: 'Alternative text',
31 | description: 'Important for SEO and accessiblity.',
32 | },
33 | ],
34 | options: {
35 | hotspot: true,
36 | aiAssist: {
37 | imageDescriptionField: 'alt',
38 | },
39 | },
40 | },
41 | {
42 | name: 'bio',
43 | title: 'Bio',
44 | type: 'array',
45 | of: [
46 | {
47 | title: 'Block',
48 | type: 'block',
49 | styles: [{title: 'Normal', value: 'normal'}],
50 | lists: [],
51 | },
52 | ],
53 | },
54 | ],
55 | preview: {
56 | select: {
57 | title: 'name',
58 | media: 'image',
59 | },
60 | },
61 | })
62 |
--------------------------------------------------------------------------------
/packages/next-sanity/src/image/Image.tsx:
--------------------------------------------------------------------------------
1 | import NextImage, {type ImageProps as NextImageProps} from 'next/image'
2 |
3 | import {imageLoader} from './imageLoader'
4 |
5 | /**
6 | * @alpha
7 | */
8 | export interface ImageProps extends Omit {
9 | /**
10 | * The `loader` prop is not supported on `Image` components. Use `next/image` directly to use a custom loader.
11 | */
12 | loader?: never
13 | /**
14 | * Must be a string that is a valid URL to an image on the Sanity Image CDN.
15 | */
16 | src: string
17 | }
18 |
19 | /**
20 | * @alpha
21 | */
22 | export function Image(props: ImageProps): React.JSX.Element {
23 | const {loader, src, ...rest} = props
24 | if (loader) {
25 | throw new TypeError(
26 | 'The `loader` prop is not supported on `Image` components. Use `next/image` directly to use a custom loader.',
27 | )
28 | }
29 | let srcUrl: URL
30 | try {
31 | srcUrl = new URL(src)
32 | if (props.height) {
33 | srcUrl.searchParams.set('h', `${props.height}`)
34 | }
35 | if (props.width) {
36 | srcUrl.searchParams.set('w', `${props.width}`)
37 | }
38 | } catch (err) {
39 | throw new TypeError('The `src` prop must be a valid URL to an image on the Sanity Image CDN.', {
40 | cause: err,
41 | })
42 | }
43 | return
44 | }
45 |
--------------------------------------------------------------------------------
/packages/next-sanity/src/studio/client-component/createHashHistoryForStudio.ts:
--------------------------------------------------------------------------------
1 | // oxlint-disable unbound-method
2 | import {createHashHistory, type History, type Listener} from 'history'
3 |
4 | /** @internal */
5 | export function createHashHistoryForStudio(): History {
6 | const history = createHashHistory()
7 | return {
8 | get action() {
9 | return history.action
10 | },
11 | get location() {
12 | return history.location
13 | },
14 | get createHref() {
15 | return history.createHref
16 | },
17 | get push() {
18 | return history.push
19 | },
20 | get replace() {
21 | return history.replace
22 | },
23 | get go() {
24 | return history.go
25 | },
26 | get back() {
27 | return history.back
28 | },
29 | get forward() {
30 | return history.forward
31 | },
32 | get block() {
33 | return history.block
34 | },
35 | // Overriding listen to workaround a problem where native history provides history.listen(location => void), but the npm package is history.listen(({action, location}) => void)
36 | listen(listener: Listener) {
37 | // return history.listen(({ action, location }) => {
38 | return history.listen(({location}) => {
39 | // console.debug('history.listen', action, location)
40 | // @ts-expect-error -- working around a bug? in studio
41 | listener(location)
42 | })
43 | },
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/packages/next-sanity/src/live/hooks/useIsLivePreview.ts:
--------------------------------------------------------------------------------
1 | import {useDraftModeEnvironment} from './useDraftMode'
2 |
3 | /**
4 | * Detects if the application is considered to be in a "Live Preview" mode.
5 | * Live Preview means that the application is either:
6 | * - being previewed inside Sanity Presentation Tool
7 | * - being previewed in Draft Mode, with a `browserToken` given to `defineLive`, also known as "Standalone Live Preview'"
8 | * When in Live Preview mode, you typically want UI to update as new content comes in, without any manual intervention.
9 | * This is very different from Live Production mode, where you usually want to delay updates that might cause layout shifts,
10 | * to avoid interrupting the user that is consuming your content.
11 | * This hook lets you adapt to this difference, making sure production doesn't cause layout shifts that worsen the UX,
12 | * while in Live Preview mode layout shift is less of an issue and it's better for the editorial experience to auto refresh in real time.
13 | *
14 | * The hook returns `null` initially, to signal it doesn't yet know if it's live previewing or not.
15 | * Then `true` if it is, and `false` otherwise.
16 | * @public
17 | */
18 | export function useIsLivePreview(): boolean | null {
19 | const environment = useDraftModeEnvironment()
20 | return environment === 'checking'
21 | ? null
22 | : environment === 'presentation-iframe' ||
23 | environment === 'presentation-window' ||
24 | environment === 'live'
25 | }
26 |
--------------------------------------------------------------------------------
/packages/next-sanity/src/live/server-actions/index.ts:
--------------------------------------------------------------------------------
1 | 'use server'
2 |
3 | import type {ClientPerspective, SyncTag} from '@sanity/client'
4 |
5 | import {perspectiveCookieName} from '@sanity/preview-url-secret/constants'
6 | import {revalidateTag} from 'next/cache'
7 | import {cookies, draftMode} from 'next/headers'
8 |
9 | import {sanitizePerspective} from '../utils'
10 |
11 | export async function revalidateSyncTags(tags: SyncTag[]): Promise {
12 | revalidateTag('sanity:fetch-sync-tags', 'max')
13 |
14 | for (const _tag of tags) {
15 | const tag = `sanity:${_tag}`
16 | revalidateTag(tag, {expire: 0})
17 | // oxlint-disable-next-line no-console
18 | console.log(` revalidated tag: ${tag}`)
19 | }
20 | }
21 |
22 | export async function setPerspectiveCookie(perspective: ClientPerspective): Promise {
23 | if (!(await draftMode()).isEnabled) {
24 | // throw new Error('Draft mode is not enabled, setting perspective cookie is not allowed')
25 | return
26 | }
27 | const sanitizedPerspective = sanitizePerspective(perspective, 'drafts')
28 | if (perspective !== sanitizedPerspective) {
29 | throw new Error(`Invalid perspective`, {cause: perspective})
30 | }
31 |
32 | ;(await cookies()).set(
33 | perspectiveCookieName,
34 | Array.isArray(sanitizedPerspective) ? sanitizedPerspective.join(',') : sanitizedPerspective,
35 | {
36 | httpOnly: true,
37 | path: '/',
38 | secure: true,
39 | sameSite: 'none',
40 | },
41 | )
42 | }
43 |
--------------------------------------------------------------------------------
/packages/next-sanity/MIGRATE-v5-to-v6.md:
--------------------------------------------------------------------------------
1 | ## Migrate
2 |
3 | ### Next v14 is required
4 |
5 | You'll need to upgrade to Next v14 to use `next-sanity` v6.
6 |
7 | ### Viewport metadata changed in Next v14
8 |
9 | If you're embedding a `sanity` studio, you'll need to add the new `viewport` export where you're currently exporting `metadata` with App Router:
10 |
11 | ```diff
12 | export {metadata} from 'next-sanity/studio/metadata'
13 | +export {viewport} from 'next-sanity/studio/viewport'
14 | ```
15 |
16 | For Pages Router:
17 |
18 | ```diff
19 | // ./pages/studio/[[...index]].tsx
20 | import Head from 'next/head'
21 | import {NextStudio} from 'next-sanity/studio'
22 | import {metadata} from 'next-sanity/studio/metadata'
23 |
24 | import config from '../../sanity.config'
25 |
26 | export default function StudioPage() {
27 | return (
28 | <>
29 |
30 | {Object.entries(metadata).map(([key, value]) => (
31 |
32 | ))}
33 | +
34 |
35 |
36 | >
37 | )
38 | }
39 | ```
40 |
41 | ### Embedded Studios should be mounted by the App Router
42 |
43 | If you're currently mounting the Studio on a Pages Router route, you should move to an [App Router route instead.](https://github.com/sanity-io/next-sanity?tab=readme-ov-file#studio-route-with-app-router)
44 | You don't have to migrate the rest of your routes to App Router, just the one that mounts the Studio.
45 |
--------------------------------------------------------------------------------
/apps/mvp/app/(website)/layout.tsx:
--------------------------------------------------------------------------------
1 | 'use cache'
2 |
3 | import '../globals.css'
4 | import {
5 | // cookies,
6 | draftMode,
7 | } from 'next/headers'
8 | import {VisualEditing} from 'next-sanity/visual-editing'
9 |
10 | import {DebugStatus} from './DebugStatus'
11 | import {FormStatusLabel} from './FormStatus'
12 | import {SanityLive} from './live'
13 | import {RefreshButton} from './RefreshButton'
14 | // import {resolvePerspectiveFromCookies} from 'next-sanity/experimental/live'
15 | // import {Suspense} from 'react'
16 |
17 | async function toggleDraftMode() {
18 | 'use server'
19 |
20 | const draft = await draftMode()
21 |
22 | if (draft.isEnabled) {
23 | draft.disable()
24 | } else {
25 | draft.enable()
26 | }
27 | }
28 |
29 | export default async function RootLayout({children}: {children: React.ReactNode}) {
30 | const isDraftMode = (await draftMode()).isEnabled
31 | return (
32 |
33 |
34 |
35 |
36 |
Draft mode: {isDraftMode ? 'On' : 'Off'}
37 | {isDraftMode &&
}
38 |
43 |
44 | {children}
45 |
46 | {isDraftMode && }
47 |
48 |
49 |
50 | )
51 | }
52 |
--------------------------------------------------------------------------------
/apps/static/app/page.tsx:
--------------------------------------------------------------------------------
1 | import {unstable__adapter, unstable__environment} from 'next-sanity'
2 | import Link from 'next/link'
3 |
4 | import PostsLayout, {type PostsLayoutProps, query} from '@/app/PostsLayout'
5 | import {sanityFetch} from '@/app/sanity.fetch'
6 |
7 | export const dynamic = 'force-static'
8 |
9 | export default async function IndexPage() {
10 | const posts = await sanityFetch({query})
11 |
12 | return (
13 | <>
14 |
19 |
22 |
23 |
24 |
25 | Visual Editing Only
26 |
27 |
28 |
29 |
30 |
31 |
32 |
36 | Open Studio
37 |
38 |
39 | >
40 | )
41 | }
42 |
--------------------------------------------------------------------------------
/apps/static/app/Image.tsx:
--------------------------------------------------------------------------------
1 | import {createImageUrlBuilder} from '@sanity/image-url'
2 | import {Image as SanityImage, type ImageProps} from 'next-sanity/image'
3 |
4 | const imageBuilder = createImageUrlBuilder({
5 | projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!,
6 | dataset: process.env.NEXT_PUBLIC_SANITY_DATASET!,
7 | })
8 |
9 | export const urlForImage = (source: Parameters<(typeof imageBuilder)['image']>[0]) =>
10 | imageBuilder.image(source)
11 |
12 | export function Image(
13 | props: Omit & {
14 | src: {
15 | _key?: string | null
16 | _type?: 'image' | (string & {})
17 | asset: {
18 | _type: 'reference'
19 | _ref: string
20 | }
21 | crop: {
22 | top: number
23 | bottom: number
24 | left: number
25 | right: number
26 | } | null
27 | hotspot: {
28 | x: number
29 | y: number
30 | height: number
31 | width: number
32 | } | null
33 | alt?: string | undefined
34 | }
35 | alt?: string
36 | },
37 | ) {
38 | const {src, ...rest} = props
39 | const imageBuilder = urlForImage(props.src)
40 | if (props.width) {
41 | imageBuilder.width(typeof props.width === 'string' ? parseInt(props.width, 10) : props.width)
42 | }
43 | if (props.height) {
44 | imageBuilder.height(
45 | typeof props.height === 'string' ? parseInt(props.height, 10) : props.height,
46 | )
47 | }
48 |
49 | return (
50 |
55 | )
56 | }
57 |
--------------------------------------------------------------------------------
/apps/mvp/app/(website)/Image.tsx:
--------------------------------------------------------------------------------
1 | import {createImageUrlBuilder} from '@sanity/image-url'
2 | import {Image as SanityImage, type ImageProps} from 'next-sanity/image'
3 |
4 | const imageBuilder = createImageUrlBuilder({
5 | projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!,
6 | dataset: process.env.NEXT_PUBLIC_SANITY_DATASET!,
7 | })
8 |
9 | export const urlForImage = (source: Parameters<(typeof imageBuilder)['image']>[0]) =>
10 | imageBuilder.image(source)
11 |
12 | export function Image(
13 | props: Omit & {
14 | src: {
15 | _key?: string | null
16 | _type?: 'image' | (string & {})
17 | asset: {
18 | _type: 'reference'
19 | _ref: string
20 | }
21 | crop: {
22 | top: number
23 | bottom: number
24 | left: number
25 | right: number
26 | } | null
27 | hotspot: {
28 | x: number
29 | y: number
30 | height: number
31 | width: number
32 | } | null
33 | alt?: string | undefined
34 | }
35 | alt?: string
36 | },
37 | ) {
38 | const {src, ...rest} = props
39 | const imageBuilder = urlForImage(props.src)
40 | if (props.width) {
41 | imageBuilder.width(typeof props.width === 'string' ? parseInt(props.width, 10) : props.width)
42 | }
43 | if (props.height) {
44 | imageBuilder.height(
45 | typeof props.height === 'string' ? parseInt(props.height, 10) : props.height,
46 | )
47 | }
48 |
49 | return (
50 |
55 | )
56 | }
57 |
--------------------------------------------------------------------------------
/.github/renovate.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json",
3 | "extends": ["github>sanity-io/renovate-config", "github>sanity-io/renovate-config:studio-v3"],
4 | "packageRules": [
5 | {
6 | "group": {
7 | "semanticCommitType": "chore"
8 | },
9 | "matchDepTypes": [
10 | "dependencies",
11 | "devDependencies",
12 | "engines",
13 | "optionalDependencies",
14 | "peerDependencies"
15 | ],
16 | "matchManagers": ["npm"],
17 | "semanticCommitType": "chore",
18 | "description": "Group all dependencies from the app directory",
19 | "matchFileNames": ["apps/**/package.json"],
20 | "groupName": "App dependencies"
21 | },
22 | {
23 | "description": "Test against canary versions of next.js",
24 | "semanticCommitType": "chore",
25 | "matchDepTypes": ["pnpm.catalog.default"],
26 | "matchPackageNames": ["@next/env", "next"],
27 | "followTag": "canary",
28 | "groupName": "next-canary"
29 | },
30 | {
31 | "matchPackageNames": [
32 | "@sanity/client",
33 | "@sanity/comlink",
34 | "@sanity/image-url",
35 | "@sanity/presentation-comlink",
36 | "@sanity/preview-url-secret",
37 | "@sanity/webhook",
38 | "@sanity/visual-editing",
39 | "@portabletext/react",
40 | "@portabletext/types",
41 | "dequal",
42 | "groq",
43 | "sanity"
44 | ],
45 | "rangeStrategy": "bump",
46 | "semanticCommitType": "fix"
47 | }
48 | ],
49 | "ignorePresets": [":ignoreModulesAndTests", "github>sanity-io/renovate-config:group-non-major"]
50 | }
51 |
--------------------------------------------------------------------------------
/packages/next-sanity/src/webhook/index.ts:
--------------------------------------------------------------------------------
1 | import type {NextRequest} from 'next/server'
2 |
3 | import {isValidSignature, SIGNATURE_HEADER_NAME} from '@sanity/webhook'
4 |
5 | /** @public */
6 | export type ParsedBody = {
7 | /**
8 | * If a secret is given then it returns a boolean. If no secret is provided then no validation is done on the signature, and it'll return `null`
9 | */
10 | isValidSignature: boolean | null
11 | body: T | null
12 | }
13 |
14 | /** @public */
15 | interface SanityDocument {
16 | _id: string
17 | _type: string
18 | _createdAt: string
19 | _updatedAt: string
20 | _rev: string
21 | [key: string]: unknown
22 | }
23 |
24 | /**
25 | * Handles parsing the body JSON, and validating its signature. Also waits for Content Lake eventual consistency so you can run your queries
26 | * without worrying about getting stale data.
27 | * @public
28 | */
29 | export async function parseBody(
30 | req: NextRequest,
31 | secret?: string,
32 | waitForContentLakeEventualConsistency = true,
33 | ): Promise> {
34 | const signature = req.headers.get(SIGNATURE_HEADER_NAME)
35 | if (!signature) {
36 | console.error('Missing signature header')
37 | return {body: null, isValidSignature: null}
38 | }
39 |
40 | const body = await req.text()
41 | const validSignature = secret ? await isValidSignature(body, signature, secret.trim()) : null
42 |
43 | if (validSignature !== false && waitForContentLakeEventualConsistency) {
44 | await new Promise((resolve) => setTimeout(resolve, 3000))
45 | }
46 |
47 | return {
48 | body: body.trim() ? JSON.parse(body) : null,
49 | isValidSignature: validSignature,
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/packages/next-sanity/src/studio/head.tsx:
--------------------------------------------------------------------------------
1 | import type {Metadata, Viewport} from 'next'
2 |
3 | /**
4 | * In router segments (`/app/studio/[[...index]]/page.tsx`):
5 | * ```tsx
6 | * // If you don't want to change any defaults you can just re-export the viewport config directly:
7 | * export {viewport} from 'next-sanity/studio'
8 | *
9 | * // To customize the viewport config, spread it on the export:
10 | * import {viewport as studioViewport} from 'next-sanity/studio'
11 | * import type { Viewport } from 'next'
12 | *
13 | * export const viewport: Viewport = {
14 | * ...studioViewport,
15 | * // Overrides the viewport to resize behavior
16 | * interactiveWidget: 'resizes-content'
17 | * })
18 | * ```
19 | * @public
20 | */
21 | export const viewport = {
22 | width: 'device-width' as const,
23 | initialScale: 1 as const,
24 | // Studio implements display cutouts CSS (The iPhone Notch ™ ) and needs `viewport-fit=covered` for it to work correctly
25 | viewportFit: 'cover',
26 | } satisfies Viewport
27 |
28 | /**
29 | * In router segments (`/app/studio/[[...index]]/page.tsx`):
30 | * ```tsx
31 | * // If you don't want to change any defaults you can just re-export the metadata directly:
32 | * export {metadata} from 'next-sanity/studio'
33 | *
34 | * // To customize the metadata, spread it on the export:
35 | * import {metadata as studioMetadata} from 'next-sanity/studio'
36 | * import type { Metadata } from 'next'
37 | *
38 | * export const metadata: Metadata = {
39 | * ...studioMetadata,
40 | * // Set another title
41 | * title: 'My Studio',
42 | * })
43 | * ```
44 | * @public
45 | */
46 | export const metadata = {
47 | referrer: 'same-origin' as const,
48 | robots: 'noindex' as const,
49 | } satisfies Metadata
50 |
--------------------------------------------------------------------------------
/packages/next-sanity/src/visual-editing/VisualEditing.tsx:
--------------------------------------------------------------------------------
1 | import type {VisualEditingProps} from 'next-sanity/visual-editing/client-component'
2 |
3 | import VisualEditingComponent from 'next-sanity/visual-editing/client-component'
4 |
5 | /**
6 | * @public
7 | */
8 | export function VisualEditing(props: VisualEditingProps): React.ReactElement {
9 | let autoBasePath: string | undefined
10 | if (typeof props.basePath !== 'string') {
11 | try {
12 | autoBasePath = process.env['__NEXT_ROUTER_BASEPATH']
13 | if (autoBasePath) {
14 | // oxlint-disable-next-line no-console
15 | console.log(
16 | `Detected next basePath as ${JSON.stringify(autoBasePath)} by reading "process.env.__NEXT_ROUTER_BASEPATH". If this is incorrect then you can set it manually with the basePath prop on the component.`,
17 | )
18 | }
19 | } catch (err) {
20 | console.error('Failed detecting basePath', err)
21 | }
22 | }
23 | let autoTrailingSlash: boolean | undefined
24 | if (typeof props.trailingSlash !== 'boolean') {
25 | try {
26 | autoTrailingSlash = Boolean(process.env['__NEXT_TRAILING_SLASH'])
27 | if (autoTrailingSlash) {
28 | // oxlint-disable-next-line no-console
29 | console.log(
30 | `Detected next trailingSlash as ${JSON.stringify(autoTrailingSlash)} by reading "process.env.__NEXT_TRAILING_SLASH". If this is incorrect then you can set it manually with the trailingSlash prop on the component.`,
31 | )
32 | }
33 | } catch (err) {
34 | console.error('Failed detecting trailingSlash', err)
35 | }
36 | }
37 | return (
38 |
43 | )
44 | }
45 |
46 | export type {VisualEditingProps} from 'next-sanity/visual-editing/client-component'
47 |
--------------------------------------------------------------------------------
/packages/sanity-config/src/schemas/post.ts:
--------------------------------------------------------------------------------
1 | import {defineType} from 'sanity'
2 |
3 | import authorType from './author'
4 | import blockContentType from './blockContent'
5 | import categoryType from './category'
6 |
7 | export default defineType({
8 | name: 'post',
9 | title: 'Post',
10 | type: 'document',
11 | fields: [
12 | {
13 | name: 'title',
14 | title: 'Title',
15 | type: 'string',
16 | },
17 | {
18 | name: 'slug',
19 | title: 'Slug',
20 | type: 'slug',
21 | options: {
22 | source: 'title',
23 | maxLength: 96,
24 | },
25 | },
26 | {
27 | name: 'author',
28 | title: 'Author',
29 | type: 'reference',
30 | to: {type: authorType.name},
31 | },
32 | {
33 | name: 'mainImage',
34 | title: 'Main image',
35 | type: 'image',
36 | fields: [
37 | {
38 | name: 'alt',
39 | type: 'string',
40 | title: 'Alternative text',
41 | description: 'Important for SEO and accessiblity.',
42 | },
43 | ],
44 | options: {
45 | hotspot: true,
46 | aiAssist: {
47 | imageDescriptionField: 'alt',
48 | },
49 | },
50 | },
51 | {
52 | name: 'categories',
53 | title: 'Categories',
54 | type: 'array',
55 | of: [{type: 'reference', to: {type: categoryType.name}}],
56 | },
57 | {
58 | name: 'publishedAt',
59 | title: 'Published at',
60 | type: 'datetime',
61 | },
62 | {
63 | name: 'body',
64 | title: 'Body',
65 | type: blockContentType.name,
66 | },
67 | ],
68 |
69 | preview: {
70 | select: {
71 | title: 'title',
72 | author: 'author.name',
73 | media: 'mainImage',
74 | },
75 | prepare(selection: any) {
76 | const {author} = selection
77 | return {...selection, subtitle: author && `by ${author}`}
78 | },
79 | },
80 | })
81 |
--------------------------------------------------------------------------------
/packages/next-sanity/src/live/hooks/useDraftMode.ts:
--------------------------------------------------------------------------------
1 | import {useCallback, useSyncExternalStore} from 'react'
2 |
3 | import {
4 | environment,
5 | environmentListeners,
6 | perspective,
7 | perspectiveListeners,
8 | type DraftEnvironment,
9 | type DraftPerspective,
10 | } from './context'
11 |
12 | /**
13 | * Reports the current draft mode environment.
14 | * Use it to determine how to adapt the UI based on wether:
15 | * - Your app is previewed in a iframe, inside Presentation Tool in a Sanity Studio.
16 | * - Your app is previewed in a new window, spawned from Presentation Tool in a Sanity Studio.
17 | * - Your app is live previewing drafts in a standalone context.
18 | * - Your app is previewing drafts, but not live.
19 | * - Your app is not previewing anything (that could be detected).
20 | * @public
21 | */
22 | export function useDraftModeEnvironment(): DraftEnvironment {
23 | const subscribe = useCallback((listener: () => void) => {
24 | environmentListeners.add(listener)
25 | return () => environmentListeners.delete(listener)
26 | }, [])
27 |
28 | return useSyncExternalStore(
29 | subscribe,
30 | () => environment,
31 | () => 'checking',
32 | )
33 | }
34 |
35 | /**
36 | * Reports the Sanity Client perspective used to fetch data in `sanityFetch` used on the page.
37 | * If the hook is used outside Draft Mode it will resolve to `'unknown'`.
38 | * If the hook is used but the `` component is not present then it'll stay in `'checking'` and console warn after a timeout that it seems like you're missing the component.
39 | * @public
40 | */
41 | export function useDraftModePerspective(): DraftPerspective {
42 | const subscribe = useCallback((listener: () => void) => {
43 | perspectiveListeners.add(listener)
44 | return () => perspectiveListeners.delete(listener)
45 | }, [])
46 |
47 | return useSyncExternalStore(
48 | subscribe,
49 | () => perspective,
50 | () => 'checking',
51 | )
52 | }
53 |
--------------------------------------------------------------------------------
/packages/next-sanity/tsdown.config.ts:
--------------------------------------------------------------------------------
1 | import {defineConfig} from 'tsdown'
2 |
3 | /**
4 | * Testing out tsdown, if it works well we'll move it to `@sanity/pkg-utils`
5 | */
6 |
7 | export default defineConfig({
8 | tsconfig: 'tsconfig.build.json',
9 | entry: [
10 | './src/draft-mode/index.ts',
11 | './src/hooks/index.ts',
12 | './src/image/index.ts',
13 | './src/experimental/live.tsx',
14 | './src/experimental/client-components/live.tsx',
15 | './src/index.ts',
16 | './src/live.ts',
17 | './src/live.server-only.ts',
18 | './src/live/client-components/live/index.ts',
19 | './src/live/client-components/live-stream/index.ts',
20 | './src/live/server-actions/index.ts',
21 | './src/studio/client-component/index.ts',
22 | './src/studio/index.ts',
23 | './src/visual-editing/client-component/index.ts',
24 | './src/visual-editing/index.ts',
25 | './src/visual-editing/server-actions/index.ts',
26 | './src/webhook/index.ts',
27 | ],
28 | external: [
29 | 'next-sanity',
30 | 'next-sanity/experimental/client-components/live',
31 | 'next-sanity/live/client-components/live',
32 | 'next-sanity/live/client-components/live-stream',
33 | 'next-sanity/live/server-actions',
34 | 'next-sanity/studio/client-component',
35 | 'next-sanity/visual-editing/client-component',
36 | 'next-sanity/visual-editing/server-actions',
37 | ],
38 | sourcemap: true,
39 | hash: false,
40 | exports: {
41 | enabled: 'local-only',
42 | devExports: true,
43 | customExports(pkg) {
44 | pkg['./live'] = {
45 | 'react-server': pkg['./live'],
46 | 'default': pkg['./live.server-only'],
47 | }
48 | delete pkg['./live.server-only']
49 | return pkg
50 | },
51 | },
52 | inputOptions: {preserveEntrySignatures: 'strict', experimental: {attachDebugInfo: 'none'}},
53 | outputOptions: {hoistTransitiveImports: false},
54 | platform: 'neutral',
55 | minify: 'dce-only',
56 | ignoreWatch: ['.turbo'],
57 | })
58 |
--------------------------------------------------------------------------------
/packages/next-sanity/MIGRATE-v10-to-v11.md:
--------------------------------------------------------------------------------
1 | ## Migrate
2 |
3 | ### The `VisualEditing` export has been moved to `next-sanity/visual-editing`
4 |
5 | The export has been moved to support `output: 'static'` builds. `VisualEditing` makes use of Server Actions, and thus it cannot be exposed to the root `'next-sanity'` export without blocking the ability to use features like `import {defineQuery} from 'next-sanity'` on static builds.
6 |
7 | ```diff
8 | // src/app/layout.tsx
9 |
10 | -import {VisualEditing} from 'next-sanity'
11 | +import {VisualEditing} from 'next-sanity/visual-editing'
12 | import {SanityLive} from '@/sanity/lib/live'
13 |
14 | export default function RootLayout({children}: {children: React.ReactNode}) {
15 | return (
16 |
17 |
18 | {children}
19 |
20 | {(await draftMode()).isEnabled && }
21 |
22 |
23 | )
24 | }
25 | ```
26 |
27 | ### The `defineLive` export has been moved to `next-sanity/live`
28 |
29 | The export has been moved to support `output: 'static'` builds. `defineLive` makes use of Server Actions, and thus it cannot be exposed to the root `'next-sanity'` export without blocking the ability to use features like `import {defineQuery} from 'next-sanity'` on static builds.
30 |
31 | ```diff
32 | // src/sanity/lib/live.ts
33 |
34 | import {createClient} from 'next-sanity'
35 | -import {defineLive} from 'next-sanity'
36 | +import {defineLive} from 'next-sanity/live'
37 |
38 | const client = createClient({
39 | projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID,
40 | dataset: process.env.NEXT_PUBLIC_SANITY_DATASET,
41 | useCdn: true,
42 | apiVersion: 'v2025-03-04',
43 | stega: {studioUrl: '/studio'},
44 | })
45 |
46 | const token = process.env.SANITY_API_READ_TOKEN
47 | if (!token) {
48 | throw new Error('Missing SANITY_API_READ_TOKEN')
49 | }
50 |
51 | export const {sanityFetch, SanityLive} = defineLive({
52 | client,
53 | serverToken: token,
54 | browserToken: token,
55 | })
56 | ```
57 |
--------------------------------------------------------------------------------
/packages/sanity-config/src/schemas/blockContent.ts:
--------------------------------------------------------------------------------
1 | import {defineType} from 'sanity'
2 | /**
3 | * This is the schema definition for the rich text fields used for
4 | * for this blog studio. When you import it in schemas.js it can be
5 | * reused in other parts of the studio with:
6 | * {
7 | * name: 'someName',
8 | * title: 'Some title',
9 | * type: 'blockContent'
10 | * }
11 | */
12 | export default defineType({
13 | title: 'Block Content',
14 | name: 'blockContent',
15 | type: 'array',
16 | of: [
17 | {
18 | title: 'Block',
19 | type: 'block',
20 | // Styles let you set what your user can mark up blocks with. These
21 | // correspond with HTML tags, but you can set any title or value
22 | // you want and decide how you want to deal with it where you want to
23 | // use your content.
24 | styles: [
25 | {title: 'Normal', value: 'normal'},
26 | {title: 'H1', value: 'h1'},
27 | {title: 'H2', value: 'h2'},
28 | {title: 'H3', value: 'h3'},
29 | {title: 'H4', value: 'h4'},
30 | {title: 'Quote', value: 'blockquote'},
31 | ],
32 | lists: [{title: 'Bullet', value: 'bullet'}],
33 | // Marks let you mark up inline text in the block editor.
34 | marks: {
35 | // Decorators usually describe a single property – e.g. a typographic
36 | // preference or highlighting by editors.
37 | decorators: [
38 | {title: 'Strong', value: 'strong'},
39 | {title: 'Emphasis', value: 'em'},
40 | ],
41 | // Annotations can be any object structure – e.g. a link or a footnote.
42 | annotations: [
43 | {
44 | title: 'URL',
45 | name: 'link',
46 | type: 'object',
47 | fields: [
48 | {
49 | title: 'URL',
50 | name: 'href',
51 | type: 'url',
52 | },
53 | ],
54 | },
55 | ],
56 | },
57 | },
58 | // You can add additional types here. Note that you can't use
59 | // primitive types such as 'string' and 'number' in the same array
60 | // as a block type.
61 | {
62 | type: 'image',
63 | options: {hotspot: true},
64 | },
65 | ],
66 | })
67 |
--------------------------------------------------------------------------------
/apps/mvp/app/(website)/only-production/page.tsx:
--------------------------------------------------------------------------------
1 | 'use cache'
2 |
3 | import {unstable__adapter, unstable__environment} from 'next-sanity'
4 | import Link from 'next/link'
5 |
6 | import PostsLayout, {postsQuery} from '@/app/(website)/PostsLayout'
7 |
8 | import {sanityFetch} from '../live'
9 |
10 | export default async function IndexPage() {
11 | const {data} = await sanityFetch({
12 | query: postsQuery.query,
13 | perspective: 'published',
14 | stega: false,
15 | })
16 |
17 | return (
18 | <>
19 |
28 |
29 |
34 | Resolve perspective
35 |
36 |
41 | No resolve perspective
42 |
43 |
44 | Only production
45 |
46 |
51 | Open Studio
52 |
53 |
54 | >
55 | )
56 | }
57 |
--------------------------------------------------------------------------------
/apps/mvp/app/(website)/no-resolve-perspective/page.tsx:
--------------------------------------------------------------------------------
1 | 'use cache'
2 |
3 | import {unstable__adapter, unstable__environment} from 'next-sanity'
4 | import {draftMode} from 'next/headers'
5 | import Link from 'next/link'
6 |
7 | import PostsLayout, {postsQuery} from '@/app/(website)/PostsLayout'
8 |
9 | import {sanityFetch} from '../live'
10 |
11 | export default async function IndexPage() {
12 | const isDraftMode = (await draftMode()).isEnabled
13 | const {data} = await sanityFetch({
14 | query: postsQuery.query,
15 | perspective: isDraftMode ? 'drafts' : 'published',
16 | stega: isDraftMode,
17 | })
18 |
19 | return (
20 | <>
21 |
30 |
31 |
36 | Resolve perspective
37 |
38 |
39 | No resolve perspective
40 |
41 |
46 | Only production
47 |
48 |
53 | Open Studio
54 |
55 |
56 | >
57 | )
58 | }
59 |
--------------------------------------------------------------------------------
/packages/next-sanity/src/experimental/client-components/PresentationComlink.tsx:
--------------------------------------------------------------------------------
1 | import type {ClientPerspective} from '@sanity/client'
2 |
3 | import {createNode, createNodeMachine} from '@sanity/comlink'
4 | import {
5 | createCompatibilityActors,
6 | type LoaderControllerMsg,
7 | type LoaderNodeMsg,
8 | } from '@sanity/presentation-comlink'
9 | import {setPerspectiveCookie} from 'next-sanity/live/server-actions'
10 | import {useRouter} from 'next/navigation'
11 | import {startTransition, useEffect, useEffectEvent} from 'react'
12 |
13 | import {
14 | setComlink,
15 | setComlinkClientConfig,
16 | setPerspective,
17 | perspective,
18 | } from '../../live/hooks/context'
19 | import {sanitizePerspective} from '../../live/utils'
20 |
21 | export default function PresentationComlink(props: {
22 | projectId: string
23 | dataset: string
24 | draftModeEnabled: boolean
25 | }): React.JSX.Element | null {
26 | const {projectId, dataset, draftModeEnabled} = props
27 | const router = useRouter()
28 |
29 | useEffect(() => {
30 | setComlinkClientConfig(projectId, dataset)
31 | }, [dataset, projectId])
32 |
33 | const handlePerspectiveChange = useEffectEvent(
34 | (_perspective: ClientPerspective, signal: AbortSignal) => {
35 | const nextPerspective = sanitizePerspective(_perspective, 'drafts')
36 | if (draftModeEnabled && perspective.toString() !== nextPerspective.toString()) {
37 | setPerspective(nextPerspective)
38 | startTransition(() =>
39 | setPerspectiveCookie(nextPerspective)
40 | .then(() => {
41 | if (signal.aborted) return
42 | router.refresh()
43 | })
44 | .catch((reason) =>
45 | console.error('Failed to set the preview perspective cookie', reason),
46 | ),
47 | )
48 | }
49 | },
50 | )
51 |
52 | useEffect(() => {
53 | const comlink = createNode(
54 | {name: 'loaders', connectTo: 'presentation'},
55 | createNodeMachine().provide({
56 | actors: createCompatibilityActors(),
57 | }),
58 | )
59 |
60 | let controller: AbortController | undefined
61 | comlink.on('loader/perspective', (data) => {
62 | controller?.abort()
63 | controller = new AbortController()
64 | handlePerspectiveChange(data.perspective, controller.signal)
65 | })
66 |
67 | const stop = comlink.start()
68 | setComlink(comlink)
69 | return () => {
70 | stop()
71 | }
72 | }, [])
73 |
74 | return null
75 | }
76 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on:
4 | merge_group:
5 | pull_request:
6 | types: [opened, synchronize]
7 | push:
8 | branches: [main]
9 |
10 | concurrency:
11 | group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
12 | cancel-in-progress: true
13 |
14 | permissions:
15 | contents: read # for checkout
16 |
17 | jobs:
18 | build:
19 | runs-on: ubuntu-latest
20 | env:
21 | TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
22 | TURBO_TEAM: ${{ vars.TURBO_TEAM }}
23 | NEXT_PUBLIC_SANITY_DATASET: ${{ secrets.NEXT_PUBLIC_SANITY_DATASET }}
24 | NEXT_PUBLIC_SANITY_PROJECT_ID: ${{ secrets.NEXT_PUBLIC_SANITY_PROJECT_ID }}
25 | steps:
26 | - uses: actions/checkout@v6
27 | - uses: pnpm/action-setup@v4
28 | - uses: actions/setup-node@v6
29 | with:
30 | node-version: lts/*
31 | - run: pnpm install --ignore-scripts
32 | - run: pnpm build --filter=./packages/*
33 | - run: pnpm typegen
34 | - run: pnpm lint --format github
35 |
36 | test:
37 | needs: build
38 | timeout-minutes: 15
39 | strategy:
40 | # A test failing on windows doesn't mean it'll fail on macos. It's useful to let all tests run to its completion to get the full picture
41 | fail-fast: false
42 | matrix:
43 | # https://nodejs.org/en/about/releases/
44 | # https://pnpm.io/installation#compatibility
45 | # @TODO re-enable `current` once it recovers
46 | # node: [lts/-1, lts/*, current]
47 | node: [lts/-1, lts/*]
48 | os: [ubuntu-latest]
49 | # Also test the LTS on mac and windows
50 | include:
51 | - os: macos-latest
52 | node: lts/*
53 | # - os: windows-latest
54 | # node: lts/*
55 | runs-on: ${{ matrix.os }}
56 | env:
57 | TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
58 | TURBO_TEAM: ${{ vars.TURBO_TEAM }}
59 | NEXT_PUBLIC_SANITY_DATASET: ${{ secrets.NEXT_PUBLIC_SANITY_DATASET }}
60 | NEXT_PUBLIC_SANITY_PROJECT_ID: ${{ secrets.NEXT_PUBLIC_SANITY_PROJECT_ID }}
61 | steps:
62 | # It's only necessary to do this for windows, as mac and ubuntu are sane OS's that already use LF
63 | - if: matrix.os == 'windows-latest'
64 | run: |
65 | git config --global core.autocrlf false
66 | git config --global core.eol lf
67 | - uses: actions/checkout@v6
68 | - uses: pnpm/action-setup@v4
69 | - uses: actions/setup-node@v6
70 | with:
71 | node-version: ${{ matrix.node }}
72 | - run: pnpm install --loglevel=error --ignore-scripts
73 | - run: pnpm test
74 |
--------------------------------------------------------------------------------
/packages/next-sanity/src/live/hooks/context.ts:
--------------------------------------------------------------------------------
1 | import type {ClientPerspective} from '@sanity/client'
2 |
3 | import {type Node} from '@sanity/comlink'
4 | import {type LoaderControllerMsg, type LoaderNodeMsg} from '@sanity/presentation-comlink'
5 |
6 | /**
7 | * The Sanity Client perspective used when fetching data in Draft Mode, in the `sanityFetch` calls
8 | * used by React Server Components on the page. Note that some of them might set the `perspective` to a different value.
9 | * This value is what's used by default.
10 | * @public
11 | */
12 | export type DraftPerspective = 'checking' | 'unknown' | ClientPerspective
13 |
14 | /** @internal */
15 | export const perspectiveListeners: Set<() => void> = new Set()
16 | /** @internal */
17 | export let perspective: DraftPerspective = 'checking'
18 | /** @internal */
19 | export function setPerspective(nextPerspective: DraftPerspective): void {
20 | if (perspective.toString() === nextPerspective.toString()) return
21 | perspective = nextPerspective
22 | for (const onPerspectiveChange of perspectiveListeners) {
23 | onPerspectiveChange()
24 | }
25 | }
26 |
27 | /**
28 | *
29 | * @public
30 | */
31 | export type DraftEnvironment =
32 | | 'checking'
33 | | 'presentation-iframe'
34 | | 'presentation-window'
35 | | 'live'
36 | | 'static'
37 | | 'unknown'
38 |
39 | /** @internal */
40 | export const environmentListeners: Set<() => void> = new Set()
41 | /** @internal */
42 | export let environment: DraftEnvironment = 'checking'
43 | /** @internal */
44 | export function setEnvironment(nextEnvironment: DraftEnvironment): void {
45 | environment = nextEnvironment
46 | for (const onEnvironmentChange of environmentListeners) {
47 | onEnvironmentChange()
48 | }
49 | }
50 |
51 | /** @internal */
52 | export const comlinkListeners: Set<() => void> = new Set()
53 | /** @internal */
54 | export let comlink: Node | null = null
55 | /** @internal */
56 | export let comlinkProjectId: string | null = null
57 | /** @internal */
58 | export let comlinkDataset: string | null = null
59 | /** @internal */
60 | export function setComlink(nextComlink: Node | null): void {
61 | comlink = nextComlink
62 | for (const onComlinkChange of comlinkListeners) {
63 | onComlinkChange()
64 | }
65 | }
66 | /** @internal */
67 | export function setComlinkClientConfig(
68 | nextComlinkProjectId: string | null,
69 | nextComlinkDataset: string | null,
70 | ): void {
71 | comlinkProjectId = nextComlinkProjectId
72 | comlinkDataset = nextComlinkDataset
73 | for (const onComlinkChange of comlinkListeners) {
74 | onComlinkChange()
75 | }
76 | }
77 |
--------------------------------------------------------------------------------
/apps/mvp/app/(website)/page.tsx:
--------------------------------------------------------------------------------
1 | import {unstable__adapter, unstable__environment} from 'next-sanity'
2 | import {resolvePerspectiveFromCookies} from 'next-sanity/experimental/live'
3 | import {cookies, draftMode} from 'next/headers'
4 | import Link from 'next/link'
5 |
6 | import PostsLayout, {postsQuery} from '@/app/(website)/PostsLayout'
7 |
8 | import {sanityFetch} from './live'
9 |
10 | export default async function IndexPage() {
11 | const isDraftMode = (await draftMode()).isEnabled
12 | const perspective = isDraftMode
13 | ? await resolvePerspectiveFromCookies({cookies: await cookies()})
14 | : 'published'
15 | const {data, tags} = await sanityFetch({
16 | query: postsQuery.query,
17 | perspective,
18 | stega: isDraftMode,
19 | })
20 |
21 | return (
22 | <>
23 |
28 |
29 |
30 |
{JSON.stringify({perspective, tags: tags.toSorted()})}
31 |
32 |
33 |
34 |
35 | Resolve perspective
36 |
37 |
42 | No resolve perspective
43 |
44 |
49 | Only production
50 |
51 |
56 | Open Studio
57 |
58 |
59 | >
60 | )
61 | }
62 |
--------------------------------------------------------------------------------
/packages/next-sanity/src/studio/client-component/NextStudio.tsx:
--------------------------------------------------------------------------------
1 | import {useMemo} from 'react'
2 | import {Studio, type StudioProps} from 'sanity'
3 |
4 | import {NextStudioLayout} from '../NextStudioLayout'
5 | import {NextStudioNoScript} from '../NextStudioNoScript'
6 | import {createHashHistoryForStudio} from './createHashHistoryForStudio'
7 | import {useIsMounted} from './useIsMounted'
8 |
9 | /** @public */
10 | export interface NextStudioProps extends StudioProps {
11 | children?: React.ReactNode
12 | /**
13 | * Render the