├── .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 |
{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 |
112 | {post.publishedAt?.toDateString()}
113 |
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 |
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 |
87 | {post.publishedAt?.toLocaleDateString()}
88 |
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 |
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 |
686 | {product.title}
687 |
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 | 
746 |
747 | With drafts enabled, you'll see the Edit Mode button show up if your Vercel plan is eligible:
748 |
749 | 
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 |
774 |
775 | And the Preview URL Sharing feature:
776 |
777 |
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 |
4 |
15 |
25 |
JavaScript disabled
26 |
27 | Please enable JavaScript in your browser
28 | and reload the page to proceed.
29 |
30 |
31 |
32 |
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 |
17 |
18 | >
19 | )
20 | }
21 |
--------------------------------------------------------------------------------
/packages/next-sanity/src/studio/client-component/NextStudio.tsx:
--------------------------------------------------------------------------------
1 | import {useMemo} from 'react'
2 | import {Studio, type StudioProps} from 'sanity'
3 |
4 | import {NextStudioLayout} from '../NextStudioLayout'
5 | import {NextStudioNoScript} from '../NextStudioNoScript'
6 | import {createHashHistoryForStudio} from './createHashHistoryForStudio'
7 | import {StyledComponentsRegistry} from './registry'
8 | import {useIsMounted} from './useIsMounted'
9 |
10 | /** @public */
11 | export interface NextStudioProps extends StudioProps {
12 | children?: React.ReactNode
13 | /**
14 | * Render the tag
15 | * @defaultValue true
16 | * @alpha
17 | */
18 | unstable__noScript?: boolean
19 | /**
20 | * The 'hash' option is new feature that is not yet stable for production, but is available for testing and its API won't change in a breaking way.
21 | * If 'hash' doesn't work for you, or if you want to use a memory based history, you can use the `unstable_history` prop instead.
22 | * @alpha
23 | * @defaultValue 'browser'
24 | */
25 | history?: 'browser' | 'hash'
26 | }
27 | /**
28 | * Override how the Studio renders by passing children.
29 | * This is useful for advanced use cases where you're using StudioProvider and StudioLayout instead of Studio:
30 | * ```
31 | * import {StudioProvider, StudioLayout} from 'sanity'
32 | * import {NextStudio} from 'next-sanity/studio'
33 | *
34 | *
35 | *
36 | *
37 | *
38 | *
39 | * ```
40 | * @public
41 | */
42 | export default function NextStudioComponent({
43 | children,
44 | config,
45 | unstable__noScript = true,
46 | scheme,
47 | history,
48 | ...props
49 | }: NextStudioProps): React.JSX.Element {
50 | const isMounted = useIsMounted()
51 | const unstableHistory = useMemo(() => {
52 | if (props.unstable_history && history) {
53 | throw new Error('Cannot use both `unstable_history` and `history` props at the same time')
54 | }
55 |
56 | if (isMounted && history === 'hash') {
57 | return createHashHistoryForStudio()
58 | }
59 | return props.unstable_history
60 | }, [history, isMounted, props.unstable_history])
61 |
62 | return (
63 | <>
64 | {unstable__noScript && }
65 |
66 |
67 | {history === 'hash' && !isMounted
68 | ? null
69 | : children || (
70 |
77 | )}
78 |
79 |
80 | >
81 | )
82 | }
83 |
--------------------------------------------------------------------------------
/packages/next-sanity/src/studio/client-component/NextStudioLazy.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | *
3 | * If pages router supported `next/dynamic` imports (it wants `next/dynamic.js`),
4 | * or if turbopack in app router allowed `next/dynamic.js` (it doesn't yet)
5 | * we could use `dynamic(() => import('...), {ssr: false})` here.
6 | * Since we can't, we need to use a lazy import and Suspense ourself.
7 | */
8 |
9 | import {lazy, Suspense} from 'react'
10 |
11 | import type {NextStudioProps} from './NextStudio'
12 |
13 | const NextStudioClientComponent = lazy(() => import('./NextStudio'))
14 |
15 | export function NextStudioLazyClientComponent(props: NextStudioProps): React.ReactNode {
16 | return (
17 |
18 |
19 |
20 | )
21 | }
22 |
--------------------------------------------------------------------------------
/packages/next-sanity/src/studio/client-component/createHashHistoryForStudio.ts:
--------------------------------------------------------------------------------
1 | import {createHashHistory, type History, type Listener} from 'history'
2 |
3 | /** @internal */
4 | export function createHashHistoryForStudio(): History {
5 | const history = createHashHistory()
6 | return {
7 | get action() {
8 | return history.action
9 | },
10 | get location() {
11 | return history.location
12 | },
13 | get createHref() {
14 | return history.createHref
15 | },
16 | get push() {
17 | return history.push
18 | },
19 | get replace() {
20 | return history.replace
21 | },
22 | get go() {
23 | return history.go
24 | },
25 | get back() {
26 | return history.back
27 | },
28 | get forward() {
29 | return history.forward
30 | },
31 | get block() {
32 | return history.block
33 | },
34 | // Overriding listen to workaround a problem where native history provides history.listen(location => void), but the npm package is history.listen(({action, location}) => void)
35 | listen(listener: Listener) {
36 | // return history.listen(({ action, location }) => {
37 | return history.listen(({location}) => {
38 | // console.debug('history.listen', action, location)
39 | // @ts-expect-error -- working around a bug? in studio
40 | listener(location)
41 | })
42 | },
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/packages/next-sanity/src/studio/client-component/index.ts:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | export type {NextStudioProps} from './NextStudio'
4 | export {NextStudioLazyClientComponent as NextStudio} from './NextStudioLazy'
5 |
--------------------------------------------------------------------------------
/packages/next-sanity/src/studio/client-component/registry.tsx:
--------------------------------------------------------------------------------
1 | // https://nextjs.org/docs/app/building-your-application/styling/css-in-js#styled-components
2 | import {useServerInsertedHTML} from 'next/navigation.js'
3 | import {useState} from 'react'
4 | import {ServerStyleSheet, StyleSheetManager} from 'styled-components'
5 |
6 | export function StyledComponentsRegistry({
7 | children,
8 | isMounted,
9 | }: {
10 | children: React.ReactNode
11 | isMounted: boolean
12 | }): React.JSX.Element {
13 | // Only create stylesheet once with lazy initial state
14 | // x-ref: https://reactjs.org/docs/hooks-reference.html#lazy-initial-state
15 | const [styledComponentsStyleSheet] = useState(() => new ServerStyleSheet())
16 |
17 | useServerInsertedHTML(() => {
18 | const styles = styledComponentsStyleSheet.getStyleElement()
19 | styledComponentsStyleSheet.instance.clearTag()
20 | return <>{styles}>
21 | })
22 |
23 | if (isMounted) return <>{children}>
24 |
25 | return (
26 | {children}
27 | )
28 | }
29 |
--------------------------------------------------------------------------------
/packages/next-sanity/src/studio/client-component/useIsMounted.ts:
--------------------------------------------------------------------------------
1 | import {useSyncExternalStore} from 'react'
2 |
3 | /** @internal */
4 | export function useIsMounted(): boolean {
5 | return useSyncExternalStore(
6 | emptySubscribe,
7 | () => true,
8 | () => false,
9 | )
10 | }
11 | // eslint-disable-next-line no-empty-function
12 | const emptySubscribe = () => () => {}
13 |
--------------------------------------------------------------------------------
/packages/next-sanity/src/studio/head.tsx:
--------------------------------------------------------------------------------
1 | import type {Metadata, Viewport} from 'next'
2 |
3 | /**
4 | * In router segments (`/app/studio/[[...index]]/page.tsx`):
5 | * ```tsx
6 | * // If you don't want to change any defaults you can just re-export the viewport config directly:
7 | * export {viewport} from 'next-sanity/studio'
8 | *
9 | * // To customize the viewport config, spread it on the export:
10 | * import {viewport as studioViewport} from 'next-sanity/studio'
11 | * import type { Viewport } from 'next'
12 | *
13 | * export const viewport: Viewport = {
14 | * ...studioViewport,
15 | * // Overrides the viewport to resize behavior
16 | * interactiveWidget: 'resizes-content'
17 | * })
18 | * ```
19 | * @public
20 | */
21 | export const viewport = {
22 | width: 'device-width' as const,
23 | initialScale: 1 as const,
24 | // Studio implements display cutouts CSS (The iPhone Notch ™ ) and needs `viewport-fit=covered` for it to work correctly
25 | viewportFit: 'cover',
26 | } satisfies Viewport
27 |
28 | /**
29 | * In router segments (`/app/studio/[[...index]]/page.tsx`):
30 | * ```tsx
31 | * // If you don't want to change any defaults you can just re-export the metadata directly:
32 | * export {metadata} from 'next-sanity/studio'
33 | *
34 | * // To customize the metadata, spread it on the export:
35 | * import {metadata as studioMetadata} from 'next-sanity/studio'
36 | * import type { Metadata } from 'next'
37 | *
38 | * export const metadata: Metadata = {
39 | * ...studioMetadata,
40 | * // Set another title
41 | * title: 'My Studio',
42 | * })
43 | * ```
44 | * @public
45 | */
46 | export const metadata = {
47 | referrer: 'same-origin' as const,
48 | robots: 'noindex' as const,
49 | } satisfies Metadata
50 |
--------------------------------------------------------------------------------
/packages/next-sanity/src/studio/index.ts:
--------------------------------------------------------------------------------
1 | export {metadata, viewport} from './head'
2 | export * from './NextStudioLayout'
3 | export * from './NextStudioNoScript'
4 | export {NextStudioWithBridge as NextStudio} from './NextStudioWithBridge'
5 | export {type NextStudioProps} from 'next-sanity/studio/client-component'
6 |
--------------------------------------------------------------------------------
/packages/next-sanity/src/visual-editing/client-component/VisualEditing.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | type HistoryAdapter,
3 | type HistoryAdapterNavigate,
4 | type HistoryRefresh,
5 | VisualEditing as VisualEditingComponent,
6 | type VisualEditingOptions,
7 | } from '@sanity/visual-editing/react'
8 | import {usePathname, useRouter, useSearchParams} from 'next/navigation.js'
9 | import {revalidateRootLayout} from 'next-sanity/visual-editing/server-actions'
10 | import {useCallback, useEffect, useMemo, useRef, useState} from 'react'
11 |
12 | import {addPathPrefix, normalizePathTrailingSlash, removePathPrefix} from './utils'
13 |
14 | /**
15 | * @public
16 | */
17 | export interface VisualEditingProps extends Omit {
18 | /**
19 | * @deprecated The histoy adapter is already implemented
20 | */
21 | history?: never
22 | /**
23 | * If next.config.ts is configured with a basePath we try to configure it automatically,
24 | * you can disable this by setting basePath to ''.
25 | * @example basePath="/my-custom-base-path"
26 | * @alpha experimental and may change without notice
27 | * @defaultValue process.env.__NEXT_ROUTER_BASEPATH || ''
28 | */
29 | basePath?: string
30 | /**
31 | * If next.config.ts is configured with a `trailingSlash` we try to detect it automatically,
32 | * it can be controlled manually by passing a boolean.
33 | * @example trailingSlash={true}
34 | * @alpha experimental and may change without notice
35 | * @defaultValue Boolean(process.env.__NEXT_TRAILING_SLASH)
36 | */
37 | trailingSlash?: boolean
38 | }
39 |
40 | export default function VisualEditing(props: VisualEditingProps): React.JSX.Element | null {
41 | const {basePath = '', plugins, components, refresh, trailingSlash = false, zIndex} = props
42 |
43 | const router = useRouter()
44 | const routerRef = useRef(router)
45 | const [navigate, setNavigate] = useState()
46 |
47 | useEffect(() => {
48 | routerRef.current = router
49 | }, [router])
50 |
51 | const history = useMemo(
52 | () => ({
53 | subscribe: (_navigate) => {
54 | setNavigate(() => _navigate)
55 | return () => setNavigate(undefined)
56 | },
57 | update: (update) => {
58 | switch (update.type) {
59 | case 'push':
60 | return routerRef.current.push(removePathPrefix(update.url, basePath))
61 | case 'pop':
62 | return routerRef.current.back()
63 | case 'replace':
64 | return routerRef.current.replace(removePathPrefix(update.url, basePath))
65 | default:
66 | throw new Error(`Unknown update type: ${update.type}`)
67 | }
68 | },
69 | }),
70 | [basePath],
71 | )
72 |
73 | const pathname = usePathname()
74 | const searchParams = useSearchParams()
75 | useEffect(() => {
76 | if (navigate) {
77 | navigate({
78 | type: 'push',
79 | url: normalizePathTrailingSlash(
80 | addPathPrefix(`${pathname}${searchParams?.size ? `?${searchParams}` : ''}`, basePath),
81 | trailingSlash,
82 | ),
83 | })
84 | }
85 | }, [basePath, navigate, pathname, searchParams, trailingSlash])
86 |
87 | const handleRefresh = useCallback(
88 | (payload: HistoryRefresh) => {
89 | if (refresh) return refresh(payload)
90 |
91 | const manualFastRefresh = () => {
92 | // eslint-disable-next-line no-console
93 | console.debug(
94 | 'Live preview is setup, calling router.refresh() to refresh the server components without refetching cached data',
95 | )
96 | routerRef.current.refresh()
97 | return Promise.resolve()
98 | }
99 | const manualFallbackRefresh = () => {
100 | // eslint-disable-next-line no-console
101 | console.debug(
102 | 'No loaders in live mode detected, or preview kit setup, revalidating root layout',
103 | )
104 | return revalidateRootLayout()
105 | }
106 | const mutationFastRefresh = (): false => {
107 | // eslint-disable-next-line no-console
108 | console.debug(
109 | 'Live preview is setup, mutation is skipped assuming its handled by the live preview',
110 | )
111 | return false
112 | }
113 | const mutationFallbackRefresh = () => {
114 | // eslint-disable-next-line no-console
115 | console.debug(
116 | 'No loaders in live mode detected, or preview kit setup, revalidating root layout',
117 | )
118 | return revalidateRootLayout()
119 | }
120 |
121 | switch (payload.source) {
122 | case 'manual':
123 | return payload.livePreviewEnabled ? manualFastRefresh() : manualFallbackRefresh()
124 | case 'mutation':
125 | return payload.livePreviewEnabled ? mutationFastRefresh() : mutationFallbackRefresh()
126 | default:
127 | throw new Error('Unknown refresh source', {cause: payload})
128 | }
129 | },
130 | [refresh],
131 | )
132 |
133 | return (
134 |
142 | )
143 | }
144 |
--------------------------------------------------------------------------------
/packages/next-sanity/src/visual-editing/client-component/VisualEditingLazy.tsx:
--------------------------------------------------------------------------------
1 | /**
2 | *
3 | * If pages router supported `next/dynamic` imports (it wants `next/dynamic.js`),
4 | * or if turbopack in app router allowed `next/dynamic.js` (it doesn't yet)
5 | * we could use `dynamic(() => import('...), {ssr: false})` here.
6 | * Since we can't, we need to use a lazy import and Suspense ourself.
7 | */
8 |
9 | import {lazy, Suspense} from 'react'
10 |
11 | import type {VisualEditingProps} from './VisualEditing'
12 |
13 | const VisualEditingClientComponent = lazy(() => import('./VisualEditing'))
14 |
15 | export function VisualEditingLazyClientComponent(props: VisualEditingProps): React.ReactNode {
16 | return (
17 |
18 |
19 |
20 | )
21 | }
22 |
--------------------------------------------------------------------------------
/packages/next-sanity/src/visual-editing/client-component/index.ts:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | export type {VisualEditingProps} from './VisualEditing'
4 | export {VisualEditingLazyClientComponent as default} from './VisualEditingLazy'
5 |
--------------------------------------------------------------------------------
/packages/next-sanity/src/visual-editing/client-component/utils.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * From: https://github.com/vercel/next.js/blob/5469e6427b54ab7e9876d4c85b47f9c3afdc5c1f/packages/next/src/shared/lib/router/utils/path-has-prefix.ts#L10-L17
3 | * Checks if a given path starts with a given prefix. It ensures it matches
4 | * exactly without containing extra chars. e.g. prefix /docs should replace
5 | * for /docs, /docs/, /docs/a but not /docsss
6 | * @param path The path to check.
7 | * @param prefix The prefix to check against.
8 | */
9 | function pathHasPrefix(path: string, prefix: string): boolean {
10 | if (typeof path !== 'string') {
11 | return false
12 | }
13 |
14 | const {pathname} = parsePath(path)
15 | return pathname === prefix || pathname.startsWith(`${prefix}/`)
16 | }
17 |
18 | /**
19 | * From: https://github.com/vercel/next.js/blob/5469e6427b54ab7e9876d4c85b47f9c3afdc5c1f/packages/next/src/shared/lib/router/utils/parse-path.ts#L6-L22
20 | * Given a path this function will find the pathname, query and hash and return
21 | * them. This is useful to parse full paths on the client side.
22 | * @param path A path to parse e.g. /foo/bar?id=1#hash
23 | */
24 | function parsePath(path: string): {
25 | pathname: string
26 | query: string
27 | hash: string
28 | } {
29 | const hashIndex = path.indexOf('#')
30 | const queryIndex = path.indexOf('?')
31 | const hasQuery = queryIndex > -1 && (hashIndex < 0 || queryIndex < hashIndex)
32 |
33 | if (hasQuery || hashIndex > -1) {
34 | return {
35 | pathname: path.substring(0, hasQuery ? queryIndex : hashIndex),
36 | query: hasQuery ? path.substring(queryIndex, hashIndex > -1 ? hashIndex : undefined) : '',
37 | hash: hashIndex > -1 ? path.slice(hashIndex) : '',
38 | }
39 | }
40 |
41 | return {pathname: path, query: '', hash: ''}
42 | }
43 |
44 | /**
45 | * From: https://github.com/vercel/next.js/blob/5469e6427b54ab7e9876d4c85b47f9c3afdc5c1f/packages/next/src/shared/lib/router/utils/add-path-prefix.ts#L3C1-L14C2
46 | * Adds the provided prefix to the given path. It first ensures that the path
47 | * is indeed starting with a slash.
48 | */
49 | export function addPathPrefix(path: string, prefix?: string): string {
50 | if (!path.startsWith('/') || !prefix) {
51 | return path
52 | }
53 | // If the path is exactly '/' then return just the prefix
54 | if (path === '/' && prefix) {
55 | return prefix
56 | }
57 |
58 | const {pathname, query, hash} = parsePath(path)
59 | return `${prefix}${pathname}${query}${hash}`
60 | }
61 |
62 | /**
63 | * From: https://github.com/vercel/next.js/blob/5469e6427b54ab7e9876d4c85b47f9c3afdc5c1f/packages/next/src/shared/lib/router/utils/remove-path-prefix.ts#L3-L39
64 | * Given a path and a prefix it will remove the prefix when it exists in the
65 | * given path. It ensures it matches exactly without containing extra chars
66 | * and if the prefix is not there it will be noop.
67 | *
68 | * @param path The path to remove the prefix from.
69 | * @param prefix The prefix to be removed.
70 | */
71 | export function removePathPrefix(path: string, prefix: string): string {
72 | // If the path doesn't start with the prefix we can return it as is. This
73 | // protects us from situations where the prefix is a substring of the path
74 | // prefix such as:
75 | //
76 | // For prefix: /blog
77 | //
78 | // /blog -> true
79 | // /blog/ -> true
80 | // /blog/1 -> true
81 | // /blogging -> false
82 | // /blogging/ -> false
83 | // /blogging/1 -> false
84 | if (!pathHasPrefix(path, prefix)) {
85 | return path
86 | }
87 |
88 | // Remove the prefix from the path via slicing.
89 | const withoutPrefix = path.slice(prefix.length)
90 |
91 | // If the path without the prefix starts with a `/` we can return it as is.
92 | if (withoutPrefix.startsWith('/')) {
93 | return withoutPrefix
94 | }
95 |
96 | // If the path without the prefix doesn't start with a `/` we need to add it
97 | // back to the path to make sure it's a valid path.
98 | return `/${withoutPrefix}`
99 | }
100 |
101 | /**
102 | * From: https://github.com/vercel/next.js/blob/dfe7fc03e2268e7cb765dce6a89e02c831c922d5/packages/next/src/client/normalize-trailing-slash.ts#L16
103 | * Normalizes the trailing slash of a path according to the `trailingSlash` option
104 | * in `next.config.js`.
105 | */
106 | export const normalizePathTrailingSlash = (path: string, trailingSlash: boolean): string => {
107 | const {pathname, query, hash} = parsePath(path)
108 | if (trailingSlash) {
109 | if (pathname.endsWith('/')) {
110 | return `${pathname}${query}${hash}`
111 | }
112 | return `${pathname}/${query}${hash}`
113 | }
114 |
115 | return `${removeTrailingSlash(pathname)}${query}${hash}`
116 | }
117 |
118 | /**
119 | * From: https://github.com/vercel/next.js/blob/dfe7fc03e2268e7cb765dce6a89e02c831c922d5/packages/next/src/shared/lib/router/utils/remove-trailing-slash.ts#L8
120 | * Removes the trailing slash for a given route or page path. Preserves the
121 | * root page. Examples:
122 | * - `/foo/bar/` -> `/foo/bar`
123 | * - `/foo/bar` -> `/foo/bar`
124 | * - `/` -> `/`
125 | */
126 | function removeTrailingSlash(route: string) {
127 | return route.replace(/\/$/, '') || '/'
128 | }
129 |
--------------------------------------------------------------------------------
/packages/next-sanity/src/visual-editing/index.tsx:
--------------------------------------------------------------------------------
1 | import type {VisualEditingProps} from 'next-sanity/visual-editing/client-component'
2 | import VisualEditingComponent from 'next-sanity/visual-editing/client-component'
3 |
4 | /**
5 | * @public
6 | */
7 | export function VisualEditing(props: VisualEditingProps): React.ReactElement {
8 | let autoBasePath: string | undefined
9 | if (typeof props.basePath !== 'string') {
10 | try {
11 | autoBasePath = process.env['__NEXT_ROUTER_BASEPATH']
12 | if (autoBasePath) {
13 | // eslint-disable-next-line no-console
14 | console.log(
15 | `Detected next basePath as ${JSON.stringify(autoBasePath)} by reading "process.env.__NEXT_ROUTER_BASEPATH". If this is incorrect then you can set it manually with the basePath prop on the component.`,
16 | )
17 | }
18 | } catch (err) {
19 | console.error('Failed detecting basePath', err)
20 | }
21 | }
22 | let autoTrailingSlash: boolean | undefined
23 | if (typeof props.trailingSlash !== 'boolean') {
24 | try {
25 | autoTrailingSlash = Boolean(process.env['__NEXT_TRAILING_SLASH'])
26 | if (autoTrailingSlash) {
27 | // eslint-disable-next-line no-console
28 | console.log(
29 | `Detected next trailingSlash as ${JSON.stringify(autoTrailingSlash)} by reading "process.env.__NEXT_TRAILING_SLASH". If this is incorrect then you can set it manually with the trailingSlash prop on the component.`,
30 | )
31 | }
32 | } catch (err) {
33 | console.error('Failed detecting trailingSlash', err)
34 | }
35 | }
36 | return (
37 |
42 | )
43 | }
44 |
45 | export type {VisualEditingProps} from 'next-sanity/visual-editing/client-component'
46 |
--------------------------------------------------------------------------------
/packages/next-sanity/src/visual-editing/server-actions/index.ts:
--------------------------------------------------------------------------------
1 | 'use server'
2 | import {revalidatePath} from 'next/cache.js'
3 | import {draftMode} from 'next/headers.js'
4 |
5 | export async function revalidateRootLayout(): Promise {
6 | if (!(await draftMode()).isEnabled) {
7 | // eslint-disable-next-line no-console
8 | console.warn('Skipped revalidatePath request because draft mode is not enabled')
9 | return
10 | }
11 | await revalidatePath('/', 'layout')
12 | }
13 |
--------------------------------------------------------------------------------
/packages/next-sanity/src/webhook/index.ts:
--------------------------------------------------------------------------------
1 | import type {SanityDocument} from '@sanity/types'
2 | import {isValidSignature, SIGNATURE_HEADER_NAME} from '@sanity/webhook'
3 | import type {NextRequest} from 'next/server'
4 |
5 | /** @public */
6 | export type ParsedBody = {
7 | /**
8 | * If a secret is given then it returns a boolean. If no secret is provided then no validation is done on the signature, and it'll return `null`
9 | */
10 | isValidSignature: boolean | null
11 | body: T | null
12 | }
13 |
14 | /**
15 | * Handles parsing the body JSON, and validating its signature. Also waits for Content Lake eventual consistency so you can run your queries
16 | * without worrying about getting stale data.
17 | * @public
18 | */
19 | export async function parseBody(
20 | req: NextRequest,
21 | secret?: string,
22 | waitForContentLakeEventualConsistency = true,
23 | ): Promise> {
24 | const signature = req.headers.get(SIGNATURE_HEADER_NAME)
25 | if (!signature) {
26 | console.error('Missing signature header')
27 | return {body: null, isValidSignature: null}
28 | }
29 |
30 | const body = await req.text()
31 | const validSignature = secret ? await isValidSignature(body, signature, secret.trim()) : null
32 |
33 | if (validSignature !== false && waitForContentLakeEventualConsistency) {
34 | await new Promise((resolve) => setTimeout(resolve, 3000))
35 | }
36 |
37 | return {
38 | body: body.trim() ? JSON.parse(body) : null,
39 | isValidSignature: validSignature,
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/packages/next-sanity/test/imageLoader.test.ts:
--------------------------------------------------------------------------------
1 | import {expect, test} from 'vitest'
2 |
3 | import {imageLoader} from '../src/image/imageLoader'
4 |
5 | test('adds basic width and format parameters', () => {
6 | const result = imageLoader({
7 | src: 'https://cdn.sanity.io/images/project/dataset/image.jpg',
8 | width: 800,
9 | quality: undefined,
10 | })
11 |
12 | expect(result).toBe(
13 | 'https://cdn.sanity.io/images/project/dataset/image.jpg?auto=format&fit=max&w=800',
14 | )
15 | })
16 |
17 | test('preserves existing URL parameters', () => {
18 | const result = imageLoader({
19 | src: 'https://cdn.sanity.io/images/project/dataset/image.jpg?blur=50',
20 | width: 800,
21 | quality: undefined,
22 | })
23 |
24 | expect(result).toBe(
25 | 'https://cdn.sanity.io/images/project/dataset/image.jpg?blur=50&auto=format&fit=max&w=800',
26 | )
27 | })
28 |
29 | test('adds quality parameter when specified', () => {
30 | const result = imageLoader({
31 | src: 'https://cdn.sanity.io/images/project/dataset/image.jpg',
32 | width: 800,
33 | quality: 75,
34 | })
35 |
36 | expect(result).toBe(
37 | 'https://cdn.sanity.io/images/project/dataset/image.jpg?auto=format&fit=max&w=800&q=75',
38 | )
39 | })
40 |
41 | test('uses min fit when height parameter exists', () => {
42 | const result = imageLoader({
43 | src: 'https://cdn.sanity.io/images/project/dataset/image.jpg?h=600',
44 | width: 800,
45 | quality: undefined,
46 | })
47 |
48 | expect(result).toBe(
49 | 'https://cdn.sanity.io/images/project/dataset/image.jpg?h=600&auto=format&fit=min&w=800',
50 | )
51 | })
52 |
53 | test('calculates proportional height when both width and height exist', () => {
54 | const result = imageLoader({
55 | src: 'https://cdn.sanity.io/images/project/dataset/image.jpg?w=1000&h=500',
56 | width: 800,
57 | quality: undefined,
58 | })
59 |
60 | expect(result).toBe(
61 | 'https://cdn.sanity.io/images/project/dataset/image.jpg?w=800&h=400&auto=format&fit=min',
62 | )
63 | })
64 |
65 | test('respects existing fit parameter', () => {
66 | const result = imageLoader({
67 | src: 'https://cdn.sanity.io/images/project/dataset/image.jpg?fit=crop',
68 | width: 800,
69 | quality: undefined,
70 | })
71 |
72 | expect(result).toBe(
73 | 'https://cdn.sanity.io/images/project/dataset/image.jpg?fit=crop&auto=format&w=800',
74 | )
75 | })
76 |
77 | test('handles URLs with hash fragments', () => {
78 | const result = imageLoader({
79 | src: 'https://cdn.sanity.io/images/project/dataset/image.jpg#fragment',
80 | width: 800,
81 | quality: undefined,
82 | })
83 |
84 | expect(result).toBe(
85 | 'https://cdn.sanity.io/images/project/dataset/image.jpg?auto=format&fit=max&w=800#fragment',
86 | )
87 | })
88 |
89 | test('handles complex URLs with multiple parameters', () => {
90 | const result = imageLoader({
91 | src: 'https://cdn.sanity.io/images/project/dataset/image.jpg?blur=50&sat=-100&w=1000&h=500',
92 | width: 800,
93 | quality: 90,
94 | })
95 |
96 | expect(result).toBe(
97 | 'https://cdn.sanity.io/images/project/dataset/image.jpg?blur=50&sat=-100&w=800&h=400&auto=format&fit=min&q=90',
98 | )
99 | })
100 |
--------------------------------------------------------------------------------
/packages/next-sanity/test/verifyHistoryVersion.test.ts:
--------------------------------------------------------------------------------
1 | import sanityJson from 'sanity/package.json' assert {type: 'json'}
2 | import {expect, test} from 'vitest'
3 |
4 | import nextSanityJson from '../package.json' assert {type: 'json'}
5 |
6 | /**
7 | * It's important that the `history` package used by `sanity` to underpin its router is the same we use to implement hash history support
8 | */
9 |
10 | test('verify that next-sanity requires the same history version as sanity', () => {
11 | expect(nextSanityJson.dependencies.history).toBe(sanityJson.dependencies.history)
12 | })
13 |
--------------------------------------------------------------------------------
/packages/next-sanity/tsconfig.base.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@sanity/pkg-utils/tsconfig/strictest.json",
3 | "compilerOptions": {
4 | "rootDir": ".",
5 | "outDir": "dist",
6 | "paths": {
7 | "next-sanity/draft-mode": ["./src/draft-mode/index.ts"],
8 | "next-sanity/image": ["./src/image/index.ts"],
9 | "next-sanity/preview/live-query": ["./src/preview/LiveQuery/index.ts"],
10 | "next-sanity/preview": ["./src/preview/index.ts"],
11 | "next-sanity/studio/client-component": ["./src/studio/client-component/index.ts"],
12 | "next-sanity/studio": ["./src/studio/index.ts"],
13 | "next-sanity/visual-editing/client-component": [
14 | "./src/visual-editing/client-component/index.ts"
15 | ],
16 | "next-sanity/visual-editing/server-actions": ["./src/visual-editing/server-actions/index.ts"],
17 | "next-sanity/webhook": ["./src/webhook/index.ts"],
18 | "next-sanity": ["./src/index"]
19 | }
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/packages/next-sanity/tsconfig.build.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.base",
3 | "include": ["src/**/*.ts", "src/**/*.tsx"],
4 | "exclude": ["dist", "node_modules", "./src/**/*.test.ts", "./src/**/*.test.tsx"]
5 | }
6 |
--------------------------------------------------------------------------------
/packages/next-sanity/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.base",
3 | "include": ["**/*.ts", "**/*.tsx"],
4 | "exclude": ["dist", "node_modules"],
5 | "compilerOptions": {
6 | "plugins": [
7 | {
8 | "name": "next"
9 | }
10 | ]
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/packages/next-sanity/turbo.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://turbo.build/schema.json",
3 | "extends": ["//"],
4 | "tasks": {
5 | "build": {
6 | "outputs": ["dist/**"]
7 | },
8 | "test": {
9 | "env": ["GITHUB_ACTIONS"]
10 | }
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/packages/next-sanity/vite.config.ts:
--------------------------------------------------------------------------------
1 | import tsconfigPaths from 'vite-tsconfig-paths'
2 | import {configDefaults, defineConfig} from 'vitest/config'
3 |
4 | export default defineConfig({
5 | plugins: [tsconfigPaths()],
6 | test: {
7 | // don't use vitest to run Bun and Deno tests
8 | exclude: [...configDefaults.exclude, 'test.cjs', 'test.mjs'],
9 | },
10 | })
11 |
--------------------------------------------------------------------------------
/packages/sanity-config/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@repo/sanity-config",
3 | "version": "0.0.0",
4 | "private": true,
5 | "exports": {
6 | ".": "./src/index.tsx"
7 | },
8 | "dependencies": {
9 | "@sanity/assist": "4.2.0"
10 | },
11 | "devDependencies": {
12 | "@repo/typescript-config": "workspace:*",
13 | "@sanity/vision": "^3.88.2",
14 | "sanity": "^3.88.2"
15 | },
16 | "peerDependencies": {
17 | "@sanity/vision": "$@sanity/vision",
18 | "sanity": "$sanity"
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/packages/sanity-config/src/index.tsx:
--------------------------------------------------------------------------------
1 | import {definePlugin} from 'sanity'
2 | import {assist} from '@sanity/assist'
3 | import {visionTool} from '@sanity/vision'
4 | import {structureTool} from 'sanity/structure'
5 |
6 | import {schemaTypes} from './schemas'
7 |
8 | export default definePlugin({
9 | name: '@repo/sanity-config',
10 | plugins: [assist(), structureTool(), visionTool()],
11 | schema: {
12 | types: schemaTypes,
13 | },
14 | })
15 |
--------------------------------------------------------------------------------
/packages/sanity-config/src/schemas/author.ts:
--------------------------------------------------------------------------------
1 | import {defineType} from 'sanity'
2 |
3 | export default defineType({
4 | name: 'author',
5 | title: 'Author',
6 | type: 'document',
7 | fields: [
8 | {
9 | name: 'name',
10 | title: 'Name',
11 | type: 'string',
12 | },
13 | {
14 | name: 'slug',
15 | title: 'Slug',
16 | type: 'slug',
17 | options: {
18 | source: 'name',
19 | maxLength: 96,
20 | },
21 | },
22 | {
23 | name: 'image',
24 | title: 'Image',
25 | type: 'image',
26 | fields: [
27 | {
28 | name: 'alt',
29 | type: 'string',
30 | title: 'Alternative text',
31 | description: 'Important for SEO and accessiblity.',
32 | },
33 | ],
34 | options: {
35 | hotspot: true,
36 | aiAssist: {
37 | imageDescriptionField: 'alt',
38 | },
39 | },
40 | },
41 | {
42 | name: 'bio',
43 | title: 'Bio',
44 | type: 'array',
45 | of: [
46 | {
47 | title: 'Block',
48 | type: 'block',
49 | styles: [{title: 'Normal', value: 'normal'}],
50 | lists: [],
51 | },
52 | ],
53 | },
54 | ],
55 | preview: {
56 | select: {
57 | title: 'name',
58 | media: 'image',
59 | },
60 | },
61 | })
62 |
--------------------------------------------------------------------------------
/packages/sanity-config/src/schemas/blockContent.ts:
--------------------------------------------------------------------------------
1 | import {defineType} from 'sanity'
2 | /**
3 | * This is the schema definition for the rich text fields used for
4 | * for this blog studio. When you import it in schemas.js it can be
5 | * reused in other parts of the studio with:
6 | * {
7 | * name: 'someName',
8 | * title: 'Some title',
9 | * type: 'blockContent'
10 | * }
11 | */
12 | export default defineType({
13 | title: 'Block Content',
14 | name: 'blockContent',
15 | type: 'array',
16 | of: [
17 | {
18 | title: 'Block',
19 | type: 'block',
20 | // Styles let you set what your user can mark up blocks with. These
21 | // correspond with HTML tags, but you can set any title or value
22 | // you want and decide how you want to deal with it where you want to
23 | // use your content.
24 | styles: [
25 | {title: 'Normal', value: 'normal'},
26 | {title: 'H1', value: 'h1'},
27 | {title: 'H2', value: 'h2'},
28 | {title: 'H3', value: 'h3'},
29 | {title: 'H4', value: 'h4'},
30 | {title: 'Quote', value: 'blockquote'},
31 | ],
32 | lists: [{title: 'Bullet', value: 'bullet'}],
33 | // Marks let you mark up inline text in the block editor.
34 | marks: {
35 | // Decorators usually describe a single property – e.g. a typographic
36 | // preference or highlighting by editors.
37 | decorators: [
38 | {title: 'Strong', value: 'strong'},
39 | {title: 'Emphasis', value: 'em'},
40 | ],
41 | // Annotations can be any object structure – e.g. a link or a footnote.
42 | annotations: [
43 | {
44 | title: 'URL',
45 | name: 'link',
46 | type: 'object',
47 | fields: [
48 | {
49 | title: 'URL',
50 | name: 'href',
51 | type: 'url',
52 | },
53 | ],
54 | },
55 | ],
56 | },
57 | },
58 | // You can add additional types here. Note that you can't use
59 | // primitive types such as 'string' and 'number' in the same array
60 | // as a block type.
61 | {
62 | type: 'image',
63 | options: {hotspot: true},
64 | },
65 | ],
66 | })
67 |
--------------------------------------------------------------------------------
/packages/sanity-config/src/schemas/category.ts:
--------------------------------------------------------------------------------
1 | import {defineType} from 'sanity'
2 |
3 | export default defineType({
4 | name: 'category',
5 | title: 'Category',
6 | type: 'document',
7 | fields: [
8 | {
9 | name: 'title',
10 | title: 'Title',
11 | type: 'string',
12 | },
13 | {
14 | name: 'description',
15 | title: 'Description',
16 | type: 'text',
17 | },
18 | ],
19 | })
20 |
--------------------------------------------------------------------------------
/packages/sanity-config/src/schemas/index.ts:
--------------------------------------------------------------------------------
1 | import author from './author'
2 | import blockContent from './blockContent'
3 | import category from './category'
4 | import post from './post'
5 |
6 | export const schemaTypes = [post, author, category, blockContent]
7 |
--------------------------------------------------------------------------------
/packages/sanity-config/src/schemas/post.ts:
--------------------------------------------------------------------------------
1 | import {defineType} from 'sanity'
2 |
3 | import authorType from './author'
4 | import blockContentType from './blockContent'
5 | import categoryType from './category'
6 |
7 | export default defineType({
8 | name: 'post',
9 | title: 'Post',
10 | type: 'document',
11 | fields: [
12 | {
13 | name: 'title',
14 | title: 'Title',
15 | type: 'string',
16 | },
17 | {
18 | name: 'slug',
19 | title: 'Slug',
20 | type: 'slug',
21 | options: {
22 | source: 'title',
23 | maxLength: 96,
24 | },
25 | },
26 | {
27 | name: 'author',
28 | title: 'Author',
29 | type: 'reference',
30 | to: {type: authorType.name},
31 | },
32 | {
33 | name: 'mainImage',
34 | title: 'Main image',
35 | type: 'image',
36 | fields: [
37 | {
38 | name: 'alt',
39 | type: 'string',
40 | title: 'Alternative text',
41 | description: 'Important for SEO and accessiblity.',
42 | },
43 | ],
44 | options: {
45 | hotspot: true,
46 | aiAssist: {
47 | imageDescriptionField: 'alt',
48 | },
49 | },
50 | },
51 | {
52 | name: 'categories',
53 | title: 'Categories',
54 | type: 'array',
55 | of: [{type: 'reference', to: {type: categoryType.name}}],
56 | },
57 | {
58 | name: 'publishedAt',
59 | title: 'Published at',
60 | type: 'datetime',
61 | },
62 | {
63 | name: 'body',
64 | title: 'Body',
65 | type: blockContentType.name,
66 | },
67 | ],
68 |
69 | preview: {
70 | select: {
71 | title: 'title',
72 | author: 'author.name',
73 | media: 'mainImage',
74 | },
75 | prepare(selection: any) {
76 | const {author} = selection
77 | return {...selection, subtitle: author && `by ${author}`}
78 | },
79 | },
80 | })
81 |
--------------------------------------------------------------------------------
/packages/sanity-config/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@repo/typescript-config/base.json",
3 | "include": ["src/**/*.ts", "src/**/*.tsx"],
4 | "exclude": ["node_modules"]
5 | }
6 |
--------------------------------------------------------------------------------
/packages/typescript-config/base.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "esnext",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": false,
8 | "strictNullChecks": true,
9 | "forceConsistentCasingInFileNames": true,
10 | "noEmit": true,
11 | "esModuleInterop": true,
12 | "module": "preserve",
13 | "moduleResolution": "bundler",
14 | "resolveJsonModule": true,
15 | "isolatedModules": true,
16 | "jsx": "preserve",
17 | "incremental": false
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/packages/typescript-config/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@repo/typescript-config",
3 | "version": "0.0.0",
4 | "private": true,
5 | "exports": {
6 | "./base.json": "./base.json"
7 | },
8 | "dependencies": {
9 | "typescript": "5.8.3"
10 | },
11 | "peerDependencies": {
12 | "typescript": "$typescript"
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/pnpm-workspace.yaml:
--------------------------------------------------------------------------------
1 | packages:
2 | - 'apps/*'
3 | - 'packages/*'
4 |
--------------------------------------------------------------------------------
/prettier.config.mjs:
--------------------------------------------------------------------------------
1 | import defaults from '@sanity/prettier-config'
2 |
3 | export default {
4 | ...defaults,
5 | plugins: [...defaults.plugins, 'prettier-plugin-tailwindcss'],
6 | }
7 |
--------------------------------------------------------------------------------
/release-please-config.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json",
3 | "packages": {
4 | "packages/next-sanity": {}
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/turbo.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://turbo.build/schema.json",
3 | "globalDependencies": [".env", ".env.local"],
4 | "tasks": {
5 | "build": {
6 | "dependsOn": ["^build"]
7 | },
8 | "test": {
9 | "dependsOn": ["^build"],
10 | "cache": false,
11 | "persistent": true
12 | },
13 | "lint": {},
14 | "type-check": {},
15 | "dev": {
16 | "cache": false,
17 | "persistent": true
18 | },
19 | "start": {
20 | "dependsOn": ["build"],
21 | "cache": false,
22 | "persistent": true
23 | }
24 | }
25 | }
26 |
--------------------------------------------------------------------------------