├── .github ├── CODEOWNERS ├── renovate.json └── workflows │ ├── ci.yml │ ├── fix-lockfile.yml │ ├── lock.yml │ ├── prettier.yml │ ├── release-canary.yml │ └── release-please.yml ├── .gitignore ├── .prettierignore ├── .release-please-manifest.json ├── LICENSE ├── README.md ├── apps ├── mvp │ ├── .env.local.example │ ├── .eslintrc.cjs │ ├── .gitignore │ ├── app │ │ ├── (sanity) │ │ │ ├── layout.tsx │ │ │ └── studio │ │ │ │ └── page.tsx │ │ ├── (website) │ │ │ ├── Image.tsx │ │ │ ├── PostsLayout.tsx │ │ │ ├── actions.ts │ │ │ ├── layout.tsx │ │ │ ├── live.ts │ │ │ └── page.tsx │ │ ├── api │ │ │ ├── draft-mode │ │ │ │ └── enable │ │ │ │ │ └── route.ts │ │ │ └── revalidate-tag │ │ │ │ └── route.ts │ │ ├── globals.css │ │ └── sanity.client.ts │ ├── next-env.d.ts │ ├── next.config.ts │ ├── package.json │ ├── postcss.config.cjs │ ├── sanity.cli.ts │ ├── sanity.config.ts │ ├── tailwind.config.cjs │ ├── tsconfig.json │ └── turbo.json └── static │ ├── .env.local.example │ ├── .eslintrc.cjs │ ├── app │ ├── Image.tsx │ ├── PostsLayout.tsx │ ├── globals.css │ ├── layout.tsx │ ├── page.tsx │ ├── sanity.client.ts │ ├── sanity.fetch.ts │ └── studio │ │ └── page.tsx │ ├── next-env.d.ts │ ├── next.config.mjs │ ├── package.json │ ├── postcss.config.cjs │ ├── sanity.cli.ts │ ├── sanity.config.ts │ ├── tailwind.config.cjs │ ├── tsconfig.json │ └── turbo.json ├── package.json ├── packages ├── next-sanity │ ├── .eslintignore │ ├── .eslintrc.cjs │ ├── .npmrc │ ├── CHANGELOG.md │ ├── MIGRATE-v1-to-v4.md │ ├── MIGRATE-v4-to-v5-app-router.md │ ├── MIGRATE-v4-to-v5-pages-router.md │ ├── MIGRATE-v5-to-v6.md │ ├── MIGRATE-v6-to-v7.md │ ├── MIGRATE-v7-to-v8.md │ ├── MIGRATE-v8-to-v9.md │ ├── PREVIEW-app-router.md │ ├── PREVIEW-pages-router.md │ ├── README.md │ ├── package.config.ts │ ├── package.json │ ├── src │ │ ├── client.ts │ │ ├── create-data-attribute.ts │ │ ├── draft-mode │ │ │ ├── define-enable-draft-mode.ts │ │ │ └── index.ts │ │ ├── hooks │ │ │ └── index.ts │ │ ├── image │ │ │ ├── Image.tsx │ │ │ ├── imageLoader.ts │ │ │ └── index.ts │ │ ├── index.edge-light.ts │ │ ├── index.ts │ │ ├── preview │ │ │ ├── LiveQuery │ │ │ │ └── index.ts │ │ │ └── index.ts │ │ ├── studio │ │ │ ├── NextStudioLayout.tsx │ │ │ ├── NextStudioNoScript.tsx │ │ │ ├── NextStudioWithBridge.tsx │ │ │ ├── client-component │ │ │ │ ├── NextStudio.tsx │ │ │ │ ├── NextStudioLazy.tsx │ │ │ │ ├── createHashHistoryForStudio.ts │ │ │ │ ├── index.ts │ │ │ │ ├── registry.tsx │ │ │ │ └── useIsMounted.ts │ │ │ ├── head.tsx │ │ │ └── index.ts │ │ ├── visual-editing │ │ │ ├── client-component │ │ │ │ ├── VisualEditing.tsx │ │ │ │ ├── VisualEditingLazy.tsx │ │ │ │ ├── index.ts │ │ │ │ └── utils.ts │ │ │ ├── index.tsx │ │ │ └── server-actions │ │ │ │ └── index.ts │ │ └── webhook │ │ │ └── index.ts │ ├── test │ │ ├── imageLoader.test.ts │ │ └── verifyHistoryVersion.test.ts │ ├── tsconfig.base.json │ ├── tsconfig.build.json │ ├── tsconfig.json │ ├── turbo.json │ └── vite.config.ts ├── sanity-config │ ├── package.json │ ├── src │ │ ├── index.tsx │ │ └── schemas │ │ │ ├── author.ts │ │ │ ├── blockContent.ts │ │ │ ├── category.ts │ │ │ ├── index.ts │ │ │ └── post.ts │ └── tsconfig.json └── typescript-config │ ├── base.json │ └── package.json ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── prettier.config.mjs ├── release-please-config.json └── turbo.json /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @sanity-io/ecosystem 2 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "github>sanity-io/renovate-config", 5 | "github>sanity-io/renovate-config:studio-v3", 6 | ":reviewer(team:ecosystem)" 7 | ], 8 | "baseBranches": ["$default", "canary"], 9 | "packageRules": [ 10 | { 11 | "group": {"semanticCommitType": "chore"}, 12 | "matchDepTypes": [ 13 | "dependencies", 14 | "devDependencies", 15 | "engines", 16 | "optionalDependencies", 17 | "peerDependencies" 18 | ], 19 | "matchManagers": ["npm"], 20 | "semanticCommitType": "chore", 21 | "description": "Group all dependencies from the app directory", 22 | "matchFileNames": ["apps/**/package.json"], 23 | "groupName": "App dependencies" 24 | }, 25 | { 26 | "matchDepTypes": ["dependencies", "peerDependencies"], 27 | "matchPackageNames": [ 28 | "@sanity/client", 29 | "@sanity/icons", 30 | "@sanity/next-loader", 31 | "@sanity/preview-kit", 32 | "@sanity/preview-url-secret", 33 | "@sanity/ui", 34 | "@sanity/types", 35 | "@sanity/webhook", 36 | "@sanity/visual-editing", 37 | "@portabletext/react", 38 | "@portabletext/types", 39 | "groq", 40 | "sanity" 41 | ], 42 | "rangeStrategy": "bump", 43 | "matchFileNames": ["packages/next-sanity/package.json"], 44 | "semanticCommitType": "fix" 45 | }, 46 | { 47 | "matchDepNames": ["next", "@next/bundle-analyzer", "@next/env", "eslint-config-next"], 48 | "matchFileNames": ["package.json"], 49 | "followTag": "canary" 50 | }, 51 | { 52 | "matchDepNames": ["react", "react-dom", "react-is"], 53 | "matchFileNames": ["apps/mvp/package.json", "apps/static/package.json"], 54 | "followTag": "rc" 55 | }, 56 | { 57 | "matchDepNames": ["babel-plugin-react-compiler", "eslint-plugin-react-compiler"], 58 | "followTag": "beta" 59 | } 60 | ], 61 | "ignorePresets": [":ignoreModulesAndTests", "github>sanity-io/renovate-config:group-non-major"] 62 | } 63 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: CI 3 | 4 | on: 5 | merge_group: 6 | pull_request: 7 | types: [opened, synchronize] 8 | push: 9 | branches: [main] 10 | 11 | concurrency: 12 | group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} 13 | cancel-in-progress: true 14 | 15 | permissions: 16 | contents: read # for checkout 17 | 18 | jobs: 19 | build: 20 | runs-on: ubuntu-latest 21 | env: 22 | TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} 23 | TURBO_TEAM: ${{ vars.TURBO_TEAM }} 24 | steps: 25 | - uses: actions/checkout@v4 26 | - uses: pnpm/action-setup@v4 27 | - uses: actions/setup-node@v4 28 | with: 29 | cache: pnpm 30 | node-version: lts/* 31 | - run: pnpm install --ignore-scripts 32 | - run: pnpm build --filter=./packages/* 33 | - run: pnpm lint 34 | - run: pnpm type-check 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 | steps: 60 | # It's only necessary to do this for windows, as mac and ubuntu are sane OS's that already use LF 61 | - if: matrix.os == 'windows-latest' 62 | run: | 63 | git config --global core.autocrlf false 64 | git config --global core.eol lf 65 | - uses: actions/checkout@v4 66 | - uses: pnpm/action-setup@v4 67 | - uses: actions/setup-node@v4 68 | with: 69 | cache: pnpm 70 | node-version: ${{ matrix.node }} 71 | - run: pnpm install --loglevel=error --ignore-scripts 72 | - run: pnpm test 73 | working-directory: packages/next-sanity 74 | -------------------------------------------------------------------------------- /.github/workflows/fix-lockfile.yml: -------------------------------------------------------------------------------- 1 | name: Fix Lockfile 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | concurrency: 7 | group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} 8 | cancel-in-progress: true 9 | 10 | permissions: 11 | contents: read # for checkout 12 | 13 | jobs: 14 | run: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v4 18 | - uses: pnpm/action-setup@v4 19 | - uses: actions/setup-node@v4 20 | with: 21 | cache: pnpm 22 | node-version: lts/* 23 | - run: pnpm install --ignore-scripts --fix-lockfile --no-frozen-lockfile 24 | - run: pnpm dedupe 25 | - uses: actions/create-github-app-token@v2 26 | id: generate-token 27 | with: 28 | app-id: ${{ secrets.ECOSPARK_APP_ID }} 29 | private-key: ${{ secrets.ECOSPARK_APP_PRIVATE_KEY }} 30 | - uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7 31 | with: 32 | body: I ran `pnpm install --fix-lockfile` 🧑‍💻 33 | branch: "actions/fix-lockfile-${{ github.ref_name }}" 34 | commit-message: "chore(lockfile): 🤖 ✨" 35 | labels: 🤖 bot 36 | sign-commits: true 37 | title: "chore(lockfile): 🤖 ✨" 38 | token: ${{ steps.generate-token.outputs.token }} 39 | -------------------------------------------------------------------------------- /.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/prettier.yml: -------------------------------------------------------------------------------- 1 | name: Prettier 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | workflow_dispatch: 7 | 8 | concurrency: 9 | group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} 10 | cancel-in-progress: true 11 | 12 | permissions: 13 | contents: read # for checkout 14 | 15 | jobs: 16 | run: 17 | name: Can the code be prettier? 🤔 18 | runs-on: ubuntu-latest 19 | steps: 20 | - uses: actions/checkout@v4 21 | - uses: pnpm/action-setup@v4 22 | - uses: actions/setup-node@v4 23 | with: 24 | cache: pnpm 25 | node-version: lts/* 26 | - run: pnpm install --dev --ignore-scripts 27 | - uses: actions/cache@v4 28 | with: 29 | path: node_modules/.cache/prettier/.prettier-cache 30 | key: prettier-${{ hashFiles('pnpm-lock.yaml') }}-${{ hashFiles('.prettierignore') }}-${{ hashFiles('package.json') }} 31 | - run: pnpm format 32 | - run: git restore .github/workflows pnpm-lock.yaml packages/next-sanity/CHANGELOG.md 33 | - uses: actions/create-github-app-token@v2 34 | id: generate-token 35 | with: 36 | app-id: ${{ secrets.ECOSPARK_APP_ID }} 37 | private-key: ${{ secrets.ECOSPARK_APP_PRIVATE_KEY }} 38 | - uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7 39 | with: 40 | body: I ran `pnpm format` 🧑‍💻 41 | branch: actions/prettier 42 | commit-message: "chore(prettier): 🤖 ✨" 43 | labels: 🤖 bot 44 | sign-commits: true 45 | title: "chore(prettier): 🤖 ✨" 46 | token: ${{ steps.generate-token.outputs.token }} 47 | -------------------------------------------------------------------------------- /.github/workflows/release-canary.yml: -------------------------------------------------------------------------------- 1 | name: Release Canary 2 | 3 | on: workflow_dispatch 4 | 5 | concurrency: 6 | group: ${{ github.workflow }} 7 | cancel-in-progress: true 8 | 9 | permissions: 10 | contents: read # for checkout 11 | 12 | jobs: 13 | release-canary: 14 | permissions: 15 | contents: read # for checkout 16 | id-token: write # to enable use of OIDC for npm provenance 17 | runs-on: ubuntu-latest 18 | env: 19 | TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} 20 | TURBO_TEAM: ${{ vars.TURBO_TEAM }} 21 | steps: 22 | - uses: actions/create-github-app-token@v2 23 | id: generate-token 24 | with: 25 | app-id: ${{ secrets.ECOSPARK_APP_ID }} 26 | private-key: ${{ secrets.ECOSPARK_APP_PRIVATE_KEY }} 27 | - uses: actions/checkout@v4 28 | with: 29 | ref: canary 30 | token: ${{ steps.generate-token.outputs.token }} 31 | - uses: pnpm/action-setup@v4 32 | - uses: actions/setup-node@v4 33 | with: 34 | cache: pnpm 35 | node-version: lts/* 36 | - run: pnpm install --ignore-scripts 37 | - run: pnpm config set '//registry.npmjs.org/:_authToken' "${NODE_AUTH_TOKEN}" 38 | env: 39 | NODE_AUTH_TOKEN: ${{secrets.NPM_PUBLISH_TOKEN}} 40 | - name: release canary & commit + push the changed versions 41 | env: 42 | NPM_CONFIG_PROVENANCE: true 43 | run: | 44 | pnpm --filter="next-sanity" exec pnpm version --no-commit-hooks --no-git-tag-version --preid canary prerelease 45 | pnpm --filter="next-sanity" publish --tag canary --no-git-checks 46 | git config user.name "github-actions[bot]" 47 | git config user.email "41898282+github-actions[bot]@users.noreply.github.com" 48 | git add . 49 | git commit -m "chore(release): publish canary [skip ci]" 50 | git push 51 | -------------------------------------------------------------------------------- /.github/workflows/release-please.yml: -------------------------------------------------------------------------------- 1 | name: Release Please 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | workflow_dispatch: 8 | inputs: 9 | publish: 10 | description: Publish to NPM 11 | required: true 12 | default: false 13 | type: boolean 14 | 15 | permissions: 16 | contents: read # for checkout 17 | 18 | jobs: 19 | release-please: 20 | permissions: 21 | contents: read # for checkout 22 | id-token: write # to enable use of OIDC for npm provenance 23 | runs-on: ubuntu-latest 24 | env: 25 | TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} 26 | TURBO_TEAM: ${{ vars.TURBO_TEAM }} 27 | steps: 28 | - uses: actions/create-github-app-token@v2 29 | id: generate-token 30 | with: 31 | app-id: ${{ secrets.ECOSPARK_APP_ID }} 32 | private-key: ${{ secrets.ECOSPARK_APP_PRIVATE_KEY }} 33 | # This action will create a release PR when regular conventional commits are pushed to main, it'll also detect if a release PR is merged and npm publish should happen 34 | - uses: googleapis/release-please-action@v4 35 | id: release 36 | with: 37 | token: ${{ steps.generate-token.outputs.token }} 38 | 39 | # Publish to NPM on new releases 40 | - uses: actions/checkout@v4 41 | if: ${{ steps.release.outputs.releases_created == 'true' || github.event.inputs.publish == 'true' }} 42 | - uses: pnpm/action-setup@v4 43 | if: ${{ steps.release.outputs.releases_created == 'true' || github.event.inputs.publish == 'true' }} 44 | - uses: actions/setup-node@v4 45 | if: ${{ steps.release.outputs.releases_created == 'true' || github.event.inputs.publish == 'true' }} 46 | with: 47 | cache: pnpm 48 | node-version: lts/* 49 | - name: install deps & build 50 | run: pnpm install --ignore-scripts && pnpm build --filter=!./apps/* 51 | if: ${{ steps.release.outputs.releases_created == 'true' || github.event.inputs.publish == 'true' }} 52 | - name: Set publishing config 53 | run: pnpm config set '//registry.npmjs.org/:_authToken' "${NODE_AUTH_TOKEN}" 54 | if: ${{ steps.release.outputs.releases_created == 'true' || github.event.inputs.publish == 'true' }} 55 | env: 56 | NODE_AUTH_TOKEN: ${{secrets.NPM_PUBLISH_TOKEN}} 57 | # Release Please has already incremented versions and published tags, so we just 58 | # need to publish all unpublished versions to NPM here 59 | - run: pnpm -r publish 60 | if: ${{ steps.release.outputs.releases_created == 'true' || github.event.inputs.publish == 'true' }} 61 | env: 62 | NPM_CONFIG_PROVENANCE: true 63 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Optional stylelint cache 58 | .stylelintcache 59 | 60 | # Microbundle cache 61 | .rpt2_cache/ 62 | .rts2_cache_cjs/ 63 | .rts2_cache_es/ 64 | .rts2_cache_umd/ 65 | 66 | # Optional REPL history 67 | .node_repl_history 68 | 69 | # Output of 'npm pack' 70 | *.tgz 71 | 72 | # Yarn Integrity file 73 | .yarn-integrity 74 | 75 | # dotenv environment variable files 76 | .env 77 | .env.development.local 78 | .env.test.local 79 | .env.production.local 80 | .env.local 81 | 82 | # parcel-bundler cache (https://parceljs.org/) 83 | .cache 84 | .parcel-cache 85 | 86 | # Next.js build output 87 | .next 88 | out 89 | 90 | # Nuxt.js build / generate output 91 | .nuxt 92 | dist 93 | 94 | # Gatsby files 95 | .cache/ 96 | # Comment in the public line in if your project uses Gatsby and not Next.js 97 | # https://nextjs.org/blog/next-9-1#public-directory-support 98 | # public 99 | 100 | # vuepress build output 101 | .vuepress/dist 102 | 103 | # vuepress v2.x temp and cache directory 104 | .temp 105 | 106 | # Docusaurus cache and generated files 107 | .docusaurus 108 | 109 | # Serverless directories 110 | .serverless/ 111 | 112 | # FuseBox cache 113 | .fusebox/ 114 | 115 | # DynamoDB Local files 116 | .dynamodb/ 117 | 118 | # TernJS port file 119 | .tern-port 120 | 121 | # Stores VSCode versions used for testing VSCode extensions 122 | .vscode-test 123 | 124 | # yarn v2 125 | .yarn/cache 126 | .yarn/unplugged 127 | .yarn/build-state.yml 128 | .yarn/install-state.gz 129 | .pnp.* 130 | 131 | yarn.lock 132 | package-lock.json 133 | 134 | *.iml 135 | .idea/ 136 | .vercel 137 | .turbo 138 | .vscode 139 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | coverage 2 | .next 3 | out 4 | dist 5 | .vercel 6 | .turbo 7 | .vscode 8 | CHANGELOG.md 9 | pnpm-lock.yaml 10 | pnpm-workspace.yaml 11 | .release-please-manifest.json 12 | -------------------------------------------------------------------------------- /.release-please-manifest.json: -------------------------------------------------------------------------------- 1 | {"packages/next-sanity":"9.12.0"} 2 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ./packages/next-sanity/README.md -------------------------------------------------------------------------------- /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/mvp/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const {readGitignoreFiles} = require('eslint-gitignore') 3 | 4 | module.exports = { 5 | root: true, 6 | parserOptions: { 7 | ecmaVersion: 2020, 8 | sourceType: 'module', 9 | }, 10 | ignorePatterns: readGitignoreFiles({cwd: path.resolve('../..', __dirname)}), 11 | plugins: ['simple-import-sort'], 12 | extends: ['next/core-web-vitals', 'prettier'], 13 | rules: { 14 | 'simple-import-sort/imports': 'warn', 15 | 'simple-import-sort/exports': 'warn', 16 | 'jsx-a11y/alt-text': 'off', 17 | }, 18 | } 19 | -------------------------------------------------------------------------------- /apps/mvp/.gitignore: -------------------------------------------------------------------------------- 1 | .vercel 2 | .env*.local 3 | public/studio/static 4 | -------------------------------------------------------------------------------- /apps/mvp/app/(sanity)/layout.tsx: -------------------------------------------------------------------------------- 1 | import '../globals.css' 2 | 3 | export {metadata, viewport} from 'next-sanity/studio' 4 | 5 | export const dynamic = 'error' 6 | 7 | export default function RootLayout({children}: {children: React.ReactNode}) { 8 | return ( 9 | 10 | 11 | {children} 12 | 13 | ) 14 | } 15 | -------------------------------------------------------------------------------- /apps/mvp/app/(sanity)/studio/page.tsx: -------------------------------------------------------------------------------- 1 | import {NextStudio} from 'next-sanity/studio' 2 | 3 | import config from '@/sanity.config' 4 | 5 | export const dynamic = 'error' 6 | 7 | export default function StudioPage() { 8 | return 9 | } 10 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /apps/mvp/app/(website)/PostsLayout.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @next/next/no-img-element */ 2 | import {q, sanityImage} from 'groqd' 3 | import {createDataAttribute} from 'next-sanity' 4 | import {memo} from 'react' 5 | 6 | import {Image} from './Image' 7 | 8 | const {query, schema} = q('*') 9 | .filter("_type == 'post'") 10 | .grab({ 11 | _id: q.string(), 12 | _type: q.literal('post'), 13 | title: q.string().nullable(), 14 | slug: q('slug').grabOne('current', q.string().optional()), 15 | mainImage: sanityImage('mainImage', { 16 | withCrop: true, 17 | withHotspot: true, 18 | additionalFields: { 19 | alt: q.string().nullish(), 20 | }, 21 | }).nullable(), 22 | publishedAt: q.date().nullable(), 23 | author: q('author') 24 | .deref() 25 | .grab({ 26 | name: q.string().optional(), 27 | image: sanityImage('image', { 28 | withCrop: true, 29 | withHotspot: true, 30 | additionalFields: { 31 | alt: q.string().nullish(), 32 | }, 33 | }), 34 | }) 35 | .nullable(), 36 | status: q.select({ 37 | '_originalId in path("drafts.**")': ['"draft"', q.literal('draft')], 38 | 'default': ['"published"', q.literal('published')], 39 | }), 40 | }) 41 | .order('publishedAt desc', '_updatedAt desc') 42 | 43 | export {query} 44 | 45 | export type PostsLayoutProps = { 46 | data: unknown[] 47 | loading?: boolean 48 | draftMode: boolean 49 | } 50 | 51 | const PostsLayout = memo(function Posts(props: PostsLayoutProps) { 52 | const posts = schema.parse(props.data) 53 | 54 | return ( 55 |
60 | {posts.map((post) => { 61 | const dataAttribute = createDataAttribute({ 62 | baseUrl: '/studio', 63 | workspace: 'stable', 64 | id: post._id, 65 | type: post._type, 66 | }) 67 | return ( 68 |
73 |
74 | {post.mainImage ? ( 75 | 81 | ) : null} 82 |
83 |
84 | 89 |
90 | {post.author ? ( 91 |
92 | {post.author.name} 93 | {post.author?.image ? ( 94 | 100 | ) : null} 101 |
102 | ) : null} 103 |
104 | {post.author ? ( 105 |

106 | {post.author.name} 107 |

108 | ) : null} 109 | {post.publishedAt ? ( 110 |
111 | 114 | 115 |
116 | ) : null} 117 |
118 |
119 |
120 | {props.draftMode && post.status === 'draft' && ( 121 | 122 | {post.status} 123 | 124 | )} 125 |
126 | ) 127 | })} 128 |
129 | ) 130 | }) 131 | 132 | export default PostsLayout 133 | -------------------------------------------------------------------------------- /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)/layout.tsx: -------------------------------------------------------------------------------- 1 | import '../globals.css' 2 | 3 | import {draftMode} from 'next/headers' 4 | import {VisualEditing} from 'next-sanity' 5 | 6 | import {SanityLive} from './live' 7 | 8 | export default async function RootLayout({children}: {children: React.ReactNode}) { 9 | return ( 10 | 11 | 12 | 13 | {children} 14 | {(await draftMode()).isEnabled && } 15 | 16 | 17 | 18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /apps/mvp/app/(website)/live.ts: -------------------------------------------------------------------------------- 1 | import {defineLive} from 'next-sanity' 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 | -------------------------------------------------------------------------------- /apps/mvp/app/(website)/page.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @next/next/no-html-link-for-pages */ 2 | import {draftMode} from 'next/headers' 3 | import Link from 'next/link' 4 | import {unstable__adapter, unstable__environment} from 'next-sanity' 5 | 6 | import PostsLayout, {query} from '@/app/(website)/PostsLayout' 7 | 8 | import {sanityFetch} from './live' 9 | 10 | export default async function IndexPage() { 11 | const {data} = await sanityFetch({query}) 12 | 13 | return ( 14 | <> 15 |
20 |
21 |
22 |
23 |
24 |
25 |

26 | Posts {(await draftMode()).isEnabled && '(Draft Mode)'} 27 |

28 |
29 | 30 |
31 |
32 |
33 | 38 | Open Studio 39 | 40 |
41 | 42 | ) 43 | } 44 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /apps/mvp/app/api/revalidate-tag/route.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-process-env */ 2 | import {revalidateTag} from 'next/cache' 3 | import {type NextRequest, NextResponse} from 'next/server' 4 | import {parseBody} from 'next-sanity/webhook' 5 | 6 | // Triggers a revalidation of the static data in the example above 7 | export async function POST(req: NextRequest): Promise { 8 | try { 9 | const {body, isValidSignature} = await parseBody<{ 10 | _type: string 11 | _id: string 12 | slug?: string | undefined 13 | }>(req as any, process.env.SANITY_REVALIDATE_SECRET) 14 | if (!isValidSignature) { 15 | const message = 'Invalid signature' 16 | return new Response(message, {status: 401}) 17 | } 18 | 19 | if (!body?._type) { 20 | return new Response('Bad Request', {status: 400}) 21 | } 22 | 23 | await Promise.all( 24 | [body.slug, body._type, body._id].map((tag) => typeof tag === 'string' && revalidateTag(tag)), 25 | ) 26 | return NextResponse.json({...body, router: 'app'}) 27 | } catch (err: any) { 28 | console.error(err) 29 | return new Response(err.message, {status: 500}) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /apps/mvp/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /apps/mvp/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. 6 | -------------------------------------------------------------------------------- /apps/mvp/next.config.ts: -------------------------------------------------------------------------------- 1 | import type {NextConfig} from 'next' 2 | 3 | import withBundleAnalyzer from '@next/bundle-analyzer' 4 | 5 | // function requireResolve(id) { 6 | // return import.meta.resolve(id).replace('file://', '') 7 | // } 8 | 9 | const nextConfig: NextConfig = { 10 | // basePath: process.env.NEXT_PUBLIC_TEST_BASE_PATH, 11 | // trailingSlash: true, 12 | experimental: { 13 | // reactCompiler: true, 14 | // turbo: { 15 | // resolveAlias: { 16 | // // '@sanity/vision': '@sanity/vision/lib/index.mjs', 17 | // // 'sanity/_singletons': 'sanity/lib/_singletons.mjs', 18 | // // 'sanity/desk': 'sanity/lib/desk.mjs', 19 | // // 'sanity/presentation': 'sanity/lib/presentation.mjs', 20 | // // 'sanity/router': 'sanity/lib/router.mjs', 21 | // // 'sanity/structure': 'sanity/lib/structure.mjs', 22 | // // sanity: 'sanity/lib/index.mjs', 23 | // 'react-rx': { 24 | // browser: 'react-rx/dist/index.compiled.js', 25 | // }, 26 | // }, 27 | // }, 28 | }, 29 | logging: { 30 | fetches: { 31 | fullUrl: false, 32 | }, 33 | }, 34 | productionBrowserSourceMaps: true, 35 | env: { 36 | // Matches the behavior of `sanity dev` which sets styled-components to use the fastest way of inserting CSS rules in both dev and production. It's default behavior is to disable it in dev mode. 37 | SC_DISABLE_SPEEDY: 'false', 38 | }, 39 | 40 | async headers() { 41 | return [ 42 | { 43 | // Speedup page load and support prefetch techniques 44 | source: '/studio/(.*)?', // Matches all studio routes 45 | headers: [ 46 | { 47 | key: 'Cache-Control', 48 | value: 'public, max-age=5', 49 | }, 50 | ], 51 | }, 52 | ] 53 | }, 54 | } 55 | 56 | export default withBundleAnalyzer({ 57 | // eslint-disable-next-line no-process-env 58 | enabled: process.env.ANALYZE === 'true', 59 | })(nextConfig) 60 | -------------------------------------------------------------------------------- /apps/mvp/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mvp", 3 | "version": "1.0.0", 4 | "private": true, 5 | "type": "module", 6 | "scripts": { 7 | "build": "next build --profile && sanity manifest extract --path public/studio/static", 8 | "dev": "next dev --turbo", 9 | "format": "next lint --fix .", 10 | "lint": "next lint --max-warnings 0 .", 11 | "start": "next start", 12 | "type-check": "tsc --noEmit" 13 | }, 14 | "dependencies": { 15 | "@repo/sanity-config": "workspace:*", 16 | "@sanity/image-url": "^1.1.0", 17 | "@sanity/preview-url-secret": "^2.1.6", 18 | "@sanity/vision": "^3.88.0", 19 | "babel-plugin-react-compiler": "beta", 20 | "groqd": "^0.15.12", 21 | "next": "^15.4.0-canary", 22 | "next-sanity": "workspace:*", 23 | "react": "^19.0.0", 24 | "react-dom": "^19.0.0", 25 | "sanity": "^3.88.0" 26 | }, 27 | "devDependencies": { 28 | "@next/bundle-analyzer": "^15.3.2", 29 | "@next/env": "^15.3.2", 30 | "@repo/typescript-config": "workspace:*", 31 | "@types/react": "^19.1.3", 32 | "@typescript-eslint/eslint-plugin": "^8.32.0", 33 | "autoprefixer": "^10.4.21", 34 | "eslint": "^9.26.0", 35 | "eslint-config-next": "^15.3.2", 36 | "eslint-config-prettier": "^10.1.3", 37 | "eslint-gitignore": "^0.1.0", 38 | "eslint-plugin-simple-import-sort": "^12.1.1", 39 | "postcss": "^8.5.3", 40 | "tailwindcss": "^3.4.17", 41 | "typescript": "5.8.3" 42 | }, 43 | "engines": { 44 | "node": "20 || 22" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /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: {}, 6 | autoprefixer: {}, 7 | }, 8 | } 9 | -------------------------------------------------------------------------------- /apps/mvp/sanity.cli.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-process-env */ 2 | import {loadEnvConfig} from '@next/env' 3 | import {defineCliConfig} from 'sanity/cli' 4 | 5 | const dev = process.env.NODE_ENV !== 'production' 6 | loadEnvConfig(__dirname, dev, {info: () => null, error: console.error}) 7 | 8 | const projectId = process.env.NEXT_PUBLIC_SANITY_PROJECT_ID 9 | const dataset = process.env.NEXT_PUBLIC_SANITY_DATASET 10 | 11 | export default defineCliConfig({ 12 | api: {projectId, dataset}, 13 | studioHost: `https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}/studio`, 14 | }) 15 | -------------------------------------------------------------------------------- /apps/mvp/sanity.config.ts: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | /* eslint-disable no-restricted-globals,no-process-env */ 4 | 5 | import sharedConfig from '@repo/sanity-config' 6 | import {debugSecrets} from '@sanity/preview-url-secret/sanity-plugin-debug-secrets' 7 | import {defineConfig} from 'sanity' 8 | import {presentationTool, type PreviewUrlResolverOptions} from 'sanity/presentation' 9 | 10 | const projectId = process.env.NEXT_PUBLIC_SANITY_PROJECT_ID! 11 | const dataset = process.env.NEXT_PUBLIC_SANITY_DATASET! 12 | 13 | const previewMode = { 14 | enable: `${process.env.NEXT_PUBLIC_TEST_BASE_PATH || ''}/api/draft-mode/enable`, 15 | } satisfies PreviewUrlResolverOptions['previewMode'] 16 | 17 | export default defineConfig({ 18 | title: 'next-sanity', 19 | projectId, 20 | dataset, 21 | 22 | plugins: [ 23 | presentationTool({ 24 | previewUrl: {preview: `${process.env.NEXT_PUBLIC_TEST_BASE_PATH || ''}/` || '/', previewMode}, 25 | }), 26 | sharedConfig(), 27 | debugSecrets(), 28 | ], 29 | }) 30 | -------------------------------------------------------------------------------- /apps/mvp/tailwind.config.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: ['./app/**/*.{js,ts,jsx,tsx}', './pages/**/*.{js,ts,jsx,tsx}'], 4 | theme: {}, 5 | plugins: [], 6 | } 7 | -------------------------------------------------------------------------------- /apps/mvp/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@repo/typescript-config/base.json", 3 | "compilerOptions": { 4 | "plugins": [ 5 | { 6 | "name": "next" 7 | } 8 | ], 9 | "paths": { 10 | "@/*": ["./*"] 11 | } 12 | }, 13 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 14 | "exclude": ["node_modules"] 15 | } 16 | -------------------------------------------------------------------------------- /apps/mvp/turbo.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://turbo.build/schema.json", 3 | "extends": ["//"], 4 | "tasks": { 5 | "build": { 6 | "env": [ 7 | "ANALYZE", 8 | "NEXT_PUBLIC_SANITY_PROJECT_ID", 9 | "NEXT_PUBLIC_SANITY_DATASET", 10 | "NEXT_PUBLIC_TEST_BASE_PATH", 11 | "SANITY_API_READ_TOKEN" 12 | ], 13 | "outputs": [".next/**", "!.next/cache/**", "public/studio/static/**"] 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /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=... -------------------------------------------------------------------------------- /apps/static/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const {readGitignoreFiles} = require('eslint-gitignore') 3 | 4 | module.exports = { 5 | root: true, 6 | parserOptions: { 7 | ecmaVersion: 2020, 8 | sourceType: 'module', 9 | }, 10 | ignorePatterns: readGitignoreFiles({cwd: path.resolve('../..', __dirname)}), 11 | plugins: ['simple-import-sort'], 12 | extends: ['next/core-web-vitals', 'prettier'], 13 | rules: { 14 | 'simple-import-sort/imports': 'warn', 15 | 'simple-import-sort/exports': 'warn', 16 | 'jsx-a11y/alt-text': 'off', 17 | }, 18 | } 19 | -------------------------------------------------------------------------------- /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/static/app/PostsLayout.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @next/next/no-img-element */ 2 | import {q, sanityImage} from 'groqd' 3 | import {memo} from 'react' 4 | 5 | import {Image} from './Image' 6 | 7 | const {query, schema} = q('*') 8 | .filter("_type == 'post'") 9 | .grab({ 10 | _id: q.string(), 11 | _type: q.literal('post'), 12 | title: q.string().optional(), 13 | slug: q('slug').grabOne('current', q.string().optional()), 14 | mainImage: sanityImage('mainImage', { 15 | withCrop: true, 16 | withHotspot: true, 17 | additionalFields: { 18 | alt: q.string().nullish(), 19 | }, 20 | }), 21 | publishedAt: q.date().optional(), 22 | author: q('author') 23 | .deref() 24 | .grab({ 25 | name: q.string().optional(), 26 | image: sanityImage('image', { 27 | withCrop: true, 28 | withHotspot: true, 29 | additionalFields: { 30 | alt: q.string().nullish(), 31 | }, 32 | }), 33 | }), 34 | status: q.select({ 35 | '_originalId in path("drafts.**")': ['"draft"', q.literal('draft')], 36 | 'default': ['"published"', q.literal('published')], 37 | }), 38 | }) 39 | .order('publishedAt desc', '_updatedAt desc') 40 | 41 | export {query} 42 | 43 | export type PostsLayoutProps = { 44 | data: unknown[] 45 | } 46 | 47 | const PostsLayout = memo(function Posts(props: PostsLayoutProps) { 48 | const posts = schema.parse(props.data) 49 | 50 | return ( 51 |
52 | {posts.map((post) => { 53 | return ( 54 |
58 |
59 | {post.mainImage ? ( 60 | 61 | ) : null} 62 |
63 |
64 | 69 |
70 |
71 | {post.author.name} 72 | {post.author?.image ? ( 73 | 79 | ) : null} 80 |
81 |
82 |

83 | {post.author.name} 84 |

85 |
86 | 89 | 90 |
91 |
92 |
93 |
94 |
95 | ) 96 | })} 97 |
98 | ) 99 | }) 100 | 101 | export default PostsLayout 102 | -------------------------------------------------------------------------------- /apps/static/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /apps/static/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import './globals.css' 2 | 3 | export default function RootLayout({children}: {children: React.ReactNode}) { 4 | return ( 5 | 6 | 7 | {children} 8 | 9 | ) 10 | } 11 | -------------------------------------------------------------------------------- /apps/static/app/page.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @next/next/no-html-link-for-pages */ 2 | import {unstable__adapter, unstable__environment} from '@sanity/client' 3 | import Link from 'next/link' 4 | 5 | import PostsLayout, {type PostsLayoutProps, query} from '@/app/PostsLayout' 6 | import {sanityFetch} from '@/app/sanity.fetch' 7 | 8 | export const dynamic = 'force-static' 9 | 10 | export default async function IndexPage() { 11 | const posts = await sanityFetch({query}) 12 | 13 | return ( 14 | <> 15 |
20 |
21 |
22 |
23 |
24 |
25 |

26 | Visual Editing Only 27 |

28 |
29 | 30 |
31 |
32 |
33 | 37 | Open Studio 38 | 39 |
40 | 41 | ) 42 | } 43 | -------------------------------------------------------------------------------- /apps/static/app/sanity.client.ts: -------------------------------------------------------------------------------- 1 | import {createClient} from '@sanity/client' 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 '@sanity/client' 18 | -------------------------------------------------------------------------------- /apps/static/app/sanity.fetch.ts: -------------------------------------------------------------------------------- 1 | import type {QueryParams} from '@sanity/client' 2 | 3 | import {client} from './sanity.client' 4 | 5 | // eslint-disable-next-line no-process-env 6 | export const token = process.env.SANITY_API_READ_TOKEN! 7 | 8 | export function sanityFetch({ 9 | query, 10 | params = {}, 11 | }: { 12 | query: string 13 | params?: QueryParams 14 | }) { 15 | return client.fetch(query, params, {cache: 'no-store'}) 16 | } 17 | -------------------------------------------------------------------------------- /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-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. 6 | -------------------------------------------------------------------------------- /apps/static/next.config.mjs: -------------------------------------------------------------------------------- 1 | import withBundleAnalyzer from '@next/bundle-analyzer' 2 | 3 | /** @type {import('next').NextConfig} */ 4 | const nextConfig = { 5 | output: 'export', 6 | logging: { 7 | fetches: { 8 | fullUrl: false, 9 | }, 10 | }, 11 | productionBrowserSourceMaps: true, 12 | env: { 13 | // Matches the behavior of `sanity dev` which sets styled-components to use the fastest way of inserting CSS rules in both dev and production. It's default behavior is to disable it in dev mode. 14 | SC_DISABLE_SPEEDY: 'false', 15 | }, 16 | } 17 | 18 | export default withBundleAnalyzer({ 19 | // eslint-disable-next-line no-process-env 20 | enabled: process.env.ANALYZE === 'true', 21 | })(nextConfig) 22 | -------------------------------------------------------------------------------- /apps/static/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "static", 3 | "version": "1.0.0", 4 | "private": true, 5 | "type": "module", 6 | "scripts": { 7 | "build": "next build", 8 | "dev": "next -p 3001 --turbo", 9 | "format": "next lint --fix .", 10 | "lint": "next lint --max-warnings 0 .", 11 | "start": "pnpx serve@latest out -p 3001", 12 | "type-check": "tsc --noEmit" 13 | }, 14 | "dependencies": { 15 | "@repo/sanity-config": "workspace:*", 16 | "@sanity/client": "^7.0.0", 17 | "@sanity/image-url": "^1.1.0", 18 | "@sanity/vision": "^3.88.0", 19 | "groqd": "^0.15.12", 20 | "next": "^15.4.0-canary", 21 | "next-sanity": "workspace:*", 22 | "react": "^19.0.0", 23 | "react-dom": "^19.0.0", 24 | "sanity": "^3.88.0" 25 | }, 26 | "devDependencies": { 27 | "@next/bundle-analyzer": "^15.3.2", 28 | "@next/env": "^15.3.2", 29 | "@repo/typescript-config": "workspace:*", 30 | "@types/react": "^19.1.3", 31 | "@typescript-eslint/eslint-plugin": "^8.32.0", 32 | "autoprefixer": "^10.4.21", 33 | "eslint": "^9.26.0", 34 | "eslint-config-next": "^15.3.2", 35 | "eslint-config-prettier": "^10.1.3", 36 | "eslint-gitignore": "^0.1.0", 37 | "eslint-plugin-simple-import-sort": "^12.1.1", 38 | "postcss": "^8.5.3", 39 | "tailwindcss": "^3.4.17", 40 | "typescript": "5.8.3" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /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: {}, 6 | autoprefixer: {}, 7 | }, 8 | } 9 | -------------------------------------------------------------------------------- /apps/static/sanity.cli.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-process-env */ 2 | import {loadEnvConfig} from '@next/env' 3 | import {defineCliConfig} from 'sanity/cli' 4 | 5 | const dev = process.env.NODE_ENV !== 'production' 6 | loadEnvConfig(__dirname, dev, {info: () => null, error: console.error}) 7 | 8 | const projectId = process.env.NEXT_PUBLIC_SANITY_PROJECT_ID 9 | const dataset = process.env.NEXT_PUBLIC_SANITY_DATASET 10 | 11 | export default defineCliConfig({api: {projectId, dataset}}) 12 | -------------------------------------------------------------------------------- /apps/static/sanity.config.ts: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | /* eslint-disable no-restricted-globals,no-process-env */ 4 | 5 | import sharedConfig from '@repo/sanity-config' 6 | import {defineConfig} from 'sanity' 7 | 8 | const projectId = process.env.NEXT_PUBLIC_SANITY_PROJECT_ID! 9 | const dataset = process.env.NEXT_PUBLIC_SANITY_DATASET! 10 | 11 | export default defineConfig({ 12 | projectId, 13 | dataset, 14 | 15 | plugins: [sharedConfig()], 16 | 17 | scheduledPublishing: { 18 | enabled: false, 19 | }, 20 | }) 21 | -------------------------------------------------------------------------------- /apps/static/tailwind.config.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: ['./app/**/*.{js,ts,jsx,tsx}', './pages/**/*.{js,ts,jsx,tsx}'], 4 | theme: {}, 5 | plugins: [], 6 | } 7 | -------------------------------------------------------------------------------- /apps/static/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@repo/typescript-config/base.json", 3 | "compilerOptions": { 4 | "plugins": [ 5 | { 6 | "name": "next" 7 | } 8 | ], 9 | "paths": { 10 | "@/*": ["./*"] 11 | } 12 | }, 13 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 14 | "exclude": ["node_modules"] 15 | } 16 | -------------------------------------------------------------------------------- /apps/static/turbo.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://turbo.build/schema.json", 3 | "extends": ["//"], 4 | "tasks": { 5 | "build": { 6 | "env": ["ANALYZE", "NEXT_PUBLIC_SANITY_PROJECT_ID", "NEXT_PUBLIC_SANITY_DATASET"], 7 | "outputs": [".next/**", "!.next/cache/**", "out/**"] 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /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/next-loader@canary @sanity/preview-url-secret@canary @sanity/visual-editing@canary && pnpm dedupe", 8 | "bump:latest": "pnpm -r up @sanity/next-loader@latest @sanity/preview-url-secret@latest @sanity/visual-editing@latest && pnpm dedupe", 9 | "predev": "pnpm build", 10 | "dev": "turbo dev", 11 | "format": "prettier --cache --write .", 12 | "lint": "turbo lint", 13 | "prestart": "turbo build", 14 | "start": "turbo watch build start", 15 | "test": "turbo test", 16 | "type-check": "turbo type-check" 17 | }, 18 | "devDependencies": { 19 | "@next/bundle-analyzer": "canary", 20 | "@next/env": "canary", 21 | "@sanity/prettier-config": "1.0.3", 22 | "eslint-config-next": "canary", 23 | "next": "canary", 24 | "prettier": "3.5.3", 25 | "prettier-plugin-packagejson": "2.5.11", 26 | "prettier-plugin-tailwindcss": "0.6.11", 27 | "turbo": "2.5.3" 28 | }, 29 | "packageManager": "pnpm@9.15.9", 30 | "pnpm": { 31 | "peerDependencyRules": { 32 | "allowAny": [ 33 | "react", 34 | "react-dom", 35 | "react-is" 36 | ] 37 | }, 38 | "overrides": { 39 | "@next/bundle-analyzer": "$@next/bundle-analyzer", 40 | "@next/env": "$@next/env", 41 | "@sanity/ui": "2.15.17", 42 | "@types/react": "^19", 43 | "@types/react-dom": "^19", 44 | "@types/react-is": "^19", 45 | "eslint-config-next": "$eslint-config-next", 46 | "next": "$next", 47 | "react": "^19", 48 | "react-dom": "^19", 49 | "react-is": "^19" 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /packages/next-sanity/.eslintignore: -------------------------------------------------------------------------------- 1 | dist -------------------------------------------------------------------------------- /packages/next-sanity/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | plugins: ['simple-import-sort', 'react', 'react-hooks', 'react-compiler'], 4 | extends: [ 5 | 'sanity/react', 6 | 'sanity/typescript', 7 | 'prettier', 8 | 'plugin:react/recommended', 9 | 'plugin:react/jsx-runtime', 10 | 'plugin:react-hooks/recommended', 11 | ], 12 | rules: { 13 | 'simple-import-sort/imports': 'warn', 14 | 'simple-import-sort/exports': 'warn', 15 | 'react-hooks/exhaustive-deps': 'error', 16 | 'react-compiler/react-compiler': 'error', 17 | 'dot-notation': 'off', 18 | }, 19 | } 20 | -------------------------------------------------------------------------------- /packages/next-sanity/.npmrc: -------------------------------------------------------------------------------- 1 | shamefully-hoist = true -------------------------------------------------------------------------------- /packages/next-sanity/MIGRATE-v1-to-v4.md: -------------------------------------------------------------------------------- 1 | ## Migrate 2 | 3 | - [From `v2`](#from-v2) 4 | - [From `v1`](#from-v1) 5 | - [From `v0.4`](#from-v04) 6 | 7 | ### From `v2` 8 | 9 | The `v3` release only contains breaking changes on the `next-sanity/studio` imports. If you're only using `import {createClient, groq} from 'next-sanity'` or `import {definePreview, PreviewSuspense} from 'next-sanity/preview'` then there's no migration for you to do. 10 | 11 | #### `NextStudioGlobalStyle` is removed 12 | 13 | The layout is no longer using global CSS to set the Studio height. The switch to local CSS helps interop between Next `/pages` and `/app` layouts. 14 | 15 | #### `ServerStyleSheetDocument` is removed 16 | 17 | It's no longer necessary to setup `styled-components` SSR for the Studio to render correctly. 18 | 19 | #### The internal `isWorkspaceWithTheme` and `isWorkspaces` utils are no longer exported 20 | 21 | The `useTheme` hook is still available if you're building abstractions that need to know what the initial workspace theme variables are. 22 | 23 | #### The `useBackgroundColorsFromTheme`, `useBasePath`, `useConfigWithBasePath`, and `useTextFontFamilyFromTheme`, hooks are removed 24 | 25 | You can `useTheme` to replace `useBackgroundColorsFromTheme` and `useTextFontFamilyFromTheme`: 26 | 27 | ```tsx 28 | import {useMemo} from 'react' 29 | import {useTheme} from 'next-sanity/studio' 30 | import type {StudioProps} from 'sanity' 31 | export default function MyComponent(props: Pick) { 32 | const theme = useTheme(config) 33 | // useBackgroundColorsFromTheme 34 | const {themeColorLight, themeColorDark} = useMemo( 35 | () => ({ 36 | themeColorLight: theme.color.light.default.base.bg, 37 | themeColorDark: theme.color.dark.default.base.bg, 38 | }), 39 | [theme], 40 | ) 41 | // useTextFontFamilyFromTheme 42 | const fontFamily = useMemo(() => theme.fonts.text.family, [theme]) 43 | } 44 | ``` 45 | 46 | The reason why `useBasePath` and `useConfigWithBasePath` got removed is because Next `/pages` and `/app` diverge too much in how they declare dynamic segments. Thus you'll need to specify `basePath` in your `sanity.config.ts` manually to match the route you're loading the studio, for the time being. 47 | 48 | #### The `NextStudioHead` component has moved from `next-sanity/studio` to `next-sanity/studio/head` 49 | 50 | Its props are also quite different and it now requires you to wrap it in `import Head from 'next/head'` if you're not using a `head.tsx` in `appDir`. Make sure you use TypeScript to ease the migration. 51 | 52 | ### From `v1` 53 | 54 | #### `createPreviewSubscriptionHook` is replaced with `definePreview` 55 | 56 | There are several differences between the hooks. First of all, `definePreview` requires React 18 and Suspense. And as it's designed to work with React Server Components you provide `token` in the hook itself instead of in the `definePreview` step. Secondly, `definePreview` encourages code-splitting using `React.lazy` and that means you only call the `usePreview` hook in a component that is lazy loaded. Quite different from `usePreviewSubscription` which was designed to be used in both preview mode, and in production by providing `initialData`. 57 | 58 | ##### Before 59 | 60 | The files that are imported here are the same as the [Next `/pages` example](#using-the-pages-director). 61 | 62 | `pages/index.tsx` 63 | 64 | ```tsx 65 | import {createPreviewSubscriptionHook} from 'next-sanity' 66 | import {DocumentsCount, query} from 'components/DocumentsCount' 67 | import {client, projectId, dataset} from 'lib/sanity.client' 68 | 69 | export const getStaticProps = async ({preview = false}) => { 70 | const data = await client.fetch(query) 71 | 72 | return {props: {preview, data}} 73 | } 74 | 75 | const usePreviewSubscription = createPreviewSubscriptionHook({projectId, dataset}) 76 | export default function IndexPage({preview, data: initialData}) { 77 | const {data} = usePreviewSubscription(indexQuery, {initialData, enabled: preview}) 78 | return 79 | } 80 | ``` 81 | 82 | ##### After 83 | 84 | `components/PreviewDocumentsCount.tsx` 85 | 86 | ```tsx 87 | import {definePreview} from 'next-sanity/preview' 88 | import {projectId, dataset} from 'lib/sanity.client' 89 | 90 | const usePreview = definePreview({projectId, dataset}) 91 | export default function PreviewDocumentsCount() { 92 | const data = usePreview(null, query) 93 | return 94 | } 95 | ``` 96 | 97 | `pages/index.tsx` 98 | 99 | ```tsx 100 | import {lazy} from 'react' 101 | import {PreviewSuspense} from 'next-sanity/preview' 102 | import {DocumentsCount, query} from 'components/DocumentsCount' 103 | import {client} from 'lib/sanity.client' 104 | 105 | const PreviewDocumentsCount = lazy(() => import('components/PreviewDocumentsCount')) 106 | 107 | export const getStaticProps = async ({preview = false}) => { 108 | const data = await client.fetch(query) 109 | 110 | return {props: {preview, data}} 111 | } 112 | 113 | export default function IndexPage({preview, data}) { 114 | if (preview) { 115 | return ( 116 | }> 117 | 118 | 119 | ) 120 | } 121 | return 122 | } 123 | ``` 124 | 125 | #### `createCurrentUserHook` is removed 126 | 127 | If you used this hook to check if the user is cookie authenticated: 128 | 129 | ```tsx 130 | import {createCurrentUserHook} from 'next-sanity' 131 | 132 | const projectId = process.env.NEXT_PUBLIC_SANITY_PROJECT_ID 133 | const useCurrentUser = createCurrentUserHook({projectId}) 134 | const useCheckAuth = () => { 135 | const {data, loading} = useCurrentUser() 136 | return loading ? false : !!data 137 | } 138 | 139 | export default function Page() { 140 | const isAuthenticated = useCheckAuth() 141 | } 142 | ``` 143 | 144 | Then you can achieve the same functionality using `@sanity/preview-kit` and `suspend-react`: 145 | 146 | ```tsx 147 | import {suspend} from 'suspend-react' 148 | import {_checkAuth} from '@sanity/preview-kit' 149 | 150 | const projectId = process.env.NEXT_PUBLIC_SANITY_PROJECT_ID 151 | const useCheckAuth = () => 152 | suspend(() => _checkAuth(projectId, null), ['@sanity/preview-kit', 'checkAuth', projectId]) 153 | 154 | export default function Page() { 155 | const isAuthenticated = useCheckAuth() 156 | } 157 | ``` 158 | 159 | ### From `v0.4` 160 | 161 | #### `createPortableTextComponent` is removed 162 | 163 | This utility used to wrap `@sanity/block-content-to-react`. It's encouraged to upgrade to `@portabletext/react`. 164 | 165 | ```sh 166 | $ npm install @portabletext/react 167 | // or 168 | $ yarn add @portabletext/react 169 | ``` 170 | 171 | ```diff 172 | -import { createPortableTextComponent } from 'next-sanity' 173 | +import { PortableText as PortableTextComponent } from '@portabletext/react' 174 | 175 | -export const PortableText = createPortableTextComponent({ serializers: {} }) 176 | +export const PortableText = (props) => 177 | ``` 178 | 179 | Please note that the `serializers` and `components` are not 100% equivalent. 180 | 181 | [Check the full migration guide.](https://github.com/portabletext/react-portabletext/blob/main/MIGRATING.md) 182 | 183 | #### `createImageUrlBuilder` is removed 184 | 185 | This utility is no longer wrapped by `next-sanity` and you'll need to install the dependency yourself: 186 | 187 | ```sh 188 | $ npm install @sanity/image-url 189 | // or 190 | $ yarn add @sanity/image-url 191 | ``` 192 | 193 | ```diff 194 | -import { createImageUrlBuilder } from 'next-sanity' 195 | +import createImageUrlBuilder from '@sanity/image-url' 196 | ``` 197 | -------------------------------------------------------------------------------- /packages/next-sanity/MIGRATE-v4-to-v5-app-router.md: -------------------------------------------------------------------------------- 1 | # Migrate from `v4` to `v5` in `app-router` 2 | 3 | > This guide tries to keep it as simple as possible, there's a more comprehensive guide [here](https://github.com/sanity-io/preview-kit/blob/main/MIGRATION.md) 4 | 5 | When migrating there are 4 main changes: 6 | 7 | 1. `usePreview` is replaced with `useLiveQuery`. 8 | 2. `definePreview` is replaced with a custom `PreviewProvider` that configures `LiveQueryProvider`. 9 | 3. `PreviewSuspense` is removed. 10 | 4. The `useLiveQuery` hook is used to render a fallback UI (Spinner) instead of a `` component. 11 | 12 | Let's look at a simple app using `v4`, and then walk through how to migrate it to `v5`. 13 | 14 | ## Before 15 | 16 | ### `app/page.tsx` 17 | 18 | ```tsx 19 | import {draftMode} from 'next/headers' 20 | import {PreviewSuspense} from 'next-sanity/preview' 21 | import {DocumentsCount, query} from 'components/DocumentsCount' 22 | import PreviewDocumentsCount from 'components/PreviewDocumentsCount' 23 | import {getClient} from 'lib/sanity.client' 24 | 25 | export default async function IndexPage() { 26 | const preview = draftMode().isEnabled ? {token: process.env.SANITY_API_READ_TOKEN} : undefined 27 | const client = getClient(preview) 28 | 29 | const data = preview ? null : await client.fetch(query) 30 | 31 | if (preview) { 32 | return ( 33 | 34 | 35 | 36 | ) 37 | } 38 | 39 | return 40 | } 41 | ``` 42 | 43 | ### `components/PreviewDocumentsCount.tsx`: 44 | 45 | ```tsx 46 | 'use client' 47 | 48 | import {query, DocumentsCount} from 'components/DocumentsCount' 49 | import {usePreview} from 'lib/sanity.preview' 50 | 51 | export default function PreviewDocumentsCount({token}) { 52 | const data = usePreview(token, query) 53 | return 54 | } 55 | ``` 56 | 57 | ### `lib/sanity.preview.ts` 58 | 59 | ```js 60 | 'use client' 61 | 62 | import {definePreview} from 'next-sanity/preview' 63 | import {getClient} from 'lib/sanity.client' 64 | 65 | const {projectId, dataset} = getClient().config() 66 | export const usePreview = definePreview({projectId, dataset}) 67 | ``` 68 | 69 | ## After 70 | 71 | ### `app/page.tsx` 72 | 73 | ```tsx 74 | import {draftMode} from 'next/headers' 75 | import {DocumentsCount, query} from 'components/DocumentsCount' 76 | import PreviewDocumentsCount from 'components/PreviewDocumentsCount' 77 | import PreviewProvider from 'components/PreviewProvider' 78 | import {getClient} from 'lib/sanity.client' 79 | 80 | export default async function IndexPage() { 81 | const preview = draftMode().isEnabled ? {token: process.env.SANITY_API_READ_TOKEN} : undefined 82 | 83 | const data = await client.fetch(query) 84 | 85 | if (preview) { 86 | return ( 87 | 88 | 89 | 90 | ) 91 | } 92 | 93 | return 94 | } 95 | ``` 96 | 97 | ### `components/PreviewDocumentsCount.tsx`: 98 | 99 | ```tsx 100 | 'use client' 101 | 102 | import {useLiveQuery} from 'next-sanity/preview' 103 | import {query, DocumentsCount} from 'components/DocumentsCount' 104 | 105 | export default function PreviewDocumentsCount({data: initialData}) { 106 | const [data, loading] = useLiveQuery(initialData, query) 107 | 108 | if (loading) { 109 | return <>Loading... 110 | } 111 | 112 | return 113 | } 114 | ``` 115 | 116 | ### `components/PreviewProvider.tsx` 117 | 118 | ```tsx 119 | 'use client' 120 | 121 | import {useMemo} from 'react' 122 | import {LiveQueryProvider} from 'next-sanity/preview' 123 | import {getClient} from 'lib/sanity.client' 124 | 125 | export default function PreviewProvider({ 126 | children, 127 | token, 128 | }: { 129 | children: React.ReactNode 130 | token: string 131 | }) { 132 | const client = useMemo(() => getClient({token}), [token]) 133 | return {children} 134 | } 135 | ``` 136 | -------------------------------------------------------------------------------- /packages/next-sanity/MIGRATE-v4-to-v5-pages-router.md: -------------------------------------------------------------------------------- 1 | # Migrate from `v4` to `v5` in `pages-router` 2 | 3 | > This guide tries to keep it as simple as possible, there's a more comprehensive guide [here](https://github.com/sanity-io/preview-kit/blob/main/MIGRATION.md) 4 | 5 | When migrating there are 4 main changes: 6 | 7 | 1. `usePreview` is replaced with `useLiveQuery`. 8 | 2. `definePreview` is replaced with a custom `PreviewProvider` that configures `LiveQueryProvider`. 9 | 3. `PreviewSuspense` is removed. 10 | 4. The `useLiveQuery` hook is used to render a fallback UI (Spinner) instead of a `` component. 11 | 12 | Let's look at a simple app using `v4`, and then walk through how to migrate it to `v5`. 13 | 14 | ## Before 15 | 16 | ### `pages/index.tsx` 17 | 18 | ```tsx 19 | import {lazy} from 'react' 20 | import {PreviewSuspense} from 'next-sanity/preview' 21 | import {DocumentsCount, query} from 'components/DocumentsCount' 22 | import {getClient} from 'lib/sanity.client' 23 | 24 | const PreviewDocumentsCount = lazy(() => import('components/PreviewDocumentsCount')) 25 | 26 | export const getStaticProps = async (context) => { 27 | const {token} = context.previewData ?? {} 28 | const preview = context.preview ? {token} : undefined 29 | const client = getClient(preview) 30 | 31 | const data = preview ? null : await client.fetch(query) 32 | return {props: {preview, data}} 33 | } 34 | 35 | export default function IndexPage({preview, data}) { 36 | if (preview) { 37 | return ( 38 | 39 | 40 | 41 | ) 42 | } 43 | 44 | return 45 | } 46 | ``` 47 | 48 | ### `components/PreviewDocumentsCount.tsx`: 49 | 50 | ```tsx 51 | import {query, DocumentsCount} from 'components/DocumentsCount' 52 | import {usePreview} from 'lib/sanity.preview' 53 | 54 | export default function PreviewDocumentsCount({token}) { 55 | const data = usePreview(token, query) 56 | return 57 | } 58 | ``` 59 | 60 | ### `lib/sanity.preview.ts` 61 | 62 | ```js 63 | import {definePreview} from 'next-sanity/preview' 64 | import {getClient} from 'lib/sanity.client' 65 | 66 | const {projectId, dataset} = getClient().config() 67 | export const usePreview = definePreview({projectId, dataset}) 68 | ``` 69 | 70 | ## After 71 | 72 | ### `pages/index.tsx` 73 | 74 | ```tsx 75 | import {DocumentsCount, query} from 'components/DocumentsCount' 76 | import PreviewDocumentsCount from 'components/PreviewDocumentsCount' 77 | import PreviewProvider from 'components/PreviewProvider' 78 | import {getClient} from 'lib/sanity.client' 79 | 80 | export const getStaticProps = async (context) => { 81 | const {token} = context.previewData ?? {} 82 | const preview = context.preview ? {token} : undefined 83 | const client = getClient(preview) 84 | 85 | const data = await client.fetch(query) 86 | return {props: {preview, data}} 87 | } 88 | 89 | export default function IndexPage({preview, data}) { 90 | if (preview) { 91 | return ( 92 | 93 | 94 | 95 | ) 96 | } 97 | 98 | return 99 | } 100 | ``` 101 | 102 | ### `components/PreviewDocumentsCount.tsx`: 103 | 104 | ```tsx 105 | import {useLiveQuery} from 'next-sanity/preview' 106 | import {query, DocumentsCount} from 'components/DocumentsCount' 107 | 108 | export default function PreviewDocumentsCount({data: initialData}) { 109 | const [data, loading] = useLiveQuery(initialData, query) 110 | 111 | if (loading) { 112 | return <>Loading... 113 | } 114 | 115 | return 116 | } 117 | ``` 118 | 119 | ### `components/PreviewProvider.tsx` 120 | 121 | ```tsx 122 | import {useMemo} from 'react' 123 | import {LiveQueryProvider} from 'next-sanity/preview' 124 | import {getClient} from 'lib/sanity.client' 125 | 126 | export default function PreviewProvider({ 127 | children, 128 | token, 129 | }: { 130 | children: React.ReactNode 131 | token: string 132 | }) { 133 | const client = useMemo(() => getClient({token}), [token]) 134 | return {children} 135 | } 136 | ``` 137 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /packages/next-sanity/MIGRATE-v7-to-v8.md: -------------------------------------------------------------------------------- 1 | ## Migrate 2 | 3 | ### `createClient` now mirrors `@sanity/client` and `sanity` 4 | 5 | The `createClient` in `next-sanity` used to re-export `@sanity/preview-kit/client`. Which had different options to handle [stega](https://github.com/sanity-io/client#using-visual-editing-with-steganography). 6 | It also set certain options automatically based on environment variables. 7 | 8 | If you relied on the old options for setting stega, here's how to upgrade: 9 | 10 | ```diff 11 | import {createClient, type FilterDefault} from 'next-sanity' 12 | 13 | const encodeSourceMapAtPath: FilterDefault = (props) => { 14 | - if(props.path.at(-1) === 'url') { 15 | + if(props.sourcePath.at(-1) === 'url') { 16 | return false 17 | } 18 | return props.filterDefault(props) 19 | } 20 | 21 | const client = createClient({ 22 | // ... other options like projectId, dataset, etc 23 | - encodeSourceMap: process.env.VERCEL_ENV === 'preview', 24 | - studioUrl: '/studio', 25 | - encodeSourceMapAtPath, 26 | - logger: console, 27 | + stega: { 28 | + enabled: process.env.VERCEL_ENV === 'preview', 29 | + studioUrl: '/studio', 30 | + filter: encodeSourceMapAtPath, 31 | + logger: console, 32 | + } 33 | }) 34 | ``` 35 | 36 | If you previously relied on how environment variables were set automatically (the `encodeSourceMap: 'auto'` option), you can replicate the same behavior as before with these options: 37 | 38 | ```ts 39 | import {createClient} from 'next-sanity' 40 | 41 | const client = createClient({ 42 | // ... other options like projectId, dataset, etc 43 | stega: { 44 | enabled: 45 | process.env.NEXT_PUBLIC_VERCEL_ENV === 'preview' || 46 | process.env.VERCEL_ENV === 'preview' || 47 | process.env.SANITY_SOURCE_MAP === 'true', 48 | studioUrl: process.env.NEXT_PUBLIC_SANITY_STUDIO_URL || process.env.SANITY_STUDIO_URL, 49 | }, 50 | }) 51 | ``` 52 | 53 | ### `parseAppBody` is removed 54 | 55 | Use `parseBody` instead: 56 | 57 | ```diff 58 | import {type NextRequest, NextResponse} from 'next/server' 59 | -import {parseAppBody} from 'next-sanity/webhook' 60 | +import {parseBody} from 'next-sanity/webhook' 61 | 62 | 63 | export async function POST(req: NextRequest) { 64 | - const {isValidSignature, body} = await parseAppBody( 65 | + const {isValidSignature, body} = await parseBody( 66 | req, 67 | process.env.SANITY_REVALIDATE_SECRET, 68 | ) 69 | } 70 | ``` 71 | 72 | The same is true for the `ParseAppBody` and `ParseBody` types: 73 | 74 | ```diff 75 | -import type {ParseAppBody} from 'next-sanity/webhook' 76 | -import type {ParseBody} from 'next-sanity/webhook' 77 | +import type {ParsedBody} from 'next-sanity/webhook' 78 | +import type {SanityDocument} from 'next-sanity' 79 | 80 | -export async function POST(request: Request): Promise { 81 | -export async function POST(request: Request): Promise { 82 | +export async function POST(request: Request): Promise> { 83 | 84 | } 85 | ``` 86 | 87 | ### `useTheme` is removed 88 | 89 | If you still have a use for it you may re-implement it yourself: 90 | 91 | ```ts 92 | import {studioTheme} from '@sanity/ui' 93 | import {useMemo} from 'react' 94 | import type {Config, SingleWorkspace, StudioTheme} from 'sanity' 95 | 96 | export function useTheme(config?: Config | Required>): StudioTheme { 97 | const workspace = useMemo> | undefined>( 98 | () => (Array.isArray(config) ? config[0] : config), 99 | [config], 100 | ) 101 | return useMemo(() => workspace?.theme || studioTheme, [workspace]) 102 | } 103 | ``` 104 | 105 | ### `usePrefersColorScheme` is removed 106 | 107 | If you still have a use for it you may re-implement it yourself: 108 | 109 | ```ts 110 | import {usePrefersDark} from '@sanity/ui' 111 | import type {ThemeColorSchemeKey} from '@sanity/ui/theme' 112 | 113 | export function usePrefersColorScheme(): ThemeColorSchemeKey { 114 | return usePrefersDark() ? 'dark' : 'light' 115 | } 116 | ``` 117 | 118 | ### `next-sanity/studio/loading` is removed 119 | 120 | If you have a route like this: 121 | 122 | ```ts 123 | // ./src/app/studio/[[...index]]/loading.tsx 124 | 125 | export {NextStudioLoading as default} from 'next-sanity/studio/loading' 126 | ``` 127 | 128 | Then delete this route to upgrade. Make sure the `page.tsx` route has this line: 129 | 130 | ```ts 131 | // Ensures the Studio route is statically generated 132 | export const dynamic = 'force-static' 133 | ``` 134 | -------------------------------------------------------------------------------- /packages/next-sanity/MIGRATE-v8-to-v9.md: -------------------------------------------------------------------------------- 1 | ## Migrate 2 | 3 | ### Minimum required `sanity` is now `3.37.1` 4 | 5 | Upgrade to the latest v3 stable using the following command: 6 | 7 | ```bash 8 | npm install sanity@latest --save-exact 9 | ``` 10 | 11 | ### Minimum required `@sanity/ui` is now `2.0.11` 12 | 13 | Upgrade to the latest v2 stable using the following command: 14 | 15 | ```bash 16 | npm install @sanity/ui@latest --save-exact 17 | ``` 18 | 19 | ### Minumum required `styled-components` is now `6.1.0` 20 | 21 | Upgrade using: 22 | 23 | ```bash 24 | npm install styled-components@^6.1.0 25 | ``` 26 | 27 | And make sure to remove `@types/styled-components` as `styled-components` now ship with its own types: 28 | 29 | ```bash 30 | npm uninstall @types/styled-components 31 | ``` 32 | 33 | You can also remove `react-is` from your dependencies, unless you're using them in your own code. 34 | 35 | ### The deprecated export `next-sanity/studio/metadata` has been removed 36 | 37 | Use the `metadata` export from `next-sanity/studio` instead: 38 | 39 | ```diff 40 | -export {metadata} from 'next-sanity/studio/metadata' 41 | +export {metadata} from 'next-sanity/studio' 42 | ``` 43 | 44 | ### The deprecated export `next-sanity/studio/viewport` has been removed 45 | 46 | Use the `viewport` export from `next-sanity/studio` instead: 47 | 48 | ```diff 49 | -export {viewport} from 'next-sanity/studio/viewport' 50 | +export {viewport} from 'next-sanity/studio' 51 | ``` 52 | 53 | ### The `next-sanity/webhook` feature is now App Router only 54 | 55 | If possible you should migrate your [Pages Router API Route](https://nextjs.org/docs/pages/building-your-application/routing/api-routes) to be an [App Router Route Handler](https://nextjs.org/docs/app/building-your-application/routing/route-handlers). 56 | 57 | If you were using the webhook to perform [On-Demand Revalidation of ISR](https://nextjs.org/docs/pages/building-your-application/data-fetching/incremental-static-regeneration#using-on-demand-revalidation) on Pages Router then you can't simply migrate to App Router, and will instead need to migrate to using `@sanity/webhook` directly: 58 | 59 | ```bash 60 | npm install @sanity/webhook@4.0.2-bc --save-exact 61 | ``` 62 | 63 | ```diff 64 | // ./pages/api/revalidate.ts 65 | 66 | -import { parseBody, type ParsedBody } from 'next-sanity/webhook' 67 | +import type { ParsedBody } from 'next-sanity/webhook' 68 | +import { isValidSignature, SIGNATURE_HEADER_NAME } from '@sanity/webhook' 69 | +import type { PageConfig } from 'next/types' 70 | 71 | 72 | -export { config } from 'next-sanity/webhook' 73 | +export const config = { 74 | + api: { 75 | + /** 76 | + * Next.js will by default parse the body, which can lead to invalid signatures. 77 | + */ 78 | + bodyParser: false, 79 | + }, 80 | + runtime: 'nodejs', 81 | +} satisfies PageConfig 82 | 83 | export default async function revalidate( 84 | req: NextApiRequest, 85 | res: NextApiResponse, 86 | ) { 87 | try { 88 | const { body, isValidSignature } = await parseBody( 89 | req, 90 | process.env.SANITY_REVALIDATE_SECRET, 91 | ) 92 | if (!isValidSignature) { 93 | const message = 'Invalid signature' 94 | console.log(message) 95 | return res.status(401).send(message) 96 | } 97 | if (typeof body?._id !== 'string' || !body?._id) { 98 | const invalidId = 'Invalid _id' 99 | console.error(invalidId, { body }) 100 | return res.status(400).send(invalidId) 101 | } 102 | const staleRoutes = await queryStaleRoutes(body as any) 103 | await Promise.all(staleRoutes.map((route) => res.revalidate(route))) 104 | const updatedRoutes = `Updated routes: ${staleRoutes.join(', ')}` 105 | console.log(updatedRoutes) 106 | return res.status(200).send(updatedRoutes) 107 | } catch (err) { 108 | console.error(err) 109 | return res.status(500).send(err.message) 110 | } 111 | } 112 | 113 | +async function parseBody( 114 | + req: NextApiRequest, 115 | + secret?: string, 116 | + waitForContentLakeEventualConsistency: boolean = true, 117 | +): Promise> { 118 | + let signature = req.headers[SIGNATURE_HEADER_NAME] 119 | + if (Array.isArray(signature)) { 120 | + signature = signature[0] 121 | + } 122 | + if (!signature) { 123 | + console.error('Missing signature header') 124 | + return { body: null, isValidSignature: null } 125 | + } 126 | + 127 | + if (req.readableEnded) { 128 | + throw new Error( 129 | + `Request already ended and the POST body can't be read. Have you setup \`export {config} from 'next-sanity/webhook' in your webhook API handler?\``, 130 | + ) 131 | + } 132 | + 133 | + const body = await readBody(req) 134 | + const validSignature = secret 135 | + ? await isValidSignature(body, signature, secret.trim()) 136 | + : null 137 | + 138 | + if (validSignature !== false && waitForContentLakeEventualConsistency) { 139 | + await new Promise((resolve) => setTimeout(resolve, 1000)) 140 | + } 141 | + 142 | + return { 143 | + body: body.trim() ? JSON.parse(body) : null, 144 | + isValidSignature: validSignature, 145 | + } 146 | +} 147 | + 148 | +async function readBody(readable: NextApiRequest): Promise { 149 | + const chunks = [] 150 | + for await (const chunk of readable) { 151 | + chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk) 152 | + } 153 | + return Buffer.concat(chunks).toString('utf8') 154 | +} 155 | ``` 156 | 157 | [Here's a complete example of such a migration.](https://github.com/sanity-io/nextjs-blog-cms-sanity-v3/commit/3eed62b7d42b8b90f8dad1917831013626065ac2) 158 | -------------------------------------------------------------------------------- /packages/next-sanity/PREVIEW-app-router.md: -------------------------------------------------------------------------------- 1 | # Setup Live Previews in `app-router` 2 | 3 | ```bash 4 | npm i next-sanity@latest server-only suspend-react 5 | ``` 6 | 7 | ### `lib/sanity.client.ts` 8 | 9 | ```tsx 10 | import {createClient} from 'next-sanity' 11 | 12 | export const client = createClient({ 13 | projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID, 14 | dataset: process.env.NEXT_PUBLIC_SANITY_DATASET, 15 | apiVersion: process.env.NEXT_PUBLIC_SANITY_API_VERSION || '2022-11-15', 16 | useCdn: false, 17 | perspective: 'published', 18 | }) 19 | ``` 20 | 21 | ### `lib/sanity.fetch.ts` 22 | 23 | ```tsx 24 | import 'server-only' 25 | 26 | import {draftMode} from 'next/headers' 27 | import type {QueryOptions, QueryParams} from 'next-sanity' 28 | 29 | import {client} from './sanity.client' 30 | 31 | export const token = process.env.SANITY_API_READ_TOKEN 32 | 33 | export async function sanityFetch({ 34 | query, 35 | params = {}, 36 | tags, 37 | }: { 38 | query: string 39 | params?: QueryParams 40 | tags?: string[] 41 | }) { 42 | const isDraftMode = draftMode().isEnabled 43 | if (isDraftMode && !token) { 44 | throw new Error('The `SANITY_API_READ_TOKEN` environment variable is required.') 45 | } 46 | 47 | return client.fetch(query, params, { 48 | ...(isDraftMode && 49 | ({ 50 | token: token, 51 | perspective: 'drafts', 52 | } satisfies QueryOptions)), 53 | next: { 54 | revalidate: isDraftMode ? 0 : false, 55 | tags, 56 | }, 57 | }) 58 | } 59 | ``` 60 | 61 | ### `components/DocumentsCount.tsx`: 62 | 63 | ```tsx 64 | export const query = `count(*[_type == "page"])` 65 | 66 | export default function DocumentsCount({data}: {data: number}) { 67 | return
There are {data} documents
68 | } 69 | ``` 70 | 71 | ## Before 72 | 73 | ### `app/layout.tsx` 74 | 75 | ```tsx 76 | export default async function RootLayout({children}: {children: React.ReactNode}) { 77 | return ( 78 | 79 | {children} 80 | 81 | ) 82 | } 83 | ``` 84 | 85 | ### `app/page.tsx` 86 | 87 | ```tsx 88 | import DocumentsCount, {query} from 'components/DocumentsCount' 89 | import {sanityFetch} from 'lib/sanity.fetch' 90 | 91 | export default async function IndexPage() { 92 | const data = await sanityFetch({query, tags: ['post']}) 93 | 94 | return 95 | } 96 | ``` 97 | 98 | ## After 99 | 100 | ### `app/layout.tsx` 101 | 102 | ```tsx 103 | import dynamic from 'next/dynamic' 104 | import {draftMode} from 'next/headers' 105 | import {token} from 'lib/sanity.fetch' 106 | 107 | const PreviewProvider = dynamic(() => import('components/PreviewProvider')) 108 | 109 | export default async function RootLayout({children}: {children: React.ReactNode}) { 110 | return ( 111 | 112 | 113 | {draftMode().isEnabled ? ( 114 | {children} 115 | ) : ( 116 | children 117 | )} 118 | 119 | 120 | ) 121 | } 122 | ``` 123 | 124 | ### `app/page.tsx` 125 | 126 | ```tsx 127 | import {draftMode} from 'next/headers' 128 | import {LiveQuery} from 'next-sanity/preview/live-query' 129 | import DocumentsCount, {query} from 'components/DocumentsCount' 130 | import PreviewDocumentsCount from 'components/PreviewDocumentsCount' 131 | import {sanityFetch} from 'lib/sanity.fetch' 132 | 133 | export default async function IndexPage() { 134 | const data = await sanityFetch({query, tags: ['post']}) 135 | 136 | return ( 137 | 143 | 144 | 145 | ) 146 | } 147 | ``` 148 | 149 | ### `components/PreviewDocumentsCount.tsx`: 150 | 151 | ```tsx 152 | 'use client' 153 | 154 | import dynamic from 'next/dynamic' 155 | 156 | // Re-exported components using next/dynamic ensures they're not bundled 157 | // and sent to the browser unless actually used, with draftMode().enabled. 158 | 159 | export default dynamic(() => import('./DocumentsCount')) 160 | ``` 161 | 162 | ### `components/PreviewProvider.tsx` 163 | 164 | ```tsx 165 | 'use client' 166 | 167 | import LiveQueryProvider from 'next-sanity/preview' 168 | import {suspend} from 'suspend-react' 169 | 170 | // suspend-react cache is global, so we use a unique key to avoid collisions 171 | const UniqueKey = Symbol('lib/sanity.client') 172 | 173 | export default function PreviewProvider({ 174 | children, 175 | token, 176 | }: { 177 | children: React.ReactNode 178 | token?: string 179 | }) { 180 | const {client} = suspend(() => import('lib/sanity.client'), [UniqueKey]) 181 | if (!token) throw new TypeError('Missing token') 182 | return ( 183 | 184 | {children} 185 | 186 | ) 187 | } 188 | ``` 189 | -------------------------------------------------------------------------------- /packages/next-sanity/PREVIEW-pages-router.md: -------------------------------------------------------------------------------- 1 | # Setup Live Previews in `pages-router` 2 | 3 | ```bash 4 | npm i next-sanity@latest 5 | ``` 6 | 7 | ### `lib/sanity.client.ts` 8 | 9 | ```tsx 10 | import {createClient} from 'next-sanity' 11 | 12 | export const client = createClient({ 13 | projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID, 14 | dataset: process.env.NEXT_PUBLIC_SANITY_DATASET, 15 | apiVersion: process.env.NEXT_PUBLIC_SANITY_API_VERSION || '2022-11-15', 16 | useCdn: false, 17 | perspective: 'published', 18 | }) 19 | ``` 20 | 21 | ### `lib/sanity.fetch.ts` 22 | 23 | ```tsx 24 | import type {QueryParams} from 'next-sanity' 25 | 26 | import {client} from './sanity.client' 27 | 28 | export const token = process.env.SANITY_API_READ_TOKEN 29 | 30 | export async function sanityFetch({ 31 | draftMode, 32 | query, 33 | params = {}, 34 | }: { 35 | draftMode: boolean 36 | query: string 37 | params?: QueryParams 38 | }) { 39 | if (draftMode && !token) { 40 | throw new Error('The `SANITY_API_READ_TOKEN` environment variable is required.') 41 | } 42 | 43 | return client.fetch(query, params, { 44 | token, 45 | perspective: draftMode ? 'drafts' : 'published', 46 | }) 47 | } 48 | ``` 49 | 50 | ### `components/DocumentsCount.tsx`: 51 | 52 | ```tsx 53 | export const query = `count(*[_type == "page"])` 54 | 55 | export function DocumentsCount({data}: {data: number}) { 56 | return
There are {data} documents
57 | } 58 | ``` 59 | 60 | ## Before 61 | 62 | ### `pages/_app.tsx` 63 | 64 | ```tsx 65 | import {AppProps} from 'next/app' 66 | 67 | export default function App({Component, pageProps}: AppProps) { 68 | return 69 | } 70 | ``` 71 | 72 | ### `pages/index.tsx` 73 | 74 | ```tsx 75 | import {DocumentsCount, query} from 'components/DocumentsCount' 76 | import {sanityFetch} from 'lib/sanity.fetch' 77 | 78 | export const getStaticProps = async ({draftMode = false}) => { 79 | const data = await sanityFetch({draftMode, query}) 80 | return {props: {data}} 81 | } 82 | 83 | export default function IndexPage({data}) { 84 | return 85 | } 86 | ``` 87 | 88 | ## After 89 | 90 | ### `pages/_app.tsx` 91 | 92 | ```tsx 93 | import {AppProps} from 'next/app' 94 | import dynamic from 'next/dynamic' 95 | 96 | const PreviewProvider = dynamic(() => import('components/PreviewProvider')) 97 | 98 | export default function App({ 99 | Component, 100 | pageProps, 101 | }: AppProps<{ 102 | draftMode: boolean 103 | token: string 104 | }>) { 105 | const {draftMode, token} = pageProps 106 | return draftMode ? ( 107 | 108 | 109 | 110 | ) : ( 111 | 112 | ) 113 | } 114 | ``` 115 | 116 | ### `pages/index.tsx` 117 | 118 | ```tsx 119 | import {LiveQuery} from 'next-sanity/preview/live-query' 120 | import {DocumentsCount, query} from 'components/DocumentsCount' 121 | import {sanityFetch, token} from 'lib/sanity.fetch' 122 | 123 | export const getStaticProps = async ({draftMode}) => { 124 | const data = await sanityFetch({draftMode, query}) 125 | return {props: {draftMode, token: draftMode ? token : '', data}} 126 | } 127 | 128 | export default function IndexPage({draftMode, data}) { 129 | return ( 130 | 131 | 132 | 133 | ) 134 | } 135 | ``` 136 | 137 | ### `components/PreviewProvider.tsx` 138 | 139 | ```tsx 140 | import {LiveQueryProvider} from 'next-sanity/preview' 141 | import {client} from 'lib/sanity.client' 142 | 143 | export default function PreviewProvider({ 144 | children, 145 | token, 146 | }: { 147 | children: React.ReactNode 148 | token?: string 149 | }) { 150 | if (!token) throw new TypeError('Missing token') 151 | return ( 152 | 153 | {children} 154 | 155 | ) 156 | } 157 | ``` 158 | -------------------------------------------------------------------------------- /packages/next-sanity/README.md: -------------------------------------------------------------------------------- 1 | # next-sanity 2 | 3 | The all-in-one [Sanity][sanity] toolkit for production-grade content-editable Next.js applications. 4 | 5 | **Features:** 6 | 7 | - [Sanity Client][sanity-client] for queries and mutations, fully compatible with the [Next.js cache][next-cache] 8 | - [Visual Editing][visual-editing] for interactive live preview of draft content 9 | - [Embedded Sanity Studio][sanity-studio], a deeply-configurable content editing dashboard 10 | - [GROQ][groq-syntax-highlighting] for powerful content querying with type generation and syntax highlighting 11 | - [Portable Text][portable-text] for rendering rich text and block content 12 | 13 | **Quicklinks**: [Sanity docs][sanity-docs] | [Next.js docs][next-docs] | [Clean starter template][sanity-next-clean-starter] | [Fully-featured starter template][sanity-next-featured-starter] 14 | 15 | ## Table of contents 16 | 17 | - [Installation](#installation) 18 | - [Quick Start](#quick-start) 19 | - [Manual installation](#manual-installation) 20 | - [Install `next-sanity`](#install-next-sanity) 21 | - [Optional: peer dependencies for embedded Sanity Studio](#optional-peer-dependencies-for-embedded-sanity-studio) 22 | - [Manual configuration](#manual-configuration) 23 | - [Write GROQ queries](#write-groq-queries) 24 | - [Generate TypeScript Types](#generate-typescript-types) 25 | - [Using query result types](#using-query-result-types) 26 | - [Query content from Sanity Content Lake](#query-content-from-sanity-content-lake) 27 | - [Configuring Sanity Client](#configuring-sanity-client) 28 | - [Fetching in App Router Components](#fetching-in-app-router-components) 29 | - [Fetching in Page Router Components](#fetching-in-page-router-components) 30 | - [Should `useCdn` be `true` or `false`?](#should-usecdn-be-true-or-false) 31 | - [How does `apiVersion` work?](#how-does-apiversionwork) 32 | - [Caching and revalidation](#caching-and-revalidation) 33 | - [`sanityFetch()` helper function](#sanityfetch-helper-function) 34 | - [Time-based revalidation](#time-based-revalidation) 35 | - [Path-based revalidation](#path-based-revalidation) 36 | - [Tag-based revalidation](#tag-based-revalidation) 37 | - [Debugging caching and revalidation](#debugging-caching-and-revalidation) 38 | - [Example implementation](#example-implementation) 39 | - [Visual Editing](#visual-editing) 40 | - [Live Content API](#live-content-api) 41 | - [Setup](#setup) 42 | - [How does it revalidate and refresh in real time](#how-does-it-revalidate-and-refresh-in-real-time) 43 | - [Embedded Sanity Studio](#embedded-sanity-studio) 44 | - [Creating a Studio route](#creating-a-studio-route) 45 | - [Automatic installation of embedded Studio](#automatic-installation-of-embedded-studio) 46 | - [Manual installation of embedded Studio](#manual-installation-of-embedded-studio) 47 | - [Studio route with App Router](#studio-route-with-app-router) 48 | - [Lower-level control with `StudioProvider` and `StudioLayout`](#lower-level-control-with-studioprovider-and-studiolayout) 49 | - [Migration guides](#migration-guides) 50 | - [License](#license) 51 | 52 | ## Installation 53 | 54 | ## Quick Start 55 | 56 | Instantly create a new free Sanity project – or link to an existing one – from the command line and connect it to your Next.js application by the following terminal command _in your Next.js project folder_: 57 | 58 | ```bash 59 | npx sanity@latest init 60 | ``` 61 | 62 | If you do not yet have a Sanity account you will be prompted to create one. This command will create basic utilities required to query content from Sanity. And optionally embed Sanity Studio - a configurable content management system - at a route in your Next.js application. See the [Embedded Sanity Studio](#embedded-sanity-studio) section. 63 | 64 | ## Manual installation 65 | 66 | If you do not yet have a Next.js application, you can create one with the following command: 67 | 68 | ```bash 69 | npx create-next-app@latest 70 | ``` 71 | 72 | This README assumes you have chosen all of the default options, but should be fairly similar for most bootstrapped Next.js projects. 73 | 74 | ### Install `next-sanity` 75 | 76 | Inside your Next.js application, run the following command in the package manager of your choice to install the next-sanity toolkit: 77 | 78 | ```bash 79 | npm install next-sanity @sanity/image-url 80 | ``` 81 | 82 | ```bash 83 | yarn add next-sanity @sanity/image-url 84 | ``` 85 | 86 | ```bash 87 | pnpm install next-sanity @sanity/image-url 88 | ``` 89 | 90 | ```bash 91 | bun install next-sanity @sanity/image-url 92 | ``` 93 | 94 | This also installs `@sanity/image-url` for [On-Demand Image Transformations][image-url] to render images from Sanity's CDN. 95 | 96 | ### Optional: peer dependencies for embedded Sanity Studio 97 | 98 | When using `npm` newer than `v7`, or `pnpm` newer than `v8`, you should end up with needed dependencies like `sanity` and `styled-components` when you installed `next-sanity`. In `yarn` `v1` you can use `install-peerdeps`: 99 | 100 | ```bash 101 | npx install-peerdeps --yarn next-sanity 102 | ``` 103 | 104 | ### Manual configuration 105 | 106 | The `npx sanity@latest init` command offers to write some configuration files for your Next.js application. Most importantly is one that writes your chosen Sanity project ID and dataset name to your local environment variables. Note that unlike access tokens, the project ID and dataset name are **not** considered sensitive information. 107 | 108 | **Create** this file at the root of your Next.js application if it does not already exist. 109 | 110 | ```bash 111 | # .env.local 112 | 113 | NEXT_PUBLIC_SANITY_PROJECT_ID= 114 | NEXT_PUBLIC_SANITY_DATASET= 115 | ``` 116 | 117 | **Create** a file to access and export these values 118 | 119 | ```ts 120 | // ./src/sanity/env.ts 121 | 122 | export const dataset = process.env.NEXT_PUBLIC_SANITY_DATASET! 123 | export const projectId = process.env.NEXT_PUBLIC_SANITY_PROJECT_ID! 124 | 125 | // Values you may additionally want to configure globally 126 | export const apiVersion = process.env.NEXT_PUBLIC_SANITY_API_VERSION || '2024-07-11' 127 | ``` 128 | 129 | Remember to add these environment variables to your hosting provider's environment as well. 130 | 131 | ### Write GROQ queries 132 | 133 | `next-sanity` exports the `defineQuery` function which will give you syntax highlighting in [VS Code with the Sanity extension installed][vs-code-extension]. It’s also used for GROQ query result type generation with [Sanity TypeGen][sanity-typegen]. 134 | 135 | ```ts 136 | // ./src/sanity/lib/queries.ts 137 | 138 | import {defineQuery} from 'next-sanity' 139 | 140 | export const POSTS_QUERY = defineQuery(`*[_type == "post" && defined(slug.current)][0...12]{ 141 | _id, title, slug 142 | }`) 143 | 144 | export const POST_QUERY = defineQuery(`*[_type == "post" && slug.current == $slug][0]{ 145 | title, body, mainImage 146 | }`) 147 | ``` 148 | 149 | ### Generate TypeScript Types 150 | 151 | You can use [Sanity TypeGen to generate TypeScript types][sanity-typegen] for your schema types and GROQ query results in your Next.js application. It should be readily available if you have used `sanity init` and chosen the embedded Studio. 152 | 153 | > [!TIP] 154 | > Sanity TypeGen will [create Types for queries][sanity-typegen-queries] that are assigned to a variable and use the `groq` template literal or `defineQuery` function. 155 | 156 | If your Sanity Studio schema types are in a different project or repository, you can [configure Sanity TypeGen to write types to your Next.js project][sanity-typegen-monorepo]. 157 | 158 | **Create** a `sanity-typegen.json` file at the root of your project to configure Sanity TypeGen: 159 | 160 | ```json 161 | // sanity-typegen.json 162 | { 163 | "path": "./src/**/*.{ts,tsx,js,jsx}", 164 | "schema": "./src/sanity/extract.json", 165 | "generates": "./src/sanity/types.ts" 166 | } 167 | ``` 168 | 169 | Note: This configuration is strongly opinionated that the generated Types and the schema extraction are both within the `/src/sanity` directory, not the root which is the default. This configuration is complimented by setting the path of the schema extraction in the updated package.json scripts below. 170 | 171 | **Run** the following command in your terminal to extract your Sanity Studio schema to a JSON file 172 | 173 | ```bash 174 | # Run this each time your schema types change 175 | npx sanity@latest schema extract 176 | ``` 177 | 178 | **Run** the following command in your terminal to generate TypeScript types for both your Sanity Studio schema and GROQ queries 179 | 180 | ```bash 181 | # Run this each time your schema types or GROQ queries change 182 | npx sanity@latest typegen generate 183 | ``` 184 | 185 | **Update** your Next.js project's `package.json` to perform both of these commands by running `npm run typegen` 186 | 187 | ```json 188 | "scripts": { 189 | "predev": "npm run typegen", 190 | "dev": "next", 191 | "prebuild": "npm run typegen", 192 | "build": "next build", 193 | "start": "next start", 194 | "lint": "next lint", 195 | "typegen": "sanity schema extract --path=src/sanity/extract.json && sanity typegen generate" 196 | }, 197 | ``` 198 | 199 | ### Using query result types 200 | 201 | Sanity TypeGen creates TypeScript types for the results of your GROQ queries, which _can_ be used as generics like this: 202 | 203 | ```ts 204 | import {client} from '@/sanity/lib/client' 205 | import {POSTS_QUERY} from '@/sanity/lib/queries' 206 | import {POSTS_QUERYResult} from '@/sanity/types' 207 | 208 | const posts = await client.fetch(POSTS_QUERY) 209 | // ^? const post: POST_QUERYResult 210 | ``` 211 | 212 | However, it is much simpler to use automatic type inference. So long as your GROQ queries are wrapped in `defineQuery`, the results should be inferred automatically: 213 | 214 | ```ts 215 | import {client} from '@/sanity/lib/client' 216 | import {POSTS_QUERY} from '@/sanity/lib/queries' 217 | 218 | const posts = await client.fetch(POSTS_QUERY) 219 | // ^? const post: POST_QUERYResult 220 | ``` 221 | 222 | ## Query content from Sanity Content Lake 223 | 224 | Sanity content is typically queried with GROQ queries from a configured Sanity Client. [Sanity also supports GraphQL][sanity-graphql]. 225 | 226 | ### Configuring Sanity Client 227 | 228 | To interact with Sanity content in a Next.js application, we recommend creating a `client.ts` file: 229 | 230 | ```ts 231 | // ./src/sanity/lib/client.ts 232 | import {createClient} from 'next-sanity' 233 | 234 | import {apiVersion, dataset, projectId} from '../env' 235 | 236 | export const client = createClient({ 237 | projectId, 238 | dataset, 239 | apiVersion, // https://www.sanity.io/docs/api-versioning 240 | useCdn: true, // Set to false if statically generating pages, using ISR or tag-based revalidation 241 | }) 242 | ``` 243 | 244 | ### Fetching in App Router Components 245 | 246 | To fetch data in a React Server Component using the [App Router][app-router] you can await results from the Sanity Client inside a server component: 247 | 248 | ```tsx 249 | // ./src/app/page.tsx 250 | 251 | import {client} from '@/sanity/lib/client' 252 | import {POSTS_QUERY} from '@/sanity/lib/queries' 253 | 254 | export default async function PostIndex() { 255 | const posts = await client.fetch(POSTS_QUERY) 256 | 257 | return ( 258 |
    259 | {posts.map((post) => ( 260 |
  • 261 | {post?.title} 262 |
  • 263 | ))} 264 |
