Skip to content

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 more
  • 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.node to >=22.12, which Vercel reads. On Node 20 the app still builds, but the sanity CLI 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.

Database
NeonSupabase
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.

    Placeholder
    abc12xyz
  • NEXT_PUBLIC_SANITY_DATASETRequiredPublic, reaches the browser

    Dataset name. Use production for the live site and a second dataset for staging; they are separate content databases in the same project.

    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.

    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_TOKEN is read in exactly one place: sanity/lib/client.ts, where it configures draftClient. Nothing else reads process.env for it.
  • draftClient and sanityFetch are 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_TOKEN to "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
  • sanityFetch checks draftMode() on every read. In draft mode it uses the token client, the drafts perspective 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, not defineLive from next-sanity/live. defineLive, <SanityLive /> and defineEnableDraftMode (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-url 2, @portabletext/react 8.
  • createClient and defineQuery from next-sanity. NextStudio, metadata and viewport from next-sanity/studio. structureTool from sanity/structure, visionTool from @sanity/vision, defineCliConfig from sanity/cli.
  • createImageUrlBuilder and SanityImageSource from @sanity/image-url. The default export is deprecated and @sanity/image-url/lib/types/types no longer exists.
  • sanity.config.ts keeps "use client". Without it the Studio lands in the server graph and next build fails to compile /studio.
  • next-sanity 13 ships its own @portabletext/react 7 and @sanity/client 7. Sanity 6 uses 8 of both, so both majors install side by side. That is expected, not a broken lockfile. Import PortableText and PortableTextComponents from @portabletext/react (8), never from the next-sanity re-export, so both come from one copy.
  • Do not add @sanity/client as a dependency or import from it. Take the client from createClient and its types from next-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.tsx uses token utilities: text-ink, text-body, text-muted, bg-surface-card, bg-surface-strong, border-hairline, rounded-*, the text-title-* and text-body-* scale, p-* and gap-* spacing. No palette classes, no hex, no inline colours.
  • Every block type, style, mark and annotation in sanity/schemas/block-content.ts has 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 render asset->url directly: that is the original upload, at full size.
  • alt is 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 revalidate on 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/ with defineType / defineField. The Studio at /studio renders 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.ts selects 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 sanity with a mutation) before you delete anything that has content in it.
  • Slugs are the public URL. Treat a published slug.current as immutable and add a redirect rather than editing it.
  • Validation belongs in the schema, not in the renderer: rule.required(), rule.max(160), required alt text 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 in defineQuery, and imported by name.
  • One file means a schema rename breaks in one place, bun run sanity:typegen can 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 sanityFetch in sanity/lib/fetch.ts. It picks the right client for draft mode and attaches the cache tags the webhook invalidates. A raw client.fetch in a page is a read nobody can revalidate. The one legitimate exception is generateStaticParams, which runs at build time where draftMode() 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 body in 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

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.

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.