├── .github ├── CODEOWNERS ├── renovate.json └── workflows │ ├── browserslist.yml │ ├── lock.yml │ ├── main.yml │ ├── prettier.yml │ └── simulate-high-traffic.yml ├── .gitignore ├── .npmrc ├── .prettierignore ├── LICENSE ├── MIGRATION.md ├── README.md ├── apps ├── next-app-router │ ├── .eslintignore │ ├── .eslintrc.json │ ├── .gitignore │ ├── app │ │ ├── DraftModeButton.tsx │ │ ├── RefreshButton.tsx │ │ ├── actions.ts │ │ ├── api │ │ │ ├── disable-draft │ │ │ │ └── route.ts │ │ │ └── draft │ │ │ │ └── route.ts │ │ ├── layout.tsx │ │ ├── page.tsx │ │ ├── previews │ │ │ └── index.tsx │ │ ├── sanity.client.ts │ │ ├── sanity.fetch.ts │ │ ├── sanity.perspective.ts │ │ └── variants │ │ │ └── live-store │ │ │ ├── PreviewProvider.tsx │ │ │ ├── index.tsx │ │ │ └── sanity.client.ts │ ├── next-env.d.ts │ ├── next.config.mjs │ ├── package.json │ ├── tsconfig.json │ └── turbo.json ├── next-pages-router │ ├── .eslintignore │ ├── .eslintrc.json │ ├── .gitignore │ ├── next-env.d.ts │ ├── next.config.mjs │ ├── package.json │ ├── src │ │ ├── app │ │ │ └── api │ │ │ │ └── draft │ │ │ │ └── route.ts │ │ ├── pages │ │ │ ├── _app.tsx │ │ │ ├── api │ │ │ │ ├── disable-draft.ts │ │ │ │ └── revalidate.ts │ │ │ └── index.tsx │ │ ├── sanity.client.ts │ │ ├── sanity.fetch.ts │ │ └── variants │ │ │ └── live-store │ │ │ ├── PreviewProvider.tsx │ │ │ ├── index.tsx │ │ │ └── sanity.client.ts │ ├── tsconfig.json │ └── turbo.json ├── remix │ ├── .eslintrc.json │ ├── .gitignore │ ├── app │ │ ├── root.tsx │ │ ├── routes │ │ │ ├── _index.tsx │ │ │ ├── api.disable-draft.tsx │ │ │ └── api.draft.tsx │ │ ├── sanity.ts │ │ ├── sessions.ts │ │ └── variants │ │ │ └── live-store │ │ │ ├── PreviewProvider.tsx │ │ │ └── index.tsx │ ├── package.json │ ├── public │ │ └── favicon.ico │ ├── remix.config.js │ ├── remix.env.d.ts │ ├── tsconfig.json │ └── turbo.json └── studio │ ├── .gitignore │ ├── package.json │ ├── public │ └── index.html │ ├── sanity.cli.ts │ ├── sanity.config.ts │ ├── src │ ├── action.ts │ └── benchmark.tsx │ ├── tsconfig.json │ └── turbo.json ├── package.json ├── packages ├── preview-kit │ ├── .eslintignore │ ├── .eslintrc.cjs │ ├── .releaserc.json │ ├── CHANGELOG.md │ ├── LICENSE │ ├── README.md │ ├── package.config.ts │ ├── package.json │ ├── src │ │ ├── LiveQueryProvider │ │ │ ├── LiveQueryProvider.tsx │ │ │ ├── constants.ts │ │ │ ├── index.ts │ │ │ ├── useLiveEvents.test.ts │ │ │ ├── useLiveEvents.ts │ │ │ ├── useLiveQueries.ts │ │ │ ├── usePerspective.ts │ │ │ └── utils.ts │ │ ├── client.ts │ │ ├── context.ts │ │ ├── hooks.ts │ │ ├── index.ts │ │ ├── live-query │ │ │ ├── LiveQuery.tsx │ │ │ ├── client-component │ │ │ │ ├── LiveQueryClientComponent.tsx │ │ │ │ ├── index.ts │ │ │ │ └── useLiveQuery.ts │ │ │ └── index.ts │ │ └── types.ts │ ├── tsconfig.base.json │ ├── tsconfig.build.json │ ├── tsconfig.json │ ├── turbo.json │ └── vite.config.ts └── ui │ ├── .eslintignore │ ├── .eslintrc.cjs │ ├── package.config.ts │ ├── package.json │ ├── src │ ├── index.ts │ └── react │ │ ├── components.tsx │ │ └── index.ts │ ├── tsconfig.base.json │ ├── tsconfig.build.json │ └── tsconfig.json ├── pnpm-lock.yaml ├── pnpm-workspace.yaml └── turbo.json /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @sanity-io/ecosystem 2 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": ["github>sanity-io/renovate-config", ":reviewer(team:ecosystem)"], 4 | "ignorePresets": [":ignoreModulesAndTests", "github>sanity-io/renovate-config:group-non-major"], 5 | "packageRules": [ 6 | { 7 | "group": {"semanticCommitType": "chore"}, 8 | "matchDepTypes": [ 9 | "dependencies", 10 | "devDependencies", 11 | "engines", 12 | "optionalDependencies", 13 | "peerDependencies" 14 | ], 15 | "matchManagers": ["npm"], 16 | "semanticCommitType": "chore", 17 | "description": "Group all dependencies from the app directory", 18 | "matchFileNames": ["apps/**/package.json", "packages/ui/package.json"], 19 | "groupName": "App dependencies" 20 | }, 21 | { 22 | "matchDepTypes": ["dependencies"], 23 | "rangeStrategy": "bump" 24 | }, 25 | { 26 | "matchDepTypes": ["peerDependencies"], 27 | "matchPackageNames": ["@sanity/comlink", "@sanity/presentation-comlink"], 28 | "rangeStrategy": "bump", 29 | "semanticCommitType": "fix" 30 | }, 31 | { 32 | "matchDepTypes": ["devDependencies"], 33 | "matchPackageNames": [ 34 | "@vercel/stega", 35 | "lru-cache", 36 | "react-fast-compare", 37 | "use-sync-external-store" 38 | ], 39 | "rangeStrategy": "bump", 40 | "semanticCommitType": "fix" 41 | } 42 | ] 43 | } 44 | -------------------------------------------------------------------------------- /.github/workflows/browserslist.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Update Browserslist database 3 | 4 | on: 5 | schedule: 6 | - cron: '0 2 1,15 * *' 7 | workflow_dispatch: 8 | 9 | permissions: 10 | contents: read # for checkout 11 | 12 | jobs: 13 | update-browserslist-database: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v4 17 | - uses: actions/setup-node@v4 18 | with: 19 | node-version: lts/* 20 | - run: npx update-browserslist-db@latest 21 | - uses: actions/create-github-app-token@v2 22 | id: generate-token 23 | with: 24 | app-id: ${{ secrets.ECOSPARK_APP_ID }} 25 | private-key: ${{ secrets.ECOSPARK_APP_PRIVATE_KEY }} 26 | - uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7 27 | with: 28 | body: I ran `npx update-browserslist-db@latest` 🧑‍💻 29 | branch: actions/update-browserslist-database-if-needed 30 | commit-message: 'chore: update browserslist db' 31 | labels: 🤖 bot 32 | sign-commits: true 33 | title: 'chore: update browserslist db' 34 | token: ${{ steps.generate-token.outputs.token }} 35 | -------------------------------------------------------------------------------- /.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@1bf7ec25051fe7c00bdd17e6a7cf3d7bfb7dc771 # v5 26 | with: 27 | issue-inactive-days: 0 28 | pr-inactive-days: 7 29 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: CI 3 | 4 | run-name: >- 5 | ${{ 6 | inputs.release && inputs.test && 'CI ➤ Test ➤ Release' || 7 | inputs.test && 'CI ➤ Test' || 8 | inputs.release && 'CI ➤ Release' || 9 | '' 10 | }} 11 | 12 | on: 13 | pull_request: 14 | types: [opened, synchronize] 15 | push: 16 | branches: [main] 17 | workflow_dispatch: 18 | inputs: 19 | test: 20 | description: Test 21 | required: true 22 | default: true 23 | type: boolean 24 | release: 25 | description: Release 26 | required: true 27 | default: false 28 | type: boolean 29 | 30 | concurrency: 31 | group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} 32 | cancel-in-progress: true 33 | 34 | permissions: 35 | contents: read # for checkout 36 | 37 | jobs: 38 | build: 39 | runs-on: ubuntu-latest 40 | env: 41 | TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} 42 | TURBO_TEAM: ${{ vars.TURBO_TEAM }} 43 | steps: 44 | - uses: actions/checkout@v4 45 | - uses: pnpm/action-setup@v4 46 | - uses: actions/setup-node@v4 47 | with: 48 | cache: pnpm 49 | node-version: lts/* 50 | - run: pnpm install 51 | - run: pnpm lint 52 | if: github.event.inputs.test != 'false' 53 | - run: pnpm build --summarize -vvv 54 | # if: github.event.inputs.test != 'false' 55 | 56 | test: 57 | needs: build 58 | # The test matrix can be skipped, in case a new release needs to be fast-tracked and tests are already passing on main 59 | if: github.event.inputs.test != 'false' 60 | timeout-minutes: 15 61 | strategy: 62 | # 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 63 | fail-fast: false 64 | matrix: 65 | # https://nodejs.org/en/about/releases/ 66 | # https://pnpm.io/installation#compatibility 67 | # Includes the lowest version supported by preview-kit (defined in pkg.engines.node) 68 | node: [lts/-1, lts/*] 69 | os: [ubuntu-latest] 70 | # Also test the LTS on mac and windows 71 | include: 72 | - os: macos-latest 73 | node: lts/* 74 | - os: windows-latest 75 | node: lts/* 76 | # We want to know ASAP if an upcoming breaking changes in the next LTS will affect us, but since "current" are unstable, 77 | # it sometimes might fail until a new nightly comes out and we don't want to be blocked from merging PRs when this happens. 78 | continue-on-error: ${{ matrix.node == 'current' }} 79 | runs-on: ${{ matrix.os }} 80 | env: 81 | NODE_VERSION: ${{ matrix.node }} 82 | TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} 83 | TURBO_TEAM: ${{ vars.TURBO_TEAM }} 84 | steps: 85 | # It's only necessary to do this for windows, as mac and ubuntu are sane OS's that already use LF 86 | - if: matrix.os == 'windows-latest' 87 | run: | 88 | git config --global core.autocrlf false 89 | git config --global core.eol lf 90 | - uses: actions/checkout@v4 91 | - uses: pnpm/action-setup@v4 92 | - uses: actions/setup-node@v4 93 | with: 94 | cache: pnpm 95 | node-version: ${{ matrix.node }} 96 | - run: pnpm install --loglevel=error 97 | - run: pnpm test 98 | 99 | release: 100 | permissions: 101 | id-token: write # to enable use of OIDC for npm provenance 102 | needs: [build, test] 103 | # only run if opt-in during workflow_dispatch 104 | if: always() && github.event.inputs.release == 'true' && needs.build.result != 'failure' && needs.test.result != 'failure' && needs.test.result != 'cancelled' 105 | runs-on: ubuntu-latest 106 | steps: 107 | - uses: actions/create-github-app-token@v2 108 | id: app-token 109 | with: 110 | app-id: ${{ secrets.ECOSPARK_APP_ID }} 111 | private-key: ${{ secrets.ECOSPARK_APP_PRIVATE_KEY }} 112 | - uses: actions/checkout@v4 113 | with: 114 | # Need to fetch entire commit history to 115 | # analyze every commit since last release 116 | fetch-depth: 0 117 | # Uses generated token to allow pushing commits back 118 | token: ${{ steps.app-token.outputs.token }} 119 | # Make sure the value of GITHUB_TOKEN will not be persisted in repo's config 120 | persist-credentials: false 121 | - uses: pnpm/action-setup@v4 122 | - uses: actions/setup-node@v4 123 | with: 124 | cache: pnpm 125 | node-version: lts/* 126 | - run: pnpm install --loglevel=error 127 | - run: pnpm run -r release 128 | # Don't allow interrupting the release step if the job is cancelled, as it can lead to an inconsistent state 129 | # e.g. git tags were pushed but it exited before `npm publish` 130 | if: always() 131 | env: 132 | NPM_CONFIG_PROVENANCE: true 133 | GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} 134 | NPM_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }} 135 | -------------------------------------------------------------------------------- /.github/workflows/prettier.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Prettier 3 | 4 | on: 5 | push: 6 | branches: [main] 7 | workflow_dispatch: 8 | 9 | concurrency: 10 | group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} 11 | cancel-in-progress: true 12 | 13 | jobs: 14 | run: 15 | name: Can the code be prettier? 🤔 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v4 19 | - uses: pnpm/action-setup@v4 20 | - uses: actions/setup-node@v4 21 | with: 22 | cache: pnpm 23 | node-version: lts/* 24 | - run: pnpm install --dev --ignore-scripts 25 | - uses: actions/cache@v4 26 | with: 27 | path: node_modules/.cache/prettier/.prettier-cache 28 | key: prettier-${{ hashFiles('pnpm-lock.yaml') }}-${{ hashFiles('.prettierignore') }}-${{ hashFiles('.prettierrc.cjs') }} 29 | - run: pnpm format 30 | - run: git restore .github/workflows 31 | - uses: actions/create-github-app-token@v2 32 | id: generate-token 33 | with: 34 | app-id: ${{ secrets.ECOSPARK_APP_ID }} 35 | private-key: ${{ secrets.ECOSPARK_APP_PRIVATE_KEY }} 36 | - uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7 37 | with: 38 | body: I ran `pnpm format` 🧑‍💻 39 | branch: actions/prettier 40 | commit-message: 'chore(prettier): 🤖 ✨' 41 | labels: 🤖 bot 42 | sign-commits: true 43 | title: 'chore(prettier): 🤖 ✨' 44 | token: ${{ steps.generate-token.outputs.token }} 45 | -------------------------------------------------------------------------------- /.github/workflows/simulate-high-traffic.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Simulate High Traffic 3 | 4 | on: 5 | workflow_dispatch: 6 | 7 | jobs: 8 | test: 9 | timeout-minutes: 15 10 | strategy: 11 | fail-fast: false 12 | matrix: 13 | node: [0, 1, 2, 3, 4, 5, 6, 7] 14 | runs-on: ubuntu-latest 15 | env: 16 | NODE_VERSION: ${{ matrix.node }} 17 | TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} 18 | TURBO_TEAM: ${{ vars.TURBO_TEAM }} 19 | steps: 20 | 21 | - uses: actions/checkout@v4 22 | - uses: pnpm/action-setup@v4 23 | - uses: actions/setup-node@v4 24 | with: 25 | cache: pnpm 26 | node-version: lts/* 27 | - run: pnpm install --loglevel=error 28 | - run: node -r esbuild-register src/action.ts 29 | working-directory: apps/studio 30 | env: 31 | MINUTES: 15 32 | BATCH: 10 33 | SANITY_API_WRITE_TOKEN: ${{ secrets.SANITY_API_WRITE_TOKEN }} 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env.*local 73 | 74 | # parcel-bundler cache (https://parceljs.org/) 75 | .cache 76 | 77 | # Next.js build output 78 | .next 79 | 80 | # Nuxt.js build / generate output 81 | .nuxt 82 | dist 83 | 84 | # Gatsby files 85 | .cache/ 86 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 87 | # https://nextjs.org/blog/next-9-1#public-directory-support 88 | # public 89 | 90 | # vuepress build output 91 | .vuepress/dist 92 | 93 | # Serverless directories 94 | .serverless/ 95 | 96 | # FuseBox cache 97 | .fusebox/ 98 | 99 | # DynamoDB Local files 100 | .dynamodb/ 101 | 102 | # TernJS port file 103 | .tern-port 104 | 105 | .vscode 106 | .vercel 107 | .turbo 108 | 109 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | enable-pre-post-scripts = true 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .cache 2 | .vercel 3 | .turbo 4 | .sanity 5 | .github/workflows/*.yml 6 | apps/*/.next 7 | apps/*/build 8 | apps/*/dist 9 | apps/*/public/build 10 | packages/*/dist 11 | pnpm-lock.yaml -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | packages/preview-kit/LICENSE -------------------------------------------------------------------------------- /MIGRATION.md: -------------------------------------------------------------------------------- 1 | - [Upgrading from v5 to v6](#upgrading-from-v5-to-v6) 2 | - [Upgrading from v1 to v2](#upgrading-from-v1-to-v2) 3 | 4 | # Upgrading from v5 to v6 5 | 6 | ## `refreshInterval` is now removed 7 | 8 | This is a side-effect from v6 adopting the [Live Content API](https://www.sanity.io/live) under the hood. On any change the API notifies which queries to refetch, thus there's zero need to do any polling. With no polling, the `refreshInterval` option no longer has any effect. 9 | 10 | ## `@sanity/preview-kit/client` is changed 11 | 12 | It now re-exports `@sanity/client`, migrating has minor changes for most use cases: 13 | 14 | ```diff 15 | import {createClient} from '@sanity/preview-kit/client' 16 | 17 | const client = createClient({ 18 | // ...base config options 19 | - encodeSourceMap: process.env.VERCEL_ENV === 'preview', 20 | + stega: { 21 | + enabled: process.env.VERCEL_ENV === 'preview', 22 | studioUrl: '/studio', 23 | + } 24 | }) 25 | ``` 26 | 27 | The `stega.enabled` option doesn't have any magic equivalent to `encodeSourceMap: "auto"`, which is the default behavior in `@sanity/preview-kit/client` in v5. You'll have to implement the environment variable checks yourself: 28 | 29 | ```diff 30 | import {createClient} from '@sanity/preview-kit/client' 31 | 32 | const client = createClient({ 33 | // ...base config options 34 | - encodeSourceMap: 'auto', // 'auto' is the default option 35 | + stega: { 36 | + enabled: process.env.SANITY_SOURCE_MAP === 'true' || process.env.VERCEL_ENV === 'preview', 37 | + studioUrl: process.env.SANITY_STUDIO_URL, 38 | + } 39 | }) 40 | ``` 41 | 42 | The `logger` option is moved to the `stega` group: 43 | 44 | ```diff 45 | const client = createClient({ 46 | // ...base config options 47 | + stega: { 48 | logger: console, 49 | + } 50 | }) 51 | ``` 52 | 53 | The `encodeSourceMapAtPath` is also moved, and has a new signature. You can re-use the same logic as before by adding a small wrapper: 54 | 55 | ```diff 56 | import {createClient} from '@sanity/preview-kit/client' 57 | 58 | function customFilterLogic({path, filterDefault}) { 59 | // ... 60 | } 61 | 62 | const client = createClient({ 63 | // ...base config options 64 | - encodeSourceMapAtPath: customFilterLogic, 65 | + stega: { 66 | + filter: (props) => 67 | + customFilterLogic({ 68 | + path: props.sourcePath, 69 | + filterDefault: () => props.filterDefault(props), 70 | + }) 71 | + } 72 | }) 73 | ``` 74 | 75 | # Upgrading from v1 to v2 76 | 77 | ## `usePreview` is now `useLiveQuery` 78 | 79 | The signature of `usePreview` is: 80 | 81 | ```tsx 82 | function usePreview, QueryString = string>( 83 | token: string | null, 84 | query: QueryString, 85 | params?: QueryParams, 86 | serverSnapshot?: QueryResult, 87 | ): QueryResult | null 88 | ``` 89 | 90 | While the signature of `useLiveQuery` is: 91 | 92 | ```tsx 93 | import type {QueryParams as ClientQueryParams} from '@sanity/client' 94 | 95 | type QueryLoading = boolean 96 | 97 | function useLiveQuery( 98 | initialData: QueryResult, 99 | query: string, 100 | params?: QueryParams, 101 | options?: { 102 | isEqual?: (a: QueryResult, b: QueryResult) => boolean 103 | }, 104 | ): [QueryResult, QueryLoading] 105 | ``` 106 | 107 | The main differences between the two hooks are: 108 | 109 | - `token` is no longer a hook argument, it's now provided by the `LiveQueryProvider` component. 110 | - generics are adjusted to match the behavior of `@sanity/client`'s `client.fetch` method. 111 | - `serverSnapshot` is now called `initialData` and is required. It's still used as the return value for a `getServerSnapshot` in the underlying `useSyncExternalStore` during SSR hydration to ensure you don't get mismatch errors. 112 | - It no longer returns `null` during the initial render. Instead, it returns `initialData` until the dataset export is finished and it's safe to run queries. 113 | - `PreviewSuspense` is no longer needed, instead you use `initialData` to implement either a `stale-while-revalidate` pattern or a fallback UI. 114 | 115 | ## `definePreview` and `PreviewSuspense` are replaced by `` 116 | 117 | The simplified signature for `definePreview` is: 118 | 119 | ```tsx 120 | import type { Config } from '@sanity/groq-store' 121 | 122 | export interface PreviewConfig extends Omit { 123 | onPublicAccessOnly?: () => void 124 | } 125 | 126 | const usePreview = definePreview(config: PreviewConfig) 127 | ``` 128 | 129 | While the signature for `` is: 130 | 131 | ```tsx 132 | import type {SanityClient} from '@sanity/client' 133 | 134 | export interface LiveQueryProviderProps { 135 | children: React.ReactNode 136 | client: SanityClient 137 | logger?: typeof console 138 | cache?: { 139 | /** @defaultValue 3000 */ 140 | maxDocuments?: number 141 | includeTypes?: string[] 142 | /** @defaultValue true */ 143 | listen?: boolean 144 | } 145 | /** @defaultValue 10000 */ 146 | refreshInterval?: number 147 | /** @defaultValue true */ 148 | turboSourceMap?: boolean 149 | } 150 | 151 | export function LiveQueryProvider(props: LiveQueryProviderProps): React.JSX.Element 152 | ``` 153 | 154 | The main differences between the two APIs are: 155 | 156 | - `LiveQueryProvider` is a React component, while `definePreview` is a top-level function call. 157 | - `definePreview` omits the `token` argument and instead requires you to pass it to the `usePreview` hook, while `LiveQueryProvider` handles `token` as part of the `client` instance. 158 | - A `Suspense` boundary is only required if you use `React.lazy` to code-split your app, otherwise it's optional. 159 | - The `onPublicAccessOnly` API is removed to speed up startup time by eliminating a waterfall of requests. Instead, it throws an error, and you can use a `ReactErrorBoundary` to handle it. 160 | - `LiveQueryProvider` is optional, when omitted the `useLiveQuery` hooks will fall back to a no-op mode, where it'll return `initialData` so it's safe to use in production. 161 | 162 | ## Migrating a component with a `stale-while-revalidate` pattern to the new hook 163 | 164 | Here's what a typical migration looks like, to keep the guide simple more advanced patterns like `React.lazy` are omitted: 165 | 166 | ```tsx 167 | import {createClient, type SanityClient} from '@sanity/client' 168 | import type {LoaderArgs} from '@vercel/remix' 169 | import {useLoaderData} from '@remix-run/react' 170 | import {definePreview, PreviewSuspense} from '@sanity/preview-kit' 171 | 172 | const projectId = 'pv8y60vp' 173 | const dataset = 'production' 174 | 175 | const query = `count(*[])` 176 | 177 | export function getClient({preview}: {preview?: {token: string}}): SanityClient { 178 | const client = createClient({ 179 | projectId, 180 | dataset, 181 | apiVersion: '2025-03-04', 182 | useCdn: true, 183 | }) 184 | if (preview) { 185 | if (!preview.token) { 186 | throw new Error('You must provide a token to preview drafts') 187 | } 188 | return client.withConfig({ 189 | token: preview.token, 190 | useCdn: false, 191 | ignoreBrowserTokenWarning: true, 192 | perspective: 'drafts', 193 | }) 194 | } 195 | return client 196 | } 197 | 198 | export async function loader({request}: LoaderArgs) { 199 | const token = process.env.SANITY_API_READ_TOKEN 200 | const preview = process.env.SANITY_API_PREVIEW_DRAFTS === 'true' ? {token} : undefined 201 | const client = getClient({preview}) 202 | 203 | const data = await client.fetch(query) 204 | 205 | return {preview, data} 206 | } 207 | 208 | export default function CountPage() { 209 | const {preview, data} = useLoaderData() 210 | 211 | const children = 212 | 213 | return ( 214 | <> 215 | {preview ? ( 216 | 217 | 218 | 219 | ) : ( 220 | children 221 | )} 222 | 223 | ) 224 | } 225 | 226 | const Count = ({data}: {data: number}) => ( 227 | <> 228 | Documents: {data} 229 | 230 | ) 231 | 232 | const usePreview: UsePreview = definePreview({projectId, dataset}) 233 | const PreviewCount = ({token}) => { 234 | const data = usePreview(token, query) 235 | return 236 | } 237 | ``` 238 | 239 | After migration, it looks like this: 240 | 241 | ```tsx 242 | import {useMemo} from 'react' 243 | import createClient from '@sanity/client' 244 | import type {LoaderArgs} from '@vercel/remix' 245 | import {useLoaderData} from '@remix-run/react' 246 | import {useLiveQuery, LiveQueryProvider} from '@sanity/preview-kit' 247 | 248 | const projectId = 'pv8y60vp' 249 | const dataset = 'production' 250 | 251 | const query = `count(*[])` 252 | 253 | export function getClient({preview}: {preview?: {token: string}}): SanityClient { 254 | const client = createClient({ 255 | projectId, 256 | dataset, 257 | apiVersion: '2025-03-04', 258 | useCdn: true, 259 | }) 260 | if (preview) { 261 | if (!preview.token) { 262 | throw new Error('You must provide a token to preview drafts') 263 | } 264 | return client.withConfig({ 265 | token: preview.token, 266 | useCdn: false, 267 | ignoreBrowserTokenWarning: true, 268 | perspective: 'drafts', 269 | }) 270 | } 271 | return client 272 | } 273 | 274 | export async function loader({request}: LoaderArgs) { 275 | const token = process.env.SANITY_API_READ_TOKEN 276 | const preview = process.env.SANITY_API_PREVIEW_DRAFTS === 'true' ? {token} : undefined 277 | const client = getClient({preview}) 278 | 279 | const data = await client.fetch(query) 280 | 281 | return {preview, data} 282 | } 283 | 284 | export default function CountPage() { 285 | const {preview, data} = useLoaderData() 286 | 287 | const children = 288 | 289 | return ( 290 | <>{preview ? {children} : children} 291 | ) 292 | } 293 | 294 | const Count = ({data: initialData}: {data: number}) => { 295 | const [data] = useLiveQuery(initialData, query) 296 | return ( 297 | <> 298 | Documents: {data} 299 | 300 | ) 301 | } 302 | 303 | function PreviewProvider({children, token}: {children: React.ReactNode; token: string}) { 304 | const client = useMemo(() => getClient({preview: {token}}), [token]) 305 | return {children} 306 | } 307 | ``` 308 | 309 | ## Migrating with a component using a Spinner fallback instead of `stale-while-revalidate` 310 | 311 | In this example a Spinner is displayed until groq-store is booted up and skips fetching data server side to speed up startup time. 312 | 313 | ```tsx 314 | import createClient from '@sanity/client' 315 | import type {LoaderArgs} from '@vercel/remix' 316 | import {useLoaderData} from '@remix-run/react' 317 | import {definePreview, PreviewSuspense} from '@sanity/preview-kit' 318 | 319 | import Spinner from '~/Spinner' 320 | 321 | const projectId = 'pv8y60vp' 322 | const dataset = 'production' 323 | 324 | const query = `count(*[])` 325 | 326 | export function getClient({preview}: {preview?: {token: string}}): SanityClient { 327 | const client = createClient({ 328 | projectId, 329 | dataset, 330 | apiVersion: '2025-03-04', 331 | useCdn: true, 332 | }) 333 | if (preview) { 334 | if (!preview.token) { 335 | throw new Error('You must provide a token to preview drafts') 336 | } 337 | return client.withConfig({ 338 | token: preview.token, 339 | useCdn: false, 340 | ignoreBrowserTokenWarning: true, 341 | perspective: 'drafts', 342 | }) 343 | } 344 | return client 345 | } 346 | 347 | export async function loader({request}: LoaderArgs) { 348 | const token = process.env.SANITY_API_READ_TOKEN 349 | const preview = process.env.SANITY_API_PREVIEW_DRAFTS === 'true' ? {token} : undefined 350 | const client = getClient({preview}) 351 | 352 | const data = preview ? null : await client.fetch(query) 353 | 354 | return {preview, data} 355 | } 356 | 357 | export default function CountPage() { 358 | const {preview, data} = useLoaderData() 359 | 360 | return ( 361 | <> 362 | {preview ? ( 363 | }> 364 | 365 | 366 | ) : ( 367 | 368 | )} 369 | 370 | ) 371 | } 372 | 373 | const Count = ({data}: {data: number}) => ( 374 | <> 375 | Documents: {data} 376 | 377 | ) 378 | 379 | const usePreview: UsePreview = definePreview({projectId, dataset}) 380 | const PreviewCount = ({token}) => { 381 | const data = usePreview(token, query) 382 | return 383 | } 384 | ``` 385 | 386 | After migration, it looks like this: 387 | 388 | ```tsx 389 | import createClient from '@sanity/client' 390 | import type {LoaderArgs} from '@vercel/remix' 391 | import {useLoaderData} from '@remix-run/react' 392 | import {useLiveQuery, LiveQueryProvider} from '@sanity/preview-kit' 393 | 394 | import Spinner from '~/Spinner' 395 | 396 | const projectId = 'pv8y60vp' 397 | const dataset = 'production' 398 | 399 | const query = `count(*[])` 400 | 401 | export function getClient({preview}: {preview?: {token: string}}): SanityClient { 402 | const client = createClient({ 403 | projectId, 404 | dataset, 405 | apiVersion: '2025-03-04', 406 | useCdn: true, 407 | }) 408 | if (preview) { 409 | if (!preview.token) { 410 | throw new Error('You must provide a token to preview drafts') 411 | } 412 | return client.withConfig({ 413 | token: preview.token, 414 | useCdn: false, 415 | ignoreBrowserTokenWarning: true, 416 | perspective: 'drafts', 417 | }) 418 | } 419 | return client 420 | } 421 | 422 | export async function loader({request}: LoaderArgs) { 423 | const token = process.env.SANITY_API_READ_TOKEN 424 | const preview = process.env.SANITY_API_PREVIEW_DRAFTS === 'true' ? {token} : undefined 425 | const client = getClient({preview}) 426 | 427 | const data = preview ? null : await client.fetch(query) 428 | 429 | return {preview, data} 430 | } 431 | 432 | export default function CountPage() { 433 | const {preview, data} = useLoaderData() 434 | 435 | return ( 436 | <> 437 | {preview ? ( 438 | 439 | 440 | 441 | ) : ( 442 | 443 | )} 444 | 445 | ) 446 | } 447 | 448 | const Count = ({data: initialData}: {data: number | null}) => { 449 | const [data, loading] = useLiveQuery(initialData, query) 450 | 451 | if (loading) { 452 | return 453 | } 454 | 455 | return ( 456 | <> 457 | Documents: {data} 458 | 459 | ) 460 | } 461 | 462 | function PreviewProvider({children, token}: {children: React.ReactNode; token: string}) { 463 | const client = useMemo(() => getClient({preview: {token}}), [token]) 464 | return {children} 465 | } 466 | ``` 467 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | packages/preview-kit/README.md -------------------------------------------------------------------------------- /apps/next-app-router/.eslintignore: -------------------------------------------------------------------------------- 1 | .next -------------------------------------------------------------------------------- /apps/next-app-router/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "extends": "next/core-web-vitals" 4 | } 5 | -------------------------------------------------------------------------------- /apps/next-app-router/.gitignore: -------------------------------------------------------------------------------- 1 | .vercel 2 | .env*.local 3 | -------------------------------------------------------------------------------- /apps/next-app-router/app/DraftModeButton.tsx: -------------------------------------------------------------------------------- 1 | import {draftMode} from 'next/headers' 2 | import {PreviewDraftsButton, ViewPublishedButton} from 'ui/react' 3 | 4 | export default async function DraftModeButton() { 5 | const {isEnabled} = await draftMode() 6 | return ( 7 |
8 | {isEnabled ? ( 9 | 10 | ) : ( 11 | 12 | )} 13 | 14 | ) 15 | } 16 | -------------------------------------------------------------------------------- /apps/next-app-router/app/RefreshButton.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import {useRouter} from 'next/navigation' 4 | import {useEffect, useTransition} from 'react' 5 | import {useFormStatus} from 'react-dom' 6 | import {Button} from 'ui/react' 7 | import {revalidate} from './actions' 8 | import {useIsEnabled} from '@sanity/preview-kit' 9 | 10 | function useRefresh() { 11 | const {pending} = useFormStatus() 12 | const router = useRouter() 13 | const [loading, startTransition] = useTransition() 14 | useEffect(() => { 15 | if (!pending) { 16 | startTransition(() => router.refresh()) 17 | } 18 | }, [pending, router]) 19 | 20 | return pending || loading 21 | } 22 | 23 | export default function RefreshButton() { 24 | const loading = useRefresh() 25 | const isLive = useIsEnabled() 26 | 27 | return ( 28 |
37 | 40 |
41 | ) 42 | } 43 | -------------------------------------------------------------------------------- /apps/next-app-router/app/actions.ts: -------------------------------------------------------------------------------- 1 | 'use server' 2 | 3 | import {revalidateTag} from 'next/cache' 4 | 5 | export async function revalidate() { 6 | await revalidateTag('pages') 7 | } 8 | -------------------------------------------------------------------------------- /apps/next-app-router/app/api/disable-draft/route.ts: -------------------------------------------------------------------------------- 1 | import {redirect} from 'next/navigation' 2 | 3 | import {draftMode} from 'next/headers' 4 | 5 | export async function GET(request: Request) { 6 | ;(await draftMode()).disable() 7 | redirect('/') 8 | } 9 | -------------------------------------------------------------------------------- /apps/next-app-router/app/api/draft/route.ts: -------------------------------------------------------------------------------- 1 | import {defineEnableDraftMode} from 'next-sanity/draft-mode' 2 | import {client} from '../../sanity.client' 3 | import {token} from '../../sanity.fetch' 4 | 5 | export const {GET} = defineEnableDraftMode({ 6 | client: client.withConfig({token}), 7 | }) 8 | -------------------------------------------------------------------------------- /apps/next-app-router/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import 'bulma/css/bulma.min.css' 2 | import {Container} from 'ui/react' 3 | import {unstable__adapter, unstable__environment} from '@sanity/preview-kit/client' 4 | import DraftModeButton from './DraftModeButton' 5 | import type {Metadata} from 'next' 6 | 7 | export const metadata: Metadata = { 8 | title: `next-app-router-${process.env.VARIANT || 'default'}`, 9 | } 10 | 11 | export default function RootLayout({children}: {children: React.ReactNode}) { 12 | return ( 13 | 14 | 15 | 16 | 17 | 18 | {children} 19 |