265 | ) 266 | } 267 | ``` 268 | 269 | ### Fetching in Page Router Components 270 | 271 | If you're using the [Pages Router][pages-router] you can await results from Sanity Client inside a `getStaticProps` function: 272 | 273 | ```tsx 274 | // ./src/pages/index.tsx 275 | 276 | import {client} from '@/sanity/lib/client' 277 | import {POSTS_QUERY} from '@/sanity/lib/queries' 278 | 279 | export async function getStaticProps() { 280 | const posts = await client.fetch(POSTS_QUERY) 281 | 282 | return {posts} 283 | } 284 | 285 | export default async function PostIndex({posts}) { 286 | return ( 287 |
    288 | {posts.map((post) => ( 289 |
  • 290 | {post?.title} 291 |
  • 292 | ))} 293 |
294 | ) 295 | } 296 | ``` 297 | 298 | ### Should `useCdn` be `true` or `false`? 299 | 300 | You might notice that you have to set the `useCdn` to `true` or `false` in the client configuration. Sanity offers [caching on a CDN for queries][cdn]. Since Next.js has its own caching, using the Sanity CDN might not be necessary, but there are some exceptions. 301 | 302 | In general, set `useCdn` to `true` when: 303 | 304 | - Data fetching happens client-side, for example, in a `useEffect` hook or in response to a user interaction where the `client.fetch` call is made in the browser. 305 | - Server-side rendered (SSR) data fetching is dynamic and has a high number of unique requests per visitor, for example, a "For You" feed. 306 | 307 | Set `useCdn` to `false` when: 308 | 309 | - Used in a static site generation context, for example, `getStaticProps` or `getStaticPaths`. 310 | - Used in an ISR on-demand webhook responder. 311 | - Good `stale-while-revalidate` caching is in place that keeps API requests on a consistent low, even if traffic to Next.js spikes. 312 | - For Preview or Draft modes as part of an editorial workflow, you need to ensure that the latest content is always fetched. 313 | 314 | ### How does `apiVersion` work? 315 | 316 | Sanity uses [date-based API versioning][api-versioning]. You can configure the date in a `YYYY-MM-DD` format, and it will automatically fall back on the latest API version of that time. Then, if a breaking change is introduced later, it won't break your application and give you time to test before upgrading. 317 | 318 | ## Caching and revalidation 319 | 320 | This toolkit includes the [`@sanity/client`][sanity-client] which fully supports Next.js `fetch` based features for caching and revalidation. This ensures great performance while preventing stale content in a way that's native to Next.js. 321 | 322 | > [!NOTE] 323 | > Some hosts (like Vercel) will keep the content cache in a dedicated data layer and not part of the static app bundle, which means re-deploying the app will not purge the cache. We recommend reading up on [caching behavior in the Next.js docs][next-cache]. 324 | 325 | ### `sanityFetch()` helper function 326 | 327 | It can be beneficial to set revalidation defaults for all queries. In all of the following examples, a `sanityFetch()` helper function is used for this purpose. 328 | 329 | While this function is written to accept _both_ Next.js caching options `revalidate` and `tags`, your application should only rely on one. For this reason, if `tags` are supplied, the `revalidate` setting will be set to `false` (cache indefinitely) and you will need to bust the cache for these pages using [`revalidateTag()`](#tag-based-revalidation). 330 | 331 | In short: 332 | 333 | - Time-based `revalidate` is good enough for most applications. 334 | - Any page can be automatically purged from the cache using [`revalidatePath()`](#path-based-revalidation). 335 | - Content-based `tags` will give you more fine-grained control for complex applications. 336 | - Pages cached by tags must be purged using [`revalidateTag()`](#tag-based-revalidation). 337 | 338 | ```ts 339 | // ./src/sanity/lib/client.ts 340 | 341 | import {createClient, type QueryParams} from 'next-sanity' 342 | 343 | import {apiVersion, dataset, projectId} from '../env' 344 | 345 | export const client = createClient({ 346 | projectId, 347 | dataset, 348 | apiVersion, // https://www.sanity.io/docs/api-versioning 349 | useCdn: true, // Set to false if statically generating pages, using ISR or tag-based revalidation 350 | }) 351 | 352 | export async function sanityFetch({ 353 | query, 354 | params = {}, 355 | revalidate = 60, // default revalidation time in seconds 356 | tags = [], 357 | }: { 358 | query: QueryString 359 | params?: QueryParams 360 | revalidate?: number | false 361 | tags?: string[] 362 | }) { 363 | return client.fetch(query, params, { 364 | cache: 'force-cache', // on next v14 it's force-cache by default, in v15 it has to be set explicitly 365 | next: { 366 | revalidate: tags.length ? false : revalidate, // for simple, time-based revalidation 367 | tags, // for tag-based revalidation 368 | }, 369 | }) 370 | } 371 | ``` 372 | 373 | Be aware that you can get errors if you use `cache` and `revalidate` configurations for Next.js together. See the [Next.js documentation on revalidation][next-revalidate-docs]. 374 | 375 | ### Time-based revalidation 376 | 377 | Time-based revalidation is often good enough for the majority of applications. 378 | 379 | Increase the `revalidate` setting for longer-lived and less frequently modified content. 380 | 381 | ```tsx 382 | // ./src/app/pages/index.tsx 383 | 384 | import {sanityFetch} from '@/sanity/lib/client' 385 | import {POSTS_QUERY} from '@/sanity/lib/queries' 386 | 387 | export default async function PostIndex() { 388 | const posts = await sanityFetch({ 389 | query: POSTS_QUERY, 390 | revalidate: 3600, // update cache at most once every hour 391 | }) 392 | 393 | return ( 394 |
    395 | {posts.map((post) => ( 396 |
  • 397 | {post?.title} 398 |
  • 399 | ))} 400 |
