Blog
Next.js boilerplate with Sanity blog
A real editorial CMS your writers can use, with the schema still living in your repo.
A hosted, structured blog on Sanity. The Studio mounts inside this app at /studio, and every GROQ query lives in one typed file. Draft previews use Next.js draft mode. Webhook revalidation puts a publish live in seconds, with no deploy.
What Sanity blog adds to the agent layer: 2 rules · 2 skills · 5 solution docs
Maintained by @raviMITNext.js on Vercel
From the manifest
Should you pick Sanity blog?
Pick it if
Teams where the people writing are not the people deploying: marketing, content and design working next to engineering. Best when content is structured (authors, categories, references between documents), not a pile of long markdown files. Also when you need previews, roles and a real-time editor without building one.
Watch out for
- Content lives in someone else's database. Losing access to the project, or a pricing change, is a business risk a folder of markdown does not have. Export regularly. The dataset export is one CLI command.
- GROQ is a new query language for the team. Projections make over-fetching easy to avoid, but nobody arrives knowing it, and a bad projection fails silently.
Show 4 moreShow fewer
- Caching becomes your job. Content changes without a deploy, so you need an answer to "why is this page stale?". The tags and webhook route in this battery are that answer.
- The Studio is a dependency of your app. Mounting it at /studio adds Sanity and styled-components to your install. The route is static and code-split, but the install is bigger.
- Sanity Studio v6 needs Node.js 22.12 or later, and next-sanity 13 needs Next.js 16. The generated package.json sets
engines.nodeto>=22.12, which Vercel reads. On Node 20 the app still builds, but thesanityCLI refuses to run. Set Node 22 or 24 on any other host or build image. - Structured content costs more upfront and pays off later. Modelling authors and categories as references takes an afternoon and saves you the day you want an author page.
What it costs
Free plan: up to 20 seats, 2 public datasets and 10k documents. Growth is $15 per seat per month: up to 50 seats, private datasets and 25k documents. Past its included quotas, Growth bills pay-as-you-go. Enterprise is custom.
Prices change. Check with Sanity blog before you commit.
registry/tested.yaml
Tested with Sanity blog
Each pair was installed, typechecked, linted, built and booted together.
- Admin panel
- Admin panel
- Error tracking
- Sentry
- Customer support
- Crisp
What it adds
What Sanity blog adds to the repo
Read straight from the blog-sanity manifest, so it is exactly what lands in your repo.
Environment variables
NEXT_PUBLIC_SANITY_PROJECT_IDRequiredPublic, reaches the browser
The project id from sanity.io/manage. Public by design: it appears in every image URL and in the Studio bundle.
- Where to get it
- https://www.sanity.io/manage
- Placeholder
- abc12xyz
NEXT_PUBLIC_SANITY_DATASETRequiredPublic, reaches the browser
Dataset name. Use
productionfor the live site and a second dataset for staging; they are separate content databases in the same project.- Where to get it
- https://www.sanity.io/docs/datasets
- Placeholder
- production
SANITY_API_READ_TOKENRequired
A Viewer token, used only in draft mode to read unpublished documents. Server-side only. Never create a token with Editor or Deploy rights for this: a write token in a frontend is a stranger editing your content.
- Where to get it
- https://www.sanity.io/manage -> API -> Tokens
- Placeholder
- sk_replace_me
SANITY_REVALIDATE_SECRETRequired
A random string you invent. It authenticates two things: the preview link the Studio opens, and the publish webhook Sanity calls. Rotate it by changing it in both places.
- Placeholder
- replace-with-32-random-characters
Dependencies
- @portabletext/react^8
- @sanity/image-url^2.1.1
- @sanity/vision^6.16.0
- next-sanity^13.3.4
- sanity^6.16.0
- styled-components^6.1.15
- @types/node^22dev
Scripts
- bun run sanity
bunx sanity
- bun run sanity:typegen
bunx sanity schemas extract && bunx sanity typegen generate
Files it writes
21 files, at these exact paths.
sanity/10 files
lib/5 files
- client.ts
- fetch.ts
- image.ts
- queries.ts
- types.ts
schemas/4 files
- author.ts
- block-content.ts
- index.ts
- post.ts
- env.ts
src/9 files
app/6 files
(sanity)/2 files
blog/2 files
[slug]/1 file
- page.tsx
- page.tsx
api/3 files
draft-mode/2 files
disable/1 file
- route.ts
enable/1 file
- route.ts
sanity/1 file
revalidate/1 file
- route.ts
studio/1 file
[[...tool]]/1 file
- page.tsx
components/3 files
sanity/3 files
- draft-banner.tsx
- portable-text.tsx
- sanity-image.tsx
- sanity.cli.ts
- sanity.config.ts
Stack slots it fills
The stack declares these injection points; this battery supplies the fragment, so the provider tree, the env check and the schema stay one file each instead of many.
- @slot bare-route-groups
- @slot env-required
- @slot legal-processors
- @slot nav-links
- @slot verify-checks
The differentiator
What Sanity blog teaches your agent
Other starter kits stop at the package. This is the part an agent reads: where it may work, what it must never do there, and the problems someone already solved.
Rules (2)
Loaded when the agent opens a matching file.
Read tokens stay on the server, Portable Text uses design tokens
Loads onsrc/app/(sanity)/**src/app/studio/**src/app/api/draft-mode/**src/app/api/sanity/**src/components/sanity/**sanity/lib/**.claude/rules/sanity-runtime.md
Never let a Sanity API token reach the browser
SANITY_API_READ_TOKENis read in exactly one place:sanity/lib/client.ts, where it configuresdraftClient. Nothing else readsprocess.envfor it.draftClientandsanityFetchare server-only. Do not import them from a file that carries"use client", or from anything such a file imports. If a client component needs content, fetch it in the server component above and pass it down as props.- Never rename the variable to
NEXT_PUBLIC_SANITY_API_READ_TOKENto "make it work" in a client component. That is not a fix, it is a publication. - The token this repo uses is a Viewer token. If a task seems to need write access from the app, stop: content is written in the Studio by authenticated editors. An Editor or Deploy token in a web app is a stranger with edit rights the moment anything leaks it.
- The publish webhook and the preview link are authenticated with
SANITY_REVALIDATE_SECRET, compared in constant time. Do not add an unauthenticated revalidate route "for testing".
Draft mode is for editors, and it says so
sanityFetchchecksdraftMode()on every read. In draft mode it uses the token client, thedraftsperspective and no caching; otherwise it uses the anonymous CDN client with tags.- Any page that can render draft content shows
<DraftBanner />. An editor who forgot they enabled preview three days ago will otherwise report bugs about content nobody else can see. - The enable route validates the secret and resolves the slug against the dataset before redirecting. Redirecting to a path taken from the query string is an open redirect.
- Leaving draft mode is a POST. A GET that mutates state gets fired by link prefetchers, mail scanners and chat unfurlers.
- Reads go through this repo's
sanityFetch, notdefineLivefromnext-sanity/live.defineLive,<SanityLive />anddefineEnableDraftMode(next-sanity/draft-mode) are one set built for the Presentation tool. Adding one of them alone gives you two read paths and two draft-mode routes.
Import from the current majors
- next-sanity 13, sanity 6,
@sanity/image-url2,@portabletext/react8. createClientanddefineQueryfromnext-sanity.NextStudio,metadataandviewportfromnext-sanity/studio.structureToolfromsanity/structure,visionToolfrom@sanity/vision,defineCliConfigfromsanity/cli.createImageUrlBuilderandSanityImageSourcefrom@sanity/image-url. The default export is deprecated and@sanity/image-url/lib/types/typesno longer exists.sanity.config.tskeeps"use client". Without it the Studio lands in the server graph andnext buildfails to compile/studio.- next-sanity 13 ships its own
@portabletext/react7 and@sanity/client7. Sanity 6 uses 8 of both, so both majors install side by side. That is expected, not a broken lockfile. ImportPortableTextandPortableTextComponentsfrom@portabletext/react(8), never from thenext-sanityre-export, so both come from one copy. - Do not add
@sanity/clientas a dependency or import from it. Take the client fromcreateClientand its types fromnext-sanity, which re-exports them from the copy it actually uses.
Portable Text renders with design tokens only
- Every mapping in
src/components/sanity/portable-text.tsxuses token utilities:text-ink,text-body,text-muted,bg-surface-card,bg-surface-strong,border-hairline,rounded-*, thetext-title-*andtext-body-*scale,p-*andgap-*spacing. No palette classes, no hex, no inline colours. - Every block type, style, mark and annotation in
sanity/schemas/block-content.tshas a mapping. An unmapped type renders as an unstyled paragraph and nobody notices until a reader does. - Images go through
<SanityImage>, which requests an exact width from the image CDN and uses the LQIP from the projection as a blur placeholder. Never renderasset->urldirectly: that is the original upload, at full size. altis required in the schema; pass it through rather than defaulting it to the title.
Caching is explicit
- Every cached read carries tags: the collection (
post) and the document (post:<slug>). The webhook invalidates exactly those. revalidateTag(tag, "max"): the two-argument form. The single-argument form is deprecated and behaves like{ expire: 0 }, which makes the next visitor wait for a fresh render instead of serving them the stale page.- Do not sprinkle
export const revalidateon pages as well. Time-based revalidation on top of webhook invalidation gives you rebuilds you did not ask for and a cache you cannot reason about.
The schema is code, and every GROQ query lives in one file
Loads onsanity/**sanity.config.ts.claude/rules/sanity-schema.md
Schema changes are commits, not Studio clicks
- Document and object types are defined in
sanity/schemas/withdefineType/defineField. The Studio at/studiorenders that schema; it has no way to change it, and nothing you do in the browser alters what is committed here. - Every schema change goes through the normal review path: branch, diff, pull request. "I added a field in the Studio" is not a thing that can happen, and if someone believes it did, they edited a file and did not commit it.
- Adding a field to a document type is only step one. The field is invisible
until a projection in
sanity/lib/queries.tsselects it and a renderer displays it. Ship all three together or ship none of them. - Renaming or deleting a field does not migrate existing documents. Sanity is
schemaless underneath: old documents keep the old key until something
rewrites them. Plan a migration script (
bun run sanitywith a mutation) before you delete anything that has content in it. - Slugs are the public URL. Treat a published
slug.currentas immutable and add a redirect rather than editing it. - Validation belongs in the schema, not in the renderer:
rule.required(),rule.max(160), requiredalttext on every image. A rule in the schema is enforced for every editor on every document; a check in a component is enforced for the one page you remembered.
Every query is in sanity/lib/queries.ts
- No
client.fetch("*[...]")inside a page, a component or a route handler. Queries are exported from one file, wrapped indefineQuery, and imported by name. - One file means a schema rename breaks in one place,
bun run sanity:typegencan generate result types for exactly this list, and over-fetching is visible in review because the projections sit next to each other. - Every read goes through
sanityFetchinsanity/lib/fetch.ts. It picks the right client for draft mode and attaches the cache tags the webhook invalidates. A rawclient.fetchin a page is a read nobody can revalidate. The one legitimate exception isgenerateStaticParams, which runs at build time wheredraftMode()cannot be called.
Projections, not *
- Always project explicitly:
{ _id, title, "slug": slug.current }. A bare*[_type == "post"]returns every field of every matching document, including bodies and drafts of fields you have forgotten about, and it grows silently as the schema grows. - Never select
bodyin a list query. Portable text is the largest field on a post, and an index page needs none of it. - Dereference in the query, not in a loop:
"author": author->{name}and"categories": categories[]->title. Fetching a list and then fetching each reference is an N+1 with a network hop per row. - Bound every list with a slice (
[0...$limit]) and order it explicitly. Without| order(...)the result order is not guaranteed to be stable. - Filter drafts out of public queries with
!(_id in path("drafts.**")). The published perspective usually handles it, but a query used with a token does not.
Skills (2)
Invoked by name.
- /add-sanity-type
Add or extend a Sanity document type end to end (schema, GROQ projection, result type, renderer and cache tags) so nothing renders blank.
.claude/skills/add-sanity-type/SKILL.md
- /preview-draft
Set up or debug Sanity draft previews: the enable route, the secret, the banner, and why an editor is still seeing published content.
.claude/skills/preview-draft/SKILL.md
Solution docs (5)
Written before you hit the problem. Each one ships in the repo at docs/solutions/ and is published here as a cookbook page.
- Draft mode is on, and the page still shows published contentDraft mode only bypasses caches for reads that know about it. One direct client.fetch, or a token-less client, and the editor sees yesterday's copy.docs/solutions/blog-sanity/draft-mode-and-the-app-router-cache.md
- GROQ projections that stop your blog index fetching every article body*[_type == "post"] returns whole documents, drafts of fields included. Project explicitly, dereference in the query, and slice every list.docs/solutions/blog-sanity/groq-projections-that-avoid-over-fetching.md
- Sanity images without the 4MB original and the layout jumpasset->url hands the browser the raw upload. Build a transform URL with a width, and use the LQIP Sanity already generated as the blur placeholder.docs/solutions/blog-sanity/image-urls-and-lqip.md
- Modelling references in Sanity without an N+1 on every pageA reference is a pointer, not an embed. Dereference inside the projection, model the direction that reads well, and never resolve in a loop.docs/solutions/blog-sanity/modelling-references-without-n-plus-one.md
- Webhook revalidation or a revalidate timer: pick oneA 60-second timer rebuilds pages nobody asked for and still makes editors wait. Tag every read, invalidate on publish, and stop guessing.docs/solutions/blog-sanity/webhook-revalidation-vs-time-based.md
How it fits
What Sanity blog needs, and what it goes well with
The resolver enforces this before it generates anything, and names every addition it makes.
Requires
Nothing. Sanity blog stands on its own.
Pairs well with
Nothing extra. Add any tested battery alongside Sanity blog.
Cannot be combined with
Compared with the alternatives
Build a repo with Sanity blog
Free and MIT. The builder opens with Sanity blog picked. You download the zip right away, and we email you the link too.
Presets
Presets that already include Sanity blog
A tested selection with its own file tree and its own generated CLAUDE.md. Start from one instead of from blank.