401 | ) 402 | } 403 | ``` 404 | 405 | ### Path-based revalidation 406 | 407 | For on-demand revalidation of individual pages, Next.js has a `revalidatePath()` function. You can create an API route in your Next.js application to execute it, and [a GROQ-powered webhook][groq-webhook] in your Sanity Project to instantly request it when content is created, updated or deleted. 408 | 409 | **Create** a new environment variable `SANITY_REVALIDATE_SECRET` with a random string that is shared between your Sanity project and your Next.js application. This is considered sensitive and should not be committed to your repository. 410 | 411 | ```bash 412 | # .env.local 413 | 414 | SANITY_REVALIDATE_SECRET= 415 | ``` 416 | 417 | **Create** a new API route in your Next.js application 418 | 419 | The code example below uses the built-in `parseBody` function to validate that the request comes from your Sanity project (using a shared secret and looking at the request headers). Then it looks at the document type information in the webhook payload and matches that against the revalidation tags in your application 420 | 421 | ```ts 422 | // ./src/app/api/revalidate-path/route.ts 423 | 424 | import {revalidatePath} from 'next/cache' 425 | import {type NextRequest, NextResponse} from 'next/server' 426 | import {parseBody} from 'next-sanity/webhook' 427 | 428 | type WebhookPayload = {path?: string} 429 | 430 | export async function POST(req: NextRequest) { 431 | try { 432 | if (!process.env.SANITY_REVALIDATE_SECRET) { 433 | return new Response('Missing environment variable SANITY_REVALIDATE_SECRET', {status: 500}) 434 | } 435 | 436 | const {isValidSignature, body} = await parseBody( 437 | req, 438 | process.env.SANITY_REVALIDATE_SECRET, 439 | ) 440 | 441 | if (!isValidSignature) { 442 | const message = 'Invalid signature' 443 | return new Response(JSON.stringify({message, isValidSignature, body}), {status: 401}) 444 | } else if (!body?.path) { 445 | const message = 'Bad Request' 446 | return new Response(JSON.stringify({message, body}), {status: 400}) 447 | } 448 | 449 | revalidatePath(body.path) 450 | const message = `Updated route: ${body.path}` 451 | return NextResponse.json({body, message}) 452 | } catch (err) { 453 | console.error(err) 454 | return new Response(err.message, {status: 500}) 455 | } 456 | } 457 | ``` 458 | 459 | **Create** a new GROQ-powered webhook in your Sanity project. 460 | 461 | You can [copy this template][webhook-template-revalidate-path] to quickly add the webhook to your Sanity project. 462 | 463 | The Projection uses [GROQ's `select()` function][groq-functions] to dynamically create paths for nested routes like `/posts/[slug]`, you can extend this example your routes and other document types. 464 | 465 | ```groq 466 | { 467 | "path": select( 468 | _type == "post" => "/posts/" + slug.current, 469 | "/" + slug.current 470 | ) 471 | } 472 | ``` 473 | 474 | > [!TIP] 475 | > If you wish to revalidate _all routes_ on demand, create an API route that calls `revalidatePath('/', 'layout')` 476 | 477 | ### Tag-based revalidation 478 | 479 | Tag-based revalidation is preferable for instances where many pages are affected by a single document being created, updated or deleted. 480 | 481 | For on-demand revalidation of many pages, Next.js has a `revalidateTag()` function. You can create an API route in your Next.js application to execute it, and [a GROQ-powered webhook][groq-webhook] in your Sanity Project to instantly request it when content is created, updated or deleted. 482 | 483 | ```tsx 484 | // ./src/app/pages/index.tsx 485 | 486 | import {sanityFetch} from '@/sanity/lib/client' 487 | import {POSTS_QUERY} from '@/sanity/lib/queries' 488 | 489 | export default async function PostIndex() { 490 | const posts = await sanityFetch({ 491 | query: POSTS_QUERY, 492 | tags: ['post', 'author'], // revalidate all pages with the tags 'post' and 'author' 493 | }) 494 | 495 | return ( 496 |
    497 | {posts.map((post) => ( 498 |
  • 499 | {post?.title} 500 |
  • 501 | ))} 502 |
503 | ) 504 | } 505 | ``` 506 | 507 | **Create** a new environment variable `SANITY_REVALIDATE_SECRET` with a random string that is shared between your Sanity project and your Next.js application. This is considered sensitive and should not be committed to your repository. 508 | 509 | ```bash 510 | # .env.local 511 | 512 | SANITY_REVALIDATE_SECRET= 513 | ``` 514 | 515 | **Create** a new API route in your Next.js application 516 | 517 | The code example below uses the built-in `parseBody` function to validate that the request comes from your Sanity project (using a shared secret and looking at the request headers). Then it looks at the document type information in the webhook payload and matches that against the revalidation tags in your application 518 | 519 | ```ts 520 | // ./src/app/api/revalidate-tag/route.ts 521 | 522 | import {revalidateTag} from 'next/cache' 523 | import {type NextRequest, NextResponse} from 'next/server' 524 | import {parseBody} from 'next-sanity/webhook' 525 | 526 | type WebhookPayload = { 527 | _type: string 528 | } 529 | 530 | export async function POST(req: NextRequest) { 531 | try { 532 | if (!process.env.SANITY_REVALIDATE_SECRET) { 533 | return new Response('Missing environment variable SANITY_REVALIDATE_SECRET', {status: 500}) 534 | } 535 | 536 | const {isValidSignature, body} = await parseBody( 537 | req, 538 | process.env.SANITY_REVALIDATE_SECRET, 539 | ) 540 | 541 | if (!isValidSignature) { 542 | const message = 'Invalid signature' 543 | return new Response(JSON.stringify({message, isValidSignature, body}), {status: 401}) 544 | } else if (!body?._type) { 545 | const message = 'Bad Request' 546 | return new Response(JSON.stringify({message, body}), {status: 400}) 547 | } 548 | 549 | // If the `_type` is `post`, then all `client.fetch` calls with 550 | // `{next: {tags: ['post']}}` will be revalidated 551 | revalidateTag(body._type) 552 | 553 | return NextResponse.json({body}) 554 | } catch (err) { 555 | console.error(err) 556 | return new Response(err.message, {status: 500}) 557 | } 558 | } 559 | ``` 560 | 561 | **Create** a new GROQ-powered webhook in your Sanity project. 562 | 563 | You can [copy this template][webhook-template-revalidate-tag] to quickly add the webhook to your Sanity project. 564 | 565 | ### Debugging caching and revalidation 566 | 567 | To aid in debugging and understanding what's in the cache, revalidated, skipped, and more, add the following to your Next.js configuration file: 568 | 569 | ```js 570 | // ./next.config.js 571 | module.exports = { 572 | logging: { 573 | fetches: { 574 | fullUrl: true, 575 | }, 576 | }, 577 | } 578 | ``` 579 | 580 | ### Example implementation 581 | 582 | Check out the [Personal website template][personal-website-template] to see a feature-complete example of how `revalidateTag` is used together with Visual Editing. 583 | 584 | ## Visual Editing 585 | 586 | Interactive live previews of draft content are the best way for authors to find and edit content with the least amount of effort and the most confidence to press publish. 587 | 588 | > [!TIP] 589 | > Visual Editing is available on all Sanity plans and can be enabled on all hosting environments. 590 | 591 | > [!NOTE] 592 | > Vercel ["Content Link"][vercel-content-link] adds an "edit" button to the Vercel toolbar on preview builds and is available on Vercel Pro and Enterprise plans. 593 | 594 | An end-to-end tutorial of [how to configure Sanity and Next.js for Visual Editing](https://www.sanity.io/guides/nextjs-app-router-live-preview) using the same patterns demonstrated in this README is available on the Sanity Exchange. 595 | 596 | ## Live Content API 597 | 598 | [The Live Content API][live-content-api] can be used to receive real time updates in your application when viewing both draft content in contexts like Presentation tool, and published content in your user-facing production application. 599 | 600 | > [!NOTE] 601 | > The Live Content API is currently considered experimental and may change in the future. 602 | 603 | ### Setup 604 | 605 | #### 1. Configure `defineLive` 606 | 607 | Use `defineLive` to enable automatic revalidation and refreshing of your fetched content. 608 | 609 | ```tsx 610 | // src/sanity/lib/live.ts 611 | 612 | import {createClient, defineLive} from 'next-sanity' 613 | 614 | const client = createClient({ 615 | projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID, 616 | dataset: process.env.NEXT_PUBLIC_SANITY_DATASET, 617 | useCdn: true, 618 | apiVersion: 'v2025-03-04', 619 | stega: {studioUrl: '/studio'}, 620 | }) 621 | 622 | const token = process.env.SANITY_API_READ_TOKEN 623 | if (!token) { 624 | throw new Error('Missing SANITY_API_READ_TOKEN') 625 | } 626 | 627 | export const {sanityFetch, SanityLive} = defineLive({ 628 | client, 629 | serverToken: token, 630 | browserToken: token, 631 | }) 632 | ``` 633 | 634 | The `token` passed to `defineLive` needs [Viewer rights](https://www.sanity.io/docs/roles#e2daad192df9) in order to fetch draft content. 635 | 636 | The same token can be used as both `browserToken` and `serverToken`, as the `browserToken` is only shared with the browser when Draft Mode is enabled. Draft Mode can only be initiated by either Sanity's Presentation Tool or the Vercel Toolbar. 637 | 638 | > Good to know: 639 | > Enterprise plans allow the creation of custom roles with more resticted access rights than the `Viewer` role, enabling the use of a `browserToken` specifically for authenticating the Live Content API. We're working to extend this capability to all Sanity price plans. 640 | 641 | #### 2. Render `` in the root `layout.tsx` 642 | 643 | ```tsx 644 | // src/app/layout.tsx 645 | 646 | import {VisualEditing} from 'next-sanity' 647 | import {SanityLive} from '@/sanity/lib/live' 648 | 649 | export default function RootLayout({children}: {children: React.ReactNode}) { 650 | return ( 651 | 652 | 653 | {children} 654 | 655 | {(await draftMode()).isEnabled && } 656 | 657 | 658 | ) 659 | } 660 | ``` 661 | 662 | The `` component is responsible for making all `sanityFetch` calls in your application _live_, so should always be rendered. This differs from the `` component, which should only be rendered when Draft Mode is enabled. 663 | 664 | #### 3. Fetching data with `sanityFetch` 665 | 666 | Use `sanityFetch` to fetch data in any server component. 667 | 668 | ```tsx 669 | // src/app/products.tsx 670 | 671 | import {defineQuery} from 'next-sanity' 672 | import {sanityFetch} from '@/sanity/lib/live' 673 | 674 | const PRODUCTS_QUERY = defineQuery(`*[_type == "product" && defined(slug.current)][0...$limit]`) 675 | 676 | export default async function Page() { 677 | const {data: products} = await sanityFetch({ 678 | query: PRODUCTS_QUERY, 679 | params: {limit: 10}, 680 | }) 681 | 682 | return ( 683 |
684 | {products.map((product) => ( 685 | 688 | ))} 689 |
690 | ) 691 | } 692 | ``` 693 | 694 | ### Using `generateMetadata`, `generateStaticParams` and more 695 | 696 | `sanityFetch` can also be used in functions like `generateMetadata` in order to make updating the page title, or even its favicon, _live_. 697 | 698 | ```ts 699 | import {sanityFetch} from '@/sanity/lib/live' 700 | import type {Metadata} from 'next' 701 | 702 | export async function generateMetadata(): Promise { 703 | const {data} = await sanityFetch({ 704 | query: SETTINGS_QUERY, 705 | // Metadata should never contain stega 706 | stega: false, 707 | }) 708 | return { 709 | title: { 710 | template: `%s | ${data.title}`, 711 | default: data.title, 712 | }, 713 | } 714 | } 715 | ``` 716 | 717 | > Good to know: 718 | > Always set `stega: false` when calling `sanityFetch` within these: 719 | > 720 | > - `generateMetadata` 721 | > - `generateViewport` 722 | > - `generateSitemaps` 723 | > - `generateImageMetadata` 724 | 725 | ```ts 726 | import {sanityFetch} from '@/sanity/lib/live' 727 | 728 | export async function generateStaticParams() { 729 | const {data} = await sanityFetch({ 730 | query: POST_SLUGS_QUERY, 731 | // Use the published perspective in generateStaticParams 732 | perspective: 'published', 733 | stega: false, 734 | }) 735 | return data 736 | } 737 | ``` 738 | 739 | ### 4. Integrating with Next.js Draft Mode and Vercel Toolbar's Edit Mode 740 | 741 | To support previewing draft content when Draft Mode is enabled, the `serverToken` passed to `defineLive` should be assigned the Viewer role, which has the ability to fetch content using the `drafts` perspective. 742 | 743 | Click the Draft Mode button in the Vercel toolbar to enable draft content: 744 | 745 | ![image](https://github.com/user-attachments/assets/5aa3ed30-929e-48f1-a16c-8246309ec099) 746 | 747 | With drafts enabled, you'll see the Edit Mode button show up if your Vercel plan is eligible: 748 | 749 | ![img](https://github.com/user-attachments/assets/6ca7a9f5-e2d1-4915-83d0-8928a0a563de) 750 | 751 | Ensure that `browserToken` is setup if you want draft content that isn't yet published to also update live. 752 | 753 | ### 5. Integrating with Sanity Presentation Tool & Visual Editing 754 | 755 | The `defineLive` API also supports Presentation Tool and Sanity Visual Editing. 756 | 757 | Setup an API route that uses `defineEnableDraftMode` in your app: 758 | 759 | ```ts 760 | // src/app/api/draft-mode/enable/route.ts 761 | 762 | import {client} from '@/sanity/lib/client' 763 | import {token} from '@/sanity/lib/token' 764 | import {defineEnableDraftMode} from 'next-sanity/draft-mode' 765 | 766 | export const {GET} = defineEnableDraftMode({ 767 | client: client.withConfig({token}), 768 | }) 769 | ``` 770 | 771 | The main benefit of `defineEnableDraftMode` is that it fully implements all of Sanity Presentation Tool's features, including the perspective switcher: 772 | 773 | image 774 | 775 | And the Preview URL Sharing feature: 776 | 777 | image 778 | 779 | In your `sanity.config.ts`, set the `previewMode.enable` option for `presentationTool`: 780 | 781 | ```ts 782 | // sanity.config.ts 783 | 'use client' 784 | 785 | import {defineConfig} from 'sanity' 786 | import {presentationTool} from 'next-sanity' 787 | 788 | export default defineConfig({ 789 | // ... 790 | plugins: [ 791 | // ... 792 | presentationTool({ 793 | previewUrl: { 794 | // ... 795 | previewMode: { 796 | enable: '/api/draft-mode/enable', 797 | }, 798 | }, 799 | }), 800 | ], 801 | }) 802 | ``` 803 | 804 | Ensuring you have a valid viewer token setup for `defineLive.serverToken` and `defineEnableDraftMode` allows Presentation Tool to auto enable Draft Mode, and your application to pull in draft content that refreshes in real time. 805 | 806 | The `defineLive.browserToken` option isn't required, but is recommended as it enables a faster live preview experience, both standalone and when using Presentation Tool. 807 | 808 | ### 6. Enabling standalone live preview of draft content 809 | 810 | Standalone live preview has the following requirements: 811 | 812 | - `defineLive.serverToken` must be defined, otherwise only published content is fetched. 813 | - At least one integration (Sanity Presentation Tool or Vercel Toolbar) must be setup, so Draft Mode can be enabled in your application on demand. 814 | - `defineLive.browserToken` must be defined with a valid token. 815 | 816 | You can verify if live preview is enabled with the `useIsLivePreview` hook: 817 | 818 | ```tsx 819 | 'use client' 820 | 821 | import {useIsLivePreview} from 'next-sanity/hooks' 822 | 823 | export function DebugLivePreview() { 824 | const isLivePreview = useIsLivePreview() 825 | if (isLivePreview === null) return 'Checking Live Preview...' 826 | return isLivePreview ? 'Live Preview Enabled' : 'Live Preview Disabled' 827 | } 828 | ``` 829 | 830 | The following hooks can also be used to provide information about the application's current environment: 831 | 832 | ```ts 833 | import { 834 | useIsPresentationTool, 835 | useDraftModeEnvironment, 836 | useDraftModePerspective, 837 | } from 'next-sanity/hooks' 838 | ``` 839 | 840 | ### Handling Layout Shift 841 | 842 | Live components will re-render automatically as content changes. This can cause jarring layout shifts in production when items appear or disappear from a list. 843 | 844 | To provide a better user experience, we can animate these layout changes. The following example uses `framer-motion@12.0.0-alpha.1`, which supports React Server Components: 845 | 846 | ```tsx 847 | // src/app/products.tsx 848 | 849 | import {AnimatePresence} from 'framer-motion' 850 | import * as motion from 'framer-motion/client' 851 | import {defineQuery} from 'next-sanity' 852 | import {sanityFetch} from '@/sanity/lib/live' 853 | 854 | const PRODUCTS_QUERY = defineQuery(`*[_type == "product" && defined(slug.current)][0...$limit]`) 855 | 856 | export default async function Page() { 857 | const {data: products} = await sanityFetch({ 858 | query: PRODUCTS_QUERY, 859 | params: {limit: 10}, 860 | }) 861 | 862 | return ( 863 |
864 | 865 | {products.map((product) => ( 866 | 872 | {product.title} 873 | 874 | ))} 875 | 876 |
877 | ) 878 | } 879 | ``` 880 | 881 | Whilst this is an improvement, it may still lead to users attempting to click on an item as it shifts position, potentially resulting in the selection of an unintended item. We can instead require users to opt-in to changes before a layout update is triggered. 882 | 883 | To preserve the ability to render everything on the server, we can make use of a Client Component wrapper. This can defer showing changes to the user until they've explicitly clicked to "Refresh". The example below uses `sonner` to provide toast functionality: 884 | 885 | ```tsx 886 | // src/app/products/products-layout-shift.tsx 887 | 888 | 'use client' 889 | 890 | import {useCallback, useState, useEffect} from 'react' 891 | import isEqual from 'react-fast-compare' 892 | import {toast} from 'sonner' 893 | 894 | export function ProductsLayoutShift(props: {children: React.ReactNode; ids: string[]}) { 895 | const [children, pending, startViewTransition] = useDeferredLayoutShift(props.children, props.ids) 896 | 897 | /** 898 | * We need to suspend layout shift for user opt-in. 899 | */ 900 | useEffect(() => { 901 | if (!pending) return 902 | 903 | toast('Products have been updated', { 904 | action: { 905 | label: 'Refresh', 906 | onClick: () => startViewTransition(), 907 | }, 908 | }) 909 | }, [pending, startViewTransition]) 910 | 911 | return children 912 | } 913 | 914 | function useDeferredLayoutShift(children: React.ReactNode, dependencies: unknown[]) { 915 | const [pending, setPending] = useState(false) 916 | const [currentChildren, setCurrentChildren] = useState(children) 917 | const [currentDependencies, setCurrentDependencies] = useState(dependencies) 918 | 919 | if (!pending) { 920 | if (isEqual(currentDependencies, dependencies)) { 921 | if (currentChildren !== children) { 922 | setCurrentChildren(children) 923 | } 924 | } else { 925 | setCurrentDependencies(dependencies) 926 | setPending(true) 927 | } 928 | } 929 | 930 | const startViewTransition = useCallback(() => { 931 | setCurrentDependencies(dependencies) 932 | setPending(false) 933 | }, [dependencies]) 934 | 935 | return [pending ? currentChildren : children, pending, startViewTransition] as const 936 | } 937 | ``` 938 | 939 | This Client Component is used to wrap the layout that should only be updated after the user has clicked the refresh button: 940 | 941 | ```diff 942 | // src/app/products/page.tsx 943 | 944 | import { AnimatePresence } from "framer-motion"; 945 | import * as motion from "framer-motion/client"; 946 | import {defineQuery} from 'next-sanity' 947 | import { sanityFetch } from "@/sanity/lib/live"; 948 | +import {ProductsLayoutShift} from './products-page-layout-shift.tsx' 949 | 950 | const PRODUCTS_QUERY = defineQuery(`*[_type == "product" && defined(slug.current)][0...$limit]`) 951 | 952 | export default async function Page() { 953 | const {data: products} = await sanityFetch({ query: PRODUCTS_QUERY, params: {limit: 10} }); 954 | + // If the list over ids change, it'll trigger the toast asking the user to opt-in to refresh 955 | + // but if a product title has changed, perhaps to fix a typo, we update that right away 956 | + const ids = products.map((product) => product._id) 957 | return ( 958 |
959 | + 960 | 961 | {products.map((product) => ( 962 | 968 | {product.title} 969 | 970 | ))} 971 | 972 | + 973 |
974 | ); 975 | } 976 | ``` 977 | 978 | With this approach we've limited the use of client components to just a single component. All the server components within `` remain as server components, with all their benefits. 979 | 980 | ## How does the Live Content API revalidate and refresh in real-time? 981 | 982 | The architecture for `defineLive` works as follows: 983 | 984 | 1. `sanityFetch` automatically sets `fetch.next.tags` for you using opaque tags generated by our backend, prefixed with `sanity:`. 985 | 2. `` listens to change events using the Sanity Live Content API (LCAPI). 986 | 3. When the LCAPI emits an event, `` invokes a Server Function that calls `revalidateTag(`sanity:${tag}`)`. 987 | 4. Since it's a Server Function, Next.js will evict data fetches associated with the revalidated tag. The page is seamlessly updated with fresh content, which future visitors will also see thanks to `revalidateTag` integrating with ISR. 988 | 989 | With this setup, as long as one visitor accesses your Next.js app after a content change, the cache is updated globally for all users, regardless of the specific URL they visit. 990 | 991 | ### Revalidating content changes from automations 992 | 993 | If your content operations involve scenarios where you might not always have a visitor to trigger the `revalidateTag` event, there are two ways to ensure your content is never stale: 994 | 995 | #### A) Use a GROQ powered webhook to call `revalidateTag(sanity)` 996 | 997 | All queries made using `sanityFetch` include the `sanity` tag in their `fetch.next.tags` array. You can use this to call `revalidateTag('sanity')` in an API route that handles a GROQ webhook payload. 998 | 999 | This approach can be considered a "heavy hammer" so it's important to limit the webhook events that trigger it. You could also implement this in a custom component to manually purge the cache if content gets stuck. 1000 | 1001 | #### B) Setup a server-side `` alternative 1002 | 1003 | You can setup your own long-running server, using Express for example, to listen for change events using the Sanity Live Content API. Then, create an API route in your Next.js app: 1004 | 1005 | ```ts 1006 | // src/app/api/revalidate-tag/route.ts 1007 | import {revalidateTag} from 'next/cache' 1008 | 1009 | export const POST = async (request) => { 1010 | const {tags, isValid} = await validateRequest(request) 1011 | if (!isValid) return new Response('No no no', {status: 400}) 1012 | for (const _tag of tags) { 1013 | const tag = `sanity:${_tag}` 1014 | revalidateTag(tag) 1015 | // eslint-disable-next-line no-console 1016 | console.log(`revalidated tag: ${tag}`) 1017 | } 1018 | } 1019 | ``` 1020 | 1021 | Your Express app can then forward change events to this endpoint, ensuring your content is always up-to-date. This method guarantees that stale content is never served, even if no browser is actively viewing your app! 1022 | 1023 | ## Embedded Sanity Studio 1024 | 1025 | Sanity Studio is a near-infinitely configurable content editing interface that can be embedded into any React application. For Next.js, you can embed the Studio on a route (like `/studio`). The Studio will still require authentication and be available only for members of your Sanity project. 1026 | 1027 | This opens up many possibilities including dynamic configuration of your Sanity Studio based on a network request or user input. 1028 | 1029 | > [!WARNING] 1030 | > The convenience of co-locating the Studio with your Next.js application is appealing, but it can also influence your content model to be too website-centric, and potentially make collaboration with other developers more difficult. Consider a standalone or monorepo Studio repository for larger projects and teams. 1031 | 1032 | ### Creating a Studio route 1033 | 1034 | `next-sanity` exports a `` component to load Sanity's `` component wrapped in a Next.js friendly layout. `metadata` specifies the necessary `` tags for making the Studio adapt to mobile devices and prevents the route from being indexed by search engines. 1035 | 1036 | ### Automatic installation of embedded Studio 1037 | 1038 | To quickly connect an existing - or create a new - Sanity project to your Next.js application, run the following command in your terminal. You will be prompted to create a route for the Studio during setup. 1039 | 1040 | ```bash 1041 | npx sanity@latest init 1042 | ``` 1043 | 1044 | ### Manual installation of embedded Studio 1045 | 1046 | **Create** a file `sanity.config.ts` in the project's root and copy the example below: 1047 | 1048 | ```ts 1049 | // ./sanity.config.ts 1050 | 'use client' 1051 | 1052 | import {defineConfig} from 'sanity' 1053 | import {structureTool} from 'sanity/structure' 1054 | 1055 | const projectId = process.env.NEXT_PUBLIC_SANITY_PROJECT_ID! 1056 | const dataset = process.env.NEXT_PUBLIC_SANITY_DATASET! 1057 | 1058 | export default defineConfig({ 1059 | basePath: '/studio', // `basePath` must match the route of your Studio 1060 | projectId, 1061 | dataset, 1062 | plugins: [structureTool()], 1063 | schema: {types: []}, 1064 | }) 1065 | ``` 1066 | 1067 | Optionally, **create** a `sanity.cli.ts` with the same `projectId` and `dataset` as your `sanity.config.ts` to the project root so that you can run `npx sanity ` from the terminal inside your Next.js application: 1068 | 1069 | ```ts 1070 | // ./sanity.cli.ts 1071 | 1072 | import {defineCliConfig} from 'sanity/cli' 1073 | 1074 | const projectId = process.env.NEXT_PUBLIC_SANITY_PROJECT_ID! 1075 | const dataset = process.env.NEXT_PUBLIC_SANITY_DATASET! 1076 | 1077 | export default defineCliConfig({api: {projectId, dataset}}) 1078 | ``` 1079 | 1080 | Now you can run commands like `npx sanity cors add`. Run `npx sanity help` for a full list of what you can do. 1081 | 1082 | ### Studio route with App Router 1083 | 1084 | Even if the rest of your app is using Pages Router, you can and should mount the Studio on an App Router route. [Next.js supports both routers in the same app.](https://nextjs.org/docs/app/building-your-application/upgrading/app-router-migration#migrating-from-pages-to-app) 1085 | 1086 | **Create** a new route to render the Studio, with the default metadata and viewport configuration: 1087 | 1088 | ```tsx 1089 | // ./src/app/studio/[[...tool]]/page.tsx 1090 | 1091 | import {NextStudio} from 'next-sanity/studio' 1092 | import config from '../../../../sanity.config' 1093 | 1094 | export const dynamic = 'force-static' 1095 | 1096 | export {metadata, viewport} from 'next-sanity/studio' 1097 | 1098 | export default function StudioPage() { 1099 | return 1100 | } 1101 | ``` 1102 | 1103 | The default meta tags exported by `next-sanity` can be customized if necessary: 1104 | 1105 | ```tsx 1106 | // ./src/app/studio/[[...tool]]/page.tsx 1107 | 1108 | import type {Metadata, Viewport} from 'next' 1109 | import {metadata as studioMetadata, viewport as studioViewport} from 'next-sanity/studio' 1110 | 1111 | // Set the correct `viewport`, `robots` and `referrer` meta tags 1112 | export const metadata: Metadata = { 1113 | ...studioMetadata, 1114 | // Overrides the title until the Studio is loaded 1115 | title: 'Loading Studio...', 1116 | } 1117 | 1118 | export const viewport: Viewport = { 1119 | ...studioViewport, 1120 | // Overrides the viewport to resize behavior 1121 | interactiveWidget: 'resizes-content', 1122 | } 1123 | 1124 | export default function StudioPage() { 1125 | return 1126 | } 1127 | ``` 1128 | 1129 | ### Lower-level control with `StudioProvider` and `StudioLayout` 1130 | 1131 | If you need even more control over the Studio, you can pass `StudioProvider` and `StudioLayout` from `sanity` as `children`: 1132 | 1133 | ```tsx 1134 | // ./src/app/studio/[[...tool]]/page.tsx 1135 | 1136 | 'use client' 1137 | 1138 | import {NextStudio} from 'next-sanity/studio' 1139 | import {StudioProvider, StudioLayout} from 'sanity' 1140 | 1141 | import config from '../../../sanity.config' 1142 | 1143 | function StudioPage() { 1144 | return ( 1145 | 1146 | 1147 | {/* Put components here and you'll have access to the same React hooks as Studio gives you when writing plugins */} 1148 | 1149 | 1150 | 1151 | ) 1152 | } 1153 | ``` 1154 | 1155 | ## Migration guides 1156 | 1157 | > [!IMPORTANT] 1158 | > You're looking at the README for v9, the README for [v8 is available here](https://github.com/sanity-io/next-sanity/tree/v8?tab=readme-ov-file#next-sanity) as well as an [migration guide][migrate-v8-to-v9]. 1159 | 1160 | - [From `v8` to `v9`][migrate-v8-to-v9] 1161 | - [From `v7` to `v8`][migrate-v7-to-v8] 1162 | - [From `v6` to `v7`][migrate-v6-to-v7] 1163 | - [From `v5` to `v6`][migrate-v5-to-v6] 1164 | - From `v4` to `v5` 1165 | - [`app-router`][migrate-v4-to-v5-app] 1166 | - [`pages-router`][migrate-v4-to-v5-pages] 1167 | - [From `<0.4` to `v4`][migrate-v1-to-v4] 1168 | 1169 | ## License 1170 | 1171 | MIT-licensed. See [LICENSE][LICENSE]. 1172 | 1173 | [api-versioning]: https://www.sanity.io/docs/api-versioning?utm_source=github&utm_medium=readme&utm_campaign=next-sanity 1174 | [app-router]: https://nextjs.org/docs/app/building-your-application/routing 1175 | [cdn]: https://www.sanity.io/docs/asset-cdn?utm_source=github&utm_medium=readme&utm_campaign=next-sanity 1176 | [groq-syntax-highlighting]: https://marketplace.visualstudio.com/items?itemName=sanity-io.vscode-sanity 1177 | [groq-webhook]: https://www.sanity.io/docs/webhooks?utm_source=github&utm_medium=readme&utm_campaign=next-sanity 1178 | [image-url]: https://www.sanity.io/docs/presenting-images?utm_source=github&utm_medium=readme&utm_campaign=next-sanity 1179 | [LICENSE]: LICENSE 1180 | [migrate-v1-to-v4]: https://github.com/sanity-io/next-sanity/blob/main/packages/next-sanity/MIGRATE-v1-to-v4.md 1181 | [migrate-v4-to-v5-app]: https://github.com/sanity-io/next-sanity/blob/main/packages/next-sanity/MIGRATE-v4-to-v5-app-router.md 1182 | [migrate-v4-to-v5-pages]: https://github.com/sanity-io/next-sanity/blob/main/packages/next-sanity/MIGRATE-v4-to-v5-pages-router.md 1183 | [migrate-v5-to-v6]: https://github.com/sanity-io/next-sanity/blob/main/packages/next-sanity/MIGRATE-v5-to-v6.md 1184 | [migrate-v6-to-v7]: https://github.com/sanity-io/next-sanity/blob/main/packages/next-sanity/MIGRATE-v6-to-v7.md 1185 | [migrate-v7-to-v8]: https://github.com/sanity-io/next-sanity/blob/main/packages/next-sanity/MIGRATE-v7-to-v8.md 1186 | [migrate-v8-to-v9]: https://github.com/sanity-io/next-sanity/blob/main/packages/next-sanity/MIGRATE-v8-to-v9.md 1187 | [next-cache]: https://nextjs.org/docs/app/building-your-application/caching 1188 | [next-docs]: https://nextjs.org/docs 1189 | [next-revalidate-docs]: https://nextjs.org/docs/app/api-reference/functions/fetch#optionsnextrevalidate 1190 | [pages-router]: https://nextjs.org/docs/pages/building-your-application/routing 1191 | [personal-website-template]: https://github.com/sanity-io/template-nextjs-personal-website 1192 | [portable-text]: https://portabletext.org 1193 | [sanity-client]: https://www.sanity.io/docs/js-client?utm_source=github&utm_medium=readme&utm_campaign=next-sanity 1194 | [sanity]: https://www.sanity.io?utm_source=github&utm_medium=readme&utm_campaign=next-sanity 1195 | [visual-editing]: https://www.sanity.io/docs/introduction-to-visual-editing?utm_source=github&utm_medium=readme&utm_campaign=next-sanity 1196 | [webhook-template-revalidate-tag]: https://www.sanity.io/manage/webhooks/share?name=Tag-based+Revalidation+Hook+for+Next.js+13+&description=1.+Replace+URL+with+the+preview+or+production+URL+for+your+revalidation+handler+in+your+Next.js+app%0A2.%C2%A0Insert%2Freplace+the+document+types+you+want+to+be+able+to+make+tags+for+in+the+Filter+array%0A3.%C2%A0Make+a+Secret+that+you+also+add+to+your+app%27s+environment+variables+%28SANITY_REVALIDATE_SECRET%29%0A%0AFor+complete+instructions%2C+see+the+README+on%3A%0Ahttps%3A%2F%2Fgithub.com%2Fsanity-io%2Fnext-sanity&url=https%3A%2F%2FYOUR-PRODUCTION-URL.TLD%2Fapi%2Frevalidate-tag&on=create&on=update&on=delete&filter=_type+in+%5B%22post%22%2C+%22home%22%2C+%22OTHER_DOCUMENT_TYPE%22%5D&projection=%7B_type%7D&httpMethod=POST&apiVersion=v2021-03-25&includeDrafts=&headers=%7B%7D 1197 | [webhook-template-revalidate-path]: https://www.sanity.io/manage/webhooks/share?name=Path-based+Revalidation+Hook+for+Next.js&description=1.+Replace+URL+with+the+preview+or+production+URL+for+your+revalidation+handler+in+your+Next.js+app%0A2.%C2%A0Insert%2Freplace+the+document+types+you+want+to+be+able+to+make+tags+for+in+the+Filter+array%0A3.%C2%A0Make+a+Secret+that+you+also+add+to+your+app%27s+environment+variables+%28SANITY_REVALIDATE_SECRET%29%0A%0AFor+complete+instructions%2C+see+the+README+on%3A%0Ahttps%3A%2F%2Fgithub.com%2Fsanity-io%2Fnext-sanity&url=https%3A%2F%2FYOUR-PRODUCTION-URL.TLD%2Fapi%2Frevalidate-path&on=create&on=update&on=delete&filter=_type+in+%5B%22post%22%2C+%22home%22%2C+%22OTHER_DOCUMENT_TYPES%22%5D&projection=%7B%0A++%22path%22%3A+select%28%0A++++_type+%3D%3D+%22post%22+%3D%3E+%22%2Fposts%2F%22+%2B+slug.current%2C%0A++++slug.current%0A++%29%0A%7D&httpMethod=POST&apiVersion=v2021-03-25&includeDrafts=&headers=%7B%7D 1198 | [sanity-typegen]: https://www.sanity.io/docs/sanity-typegen?utm_source=github&utm_medium=readme&utm_campaign=next-sanity 1199 | [sanity-typegen-monorepo]: https://www.sanity.io/docs/sanity-typegen#1a6a147d6737?utm_source=github&utm_medium=readme&utm_campaign=next-sanity 1200 | [sanity-typegen-queries]: https://www.sanity.io/docs/sanity-typegen#c3ef15d8ad39?utm_source=github&utm_medium=readme&utm_campaign=next-sanity 1201 | [sanity-docs]: https://www.sanity.io/docs 1202 | [sanity-graphql]: https://www.sanity.io/docs/graphql?utm_source=github&utm_medium=readme&utm_campaign=next-sanity 1203 | [vs-code-extension]: https://marketplace.visualstudio.com/items?itemName=sanity-io.vscode-sanity 1204 | [sanity-studio]: https://www.sanity.io/docs/sanity-studio?utm_source=github&utm_medium=readme&utm_campaign=next-sanity 1205 | [groq-functions]: https://www.sanity.io/docs/groq-functions?utm_source=github&utm_medium=readme&utm_campaign=next-sanity 1206 | [vercel-content-link]: https://vercel.com/docs/workflow-collaboration/edit-mode#content-link?utm_source=github&utm_medium=readme&utm_campaign=next-sanity 1207 | [sanity-next-clean-starter]: https://www.sanity.io/templates/nextjs-sanity-clean 1208 | [sanity-next-featured-starter]: https://www.sanity.io/templates/personal-website-with-built-in-content-editing 1209 | [live-content-api]: https://www.sanity.io/docs/live-content-api?utm_source=github&utm_medium=readme&utm_campaign=next-sanity 1210 | -------------------------------------------------------------------------------- /packages/next-sanity/package.config.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | import path from 'node:path' 3 | 4 | import {defineConfig} from '@sanity/pkg-utils' 5 | 6 | const MODULE_PATHS_WHICH_USE_CLIENT_DIRECTIVE_SHOULD_BE_ADDED = [ 7 | path.join('src', 'image', 'index.ts'), 8 | path.join('src', 'studio', 'client-component', 'index.ts'), 9 | path.join('src', 'visual-editing', 'client-component', 'index.ts'), 10 | ] 11 | 12 | const MODULE_PATHS_WHICH_USE_SERVER_DIRECTIVE_SHOULD_BE_ADDED = [ 13 | path.join('src', 'visual-editing', 'server-actions', 'index.ts'), 14 | ] 15 | 16 | export default defineConfig({ 17 | tsconfig: 'tsconfig.build.json', 18 | bundles: [ 19 | { 20 | source: './src/index.edge-light.ts', 21 | import: './dist/index.edge-light.js', 22 | }, 23 | ], 24 | rollup: { 25 | output: { 26 | banner: (chunkInfo) => { 27 | if ( 28 | MODULE_PATHS_WHICH_USE_CLIENT_DIRECTIVE_SHOULD_BE_ADDED.find((modulePath) => 29 | chunkInfo.facadeModuleId?.endsWith(modulePath), 30 | ) 31 | ) { 32 | return `"use client"` 33 | } 34 | if ( 35 | MODULE_PATHS_WHICH_USE_SERVER_DIRECTIVE_SHOULD_BE_ADDED.find((modulePath) => 36 | chunkInfo.facadeModuleId?.endsWith(modulePath), 37 | ) 38 | ) { 39 | return `"use server"` 40 | } 41 | return '' 42 | }, 43 | }, 44 | }, 45 | extract: { 46 | rules: { 47 | 'ae-incompatible-release-tags': 'warn', 48 | 'ae-internal-missing-underscore': 'off', 49 | 'ae-missing-release-tag': 'warn', 50 | }, 51 | }, 52 | 53 | /* 54 | reactCompilerOptions: { 55 | logger: { 56 | logEvent(filename, event) { 57 | if (event.kind === 'CompileError') { 58 | console.group(`[${filename}] ${event.kind}`) 59 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 60 | const {reason, description, severity, loc, suggestions} = event.detail as any 61 | console.error(`[${severity}] ${reason}`) 62 | console.log(`${filename}:${loc.start?.line}:${loc.start?.column} ${description}`) 63 | console.log(suggestions) 64 | 65 | console.groupEnd() 66 | } 67 | }, 68 | }, 69 | }, 70 | // */ 71 | }) 72 | -------------------------------------------------------------------------------- /packages/next-sanity/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "next-sanity", 3 | "version": "9.12.0", 4 | "description": "Sanity.io toolkit for Next.js", 5 | "keywords": [ 6 | "sanity", 7 | "sanity.io", 8 | "next.js", 9 | "studio", 10 | "studio-v3", 11 | "live", 12 | "preview" 13 | ], 14 | "homepage": "https://github.com/sanity-io/next-sanity#readme", 15 | "bugs": { 16 | "url": "https://github.com/sanity-io/next-sanity/issues" 17 | }, 18 | "repository": { 19 | "type": "git", 20 | "url": "git+ssh://git@github.com/sanity-io/next-sanity.git", 21 | "directory": "packages/next-sanity" 22 | }, 23 | "license": "MIT", 24 | "author": "Sanity.io ", 25 | "sideEffects": false, 26 | "type": "module", 27 | "exports": { 28 | ".": { 29 | "source": "./src/index.ts", 30 | "edge-light": "./dist/index.edge-light.js", 31 | "import": "./dist/index.js", 32 | "require": "./dist/index.cjs", 33 | "default": "./dist/index.js" 34 | }, 35 | "./draft-mode": { 36 | "source": "./src/draft-mode/index.ts", 37 | "import": "./dist/draft-mode.js", 38 | "require": "./dist/draft-mode.cjs", 39 | "default": "./dist/draft-mode.js" 40 | }, 41 | "./hooks": { 42 | "source": "./src/hooks/index.ts", 43 | "import": "./dist/hooks.js", 44 | "require": "./dist/hooks.cjs", 45 | "default": "./dist/hooks.js" 46 | }, 47 | "./image": { 48 | "source": "./src/image/index.ts", 49 | "import": "./dist/image.js", 50 | "require": "./dist/image.cjs", 51 | "default": "./dist/image.js" 52 | }, 53 | "./preview": { 54 | "source": "./src/preview/index.ts", 55 | "import": "./dist/preview.js", 56 | "require": "./dist/preview.cjs", 57 | "default": "./dist/preview.js" 58 | }, 59 | "./preview/live-query": { 60 | "source": "./src/preview/LiveQuery/index.ts", 61 | "import": "./dist/preview/live-query.js", 62 | "require": "./dist/preview/live-query.cjs", 63 | "default": "./dist/preview/live-query.js" 64 | }, 65 | "./studio": { 66 | "source": "./src/studio/index.ts", 67 | "import": "./dist/studio.js", 68 | "require": "./dist/studio.cjs", 69 | "default": "./dist/studio.js" 70 | }, 71 | "./studio/client-component": { 72 | "source": "./src/studio/client-component/index.ts", 73 | "import": "./dist/studio/client-component.js", 74 | "require": "./dist/studio/client-component.cjs", 75 | "default": "./dist/studio/client-component.js" 76 | }, 77 | "./visual-editing/client-component": { 78 | "source": "./src/visual-editing/client-component/index.ts", 79 | "import": "./dist/visual-editing/client-component.js", 80 | "require": "./dist/visual-editing/client-component.cjs", 81 | "default": "./dist/visual-editing/client-component.js" 82 | }, 83 | "./visual-editing/server-actions": { 84 | "source": "./src/visual-editing/server-actions/index.ts", 85 | "import": "./dist/visual-editing/server-actions.js", 86 | "require": "./dist/visual-editing/server-actions.cjs", 87 | "default": "./dist/visual-editing/server-actions.js" 88 | }, 89 | "./webhook": { 90 | "source": "./src/webhook/index.ts", 91 | "import": "./dist/webhook.js", 92 | "require": "./dist/webhook.cjs", 93 | "default": "./dist/webhook.js" 94 | }, 95 | "./package.json": "./package.json" 96 | }, 97 | "main": "./dist/index.cjs", 98 | "module": "./dist/index.js", 99 | "types": "./dist/index.d.ts", 100 | "typesVersions": { 101 | "*": { 102 | "draft-mode": [ 103 | "./dist/draft-mode.d.ts" 104 | ], 105 | "hooks": [ 106 | "./dist/hooks.d.ts" 107 | ], 108 | "image": [ 109 | "./dist/image.d.ts" 110 | ], 111 | "preview": [ 112 | "./dist/preview.d.ts" 113 | ], 114 | "preview/live-query": [ 115 | "./dist/preview/live-query.d.ts" 116 | ], 117 | "studio": [ 118 | "./dist/studio.d.ts" 119 | ], 120 | "studio/client-component": [ 121 | "./dist/studio/client-component.d.ts" 122 | ], 123 | "visual-editing/client-component": [ 124 | "./dist/visual-editing/client-component.d.ts" 125 | ], 126 | "visual-editing/server-actions": [ 127 | "./dist/visual-editing/server-actions.d.ts" 128 | ], 129 | "webhook": [ 130 | "./dist/webhook.d.ts" 131 | ] 132 | } 133 | }, 134 | "files": [ 135 | "dist", 136 | "src" 137 | ], 138 | "scripts": { 139 | "build": "pkg build --strict --clean --check", 140 | "coverage": "npm test -- --coverage", 141 | "format": "eslint --fix .", 142 | "lint": "eslint --max-warnings 0 .", 143 | "prepublishOnly": "npm run build", 144 | "test": "vitest", 145 | "type-check": "tsc --noEmit" 146 | }, 147 | "browserslist": "extends @sanity/browserslist-config", 148 | "dependencies": { 149 | "@portabletext/react": "^3.2.1", 150 | "@sanity/client": "^7.1.0", 151 | "@sanity/next-loader": "^1.6.0", 152 | "@sanity/preview-kit": "^6.1.0", 153 | "@sanity/preview-url-secret": "^2.1.11", 154 | "@sanity/visual-editing": "^2.14.0", 155 | "groq": "^3.88.2", 156 | "history": "^5.3.0" 157 | }, 158 | "devDependencies": { 159 | "@sanity/browserslist-config": "^1.0.5", 160 | "@sanity/eslint-config-studio": "^5.0.2", 161 | "@sanity/pkg-utils": "^7.2.2", 162 | "@sanity/webhook": "4.0.4", 163 | "@types/react": "^19.1.3", 164 | "@types/react-dom": "^19.1.3", 165 | "@typescript-eslint/eslint-plugin": "^8.33.0", 166 | "@vitest/coverage-v8": "^3.1.3", 167 | "eslint": "^8.57.1", 168 | "eslint-config-prettier": "^10.1.5", 169 | "eslint-config-sanity": "^7.1.4", 170 | "eslint-gitignore": "^0.1.0", 171 | "eslint-plugin-react": "^7.37.5", 172 | "eslint-plugin-react-compiler": "beta", 173 | "eslint-plugin-react-hooks": "^5.2.0", 174 | "eslint-plugin-simple-import-sort": "^12.1.1", 175 | "next": "15.3.2", 176 | "react": "19.1.0", 177 | "react-dom": "19.1.0", 178 | "styled-components": "^6.1.18", 179 | "typescript": "5.8.3", 180 | "vite-tsconfig-paths": "^5.1.4", 181 | "vitest": "^3.1.3" 182 | }, 183 | "peerDependencies": { 184 | "@sanity/client": "^7.1.0", 185 | "@sanity/icons": "^3.7.0", 186 | "@sanity/types": "^3.88.2", 187 | "@sanity/ui": "^2.15.17", 188 | "next": "^14.2 || ^15.0.0-0", 189 | "react": "^18.3 || ^19.0.0-0", 190 | "react-dom": "^18.3 || ^19.0.0-0", 191 | "sanity": "^3.88.2", 192 | "styled-components": "^6.1" 193 | }, 194 | "engines": { 195 | "node": ">=18.18" 196 | } 197 | } 198 | -------------------------------------------------------------------------------- /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/draft-mode/define-enable-draft-mode.ts: -------------------------------------------------------------------------------- 1 | import {validatePreviewUrl} from '@sanity/preview-url-secret' 2 | import {perspectiveCookieName} from '@sanity/preview-url-secret/constants' 3 | import {cookies, draftMode} from 'next/headers' 4 | import {redirect} from 'next/navigation' 5 | 6 | import type {SanityClient} from '../client' 7 | 8 | /** 9 | * @public 10 | */ 11 | export interface DefineEnableDraftModeOptions { 12 | client: SanityClient 13 | } 14 | 15 | /** 16 | * @public 17 | */ 18 | export interface EnableDraftMode { 19 | GET: (request: Request) => Promise 20 | } 21 | 22 | /** 23 | * Sets up an API route for enabling draft mode, can be paired with the `previewUrl.previewMode.enable` in `sanity/presentation`. 24 | * Can also be used with `sanity-plugin-iframe-pane`. 25 | * @example 26 | * ```ts 27 | * // src/app/api/draft-mode/enable/route.ts 28 | * 29 | * import { defineEnableDraftMode } from "next-sanity/draft-mode"; 30 | * import { client } from "@/sanity/lib/client"; 31 | * 32 | * export const { GET } = defineEnableDraftMode({ 33 | * client: client.withConfig({ token: process.env.SANITY_API_READ_TOKEN }), 34 | * }); 35 | * ``` 36 | * 37 | * @public 38 | */ 39 | export function defineEnableDraftMode(options: DefineEnableDraftModeOptions): EnableDraftMode { 40 | const {client} = options 41 | return { 42 | GET: async (request: Request) => { 43 | // eslint-disable-next-line no-warning-comments 44 | // @TODO check if already in draft mode at a much earlier stage, and skip validation 45 | 46 | const { 47 | isValid, 48 | redirectTo = '/', 49 | studioPreviewPerspective, 50 | } = await validatePreviewUrl(client, request.url) 51 | if (!isValid) { 52 | return new Response('Invalid secret', {status: 401}) 53 | } 54 | 55 | const draftModeStore = await draftMode() 56 | 57 | // Let's enable draft mode if it's not already enabled 58 | if (!draftModeStore.isEnabled) { 59 | draftModeStore.enable() 60 | } 61 | 62 | const dev = process.env.NODE_ENV !== 'production' 63 | 64 | // Override cookie header for draft mode for usage in live-preview 65 | // https://github.com/vercel/next.js/issues/49927 66 | const cookieStore = await cookies() 67 | const cookie = cookieStore.get('__prerender_bypass')! 68 | cookieStore.set({ 69 | name: '__prerender_bypass', 70 | value: cookie?.value, 71 | httpOnly: true, 72 | path: '/', 73 | secure: !dev, 74 | sameSite: dev ? 'lax' : 'none', 75 | }) 76 | 77 | if (studioPreviewPerspective) { 78 | cookieStore.set({ 79 | name: perspectiveCookieName, 80 | value: studioPreviewPerspective, 81 | httpOnly: true, 82 | path: '/', 83 | secure: !dev, 84 | sameSite: dev ? 'lax' : 'none', 85 | }) 86 | } 87 | 88 | // the `redirect` function throws, and eventually returns a Promise. TSC doesn't "see" that so we have to tell it 89 | return redirect(redirectTo) as Promise 90 | }, 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /packages/next-sanity/src/draft-mode/index.ts: -------------------------------------------------------------------------------- 1 | export * from './define-enable-draft-mode' 2 | -------------------------------------------------------------------------------- /packages/next-sanity/src/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export * from '@sanity/next-loader/hooks' 2 | export {useOptimistic} from '@sanity/visual-editing/react' 3 | -------------------------------------------------------------------------------- /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/image/imageLoader.ts: -------------------------------------------------------------------------------- 1 | import type {ImageLoader} from 'next/image' 2 | 3 | /** 4 | * @alpha 5 | */ 6 | export const 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 | }) satisfies ImageLoader 23 | -------------------------------------------------------------------------------- /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/index.edge-light.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Some of the exports on index.ts causes errors on the edge runtime, so we omit them here. 3 | */ 4 | 5 | import type {VisualEditingProps} from './visual-editing' 6 | 7 | export * from './client' 8 | export * from './create-data-attribute' 9 | export * from '@portabletext/react' 10 | export * from '@sanity/next-loader' 11 | export {defineQuery, default as groq} from 'groq' 12 | export type {VisualEditingProps} from 'next-sanity/visual-editing/client-component' 13 | 14 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 15 | export function VisualEditing(_props: VisualEditingProps): React.ReactNode { 16 | throw new TypeError('VisualEditing is not supported on the edge runtime') 17 | } 18 | -------------------------------------------------------------------------------- /packages/next-sanity/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './client' 2 | export * from './create-data-attribute' 3 | export * from './visual-editing' 4 | export * from '@portabletext/react' 5 | export * from '@sanity/next-loader' 6 | export {defineQuery, default as groq} from 'groq' 7 | -------------------------------------------------------------------------------- /packages/next-sanity/src/preview/LiveQuery/index.ts: -------------------------------------------------------------------------------- 1 | export type {LiveQueryClientComponentProps, LiveQueryProps} from '@sanity/preview-kit/live-query' 2 | export {LiveQuery as default, LiveQuery} from '@sanity/preview-kit/live-query' 3 | -------------------------------------------------------------------------------- /packages/next-sanity/src/preview/index.ts: -------------------------------------------------------------------------------- 1 | export type { 2 | CacheOptions, 3 | DefineListenerContext, 4 | isEqualFn, 5 | ListenerGetSnapshot, 6 | ListenerSubscribe, 7 | LiveQueryHookOptions, 8 | LiveQueryProviderProps, 9 | Logger, 10 | QueryEnabled, 11 | QueryLoading, 12 | } from '@sanity/preview-kit' 13 | export {default, LiveQueryProvider, useLiveQuery} from '@sanity/preview-kit' 14 | -------------------------------------------------------------------------------- /packages/next-sanity/src/studio/NextStudioLayout.tsx: -------------------------------------------------------------------------------- 1 | import {memo} from 'react' 2 | 3 | /** @public */ 4 | export interface NextStudioLayoutProps { 5 | children: React.ReactNode 6 | } 7 | 8 | const NextStudioLayoutComponent = ({children}: NextStudioLayoutProps) => { 9 | return ( 10 |
21 | {children} 22 |
23 | ) 24 | } 25 | 26 | /** @public */ 27 | export const NextStudioLayout = memo(NextStudioLayoutComponent) 28 | -------------------------------------------------------------------------------- /packages/next-sanity/src/studio/NextStudioNoScript.tsx: -------------------------------------------------------------------------------- 1 | /** @internal */ 2 | export const NextStudioNoScript = (): React.JSX.Element => ( 3 | 33 | ) 34 | -------------------------------------------------------------------------------- /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 |