Skip to content
Mrozowski Digital

NotesEngineering19 min read

Next.js 16 + Sanity in 2026: the production setup I would use again

A production-tested setup designed around developers, editors and visitors — not just the happy path.

Piotr MrozowskiFounder, Mrozowski International

I have worked with Sanity as a headless CMS for years, and it has become my go-to content platform. It offers an excellent developer experience, gives editors a clean workflow once the implementation is in place, and remains remarkably generous in its pricing. More importantly, it gives you the flexibility a proper headless CMS should.

After more than ten production sites and dozens of hobby and side projects, this is how I now structure the repository, Studio, previews, types, cache invalidation, deployment and DNS. The happy path for a Next.js and Sanity demo takes less than an hour.

The production path is a bit of a different story.

The hard parts are rarely the first GROQ query or rendering an image. They are the decisions around it: where the Studio should live, which token is allowed to reach a browser, how draft previews refresh, whether a publish reaches a site with no visitors, what Next.js has actually cached, and why one forgotten DNS record can make a site work only some of the time.

I learned those details while shipping 10+ production sites (and dozens of hobby / side projects) on this stack. Eventually you run into the same issues repeatedly. There are ways around them, but sometimes even following the docs to the letter can be a trap.

A production architecture should not be judged by how many platform features it uses. It should be judged by how reliably it solves the problem, how easy it is to operate, and how many unpleasant surprises it creates for the people around it.

This is the setup I would (and will) use again.

Why this stack works for more than developers

Next.js and Sanity are often discussed as a developer-friendly combination. They are, but that is only a third of the story.

Developers get one coherent application

The public site, embedded Sanity Studio, preview routes, cache invalidation endpoints and generated content types can all live in one repository and one web deployment.

Next.js handles the application, rendering, caching, metadata and deployment model. Sanity handles structured content, editorial workflows and its content APIs. next-sanity connects the two without forcing the CMS into a separate frontend architecture.

The result is a clean boundary: editors own content, developers own presentation and behavior, and both work against the same typed model.

Editors work on the page they are changing

An embedded Studio at /studio is already more convenient than sending editors to a disconnected admin site. Presentation and Visual Editing go further: an editor can open a preview, click visible content, jump to the field that produced it and see draft changes without publishing first.

That removes a surprising amount of friction. Editors no longer need to remember which abstract field controls which part of a page, and developers receive fewer requests to explain the CMS.

Press enter or click to view image in full size

Visitors get fast pages without stale content

Most brochure and content sites do not need to render every request dynamically. They need fast static output, global delivery and a reliable way to replace cached content when an editor publishes.

Next.js and Vercel are very good at the first two. Sanity’s live APIs and webhooks can handle the third. The important part is wiring them deliberately, because the default development experience can hide gaps that only appear after deployment.

The exact scope and versions

As of 30 August 2026, the current package versions relevant to this setup are:

  • Next.js 16.3.3
  • React 19.2
  • next-sanity 13.3.3
  • Sanity Studio 6.11.0

Sanity 6 requires Node >= 22.12. The latest sites behind this article were built on Sanity 5.31.2, the maintenance-v5 line, because their wider toolchain was deliberately pinned to Node 20. One site was also a single Next.js patch behind at 16.3.2.

For a fresh project, I would use Node 22 or newer and Sanity 6. I would only pin Sanity 5 if a real dependency required Node 20, and I would make that decision explicit rather than arriving there by accident.

This article also assumes the default Next.js caching model with cacheComponents disabled. If you enable Cache Components, next-sanity requires explicit perspective and stega settings per call through defineLive({ strict: true }), and the page structure is different enough to deserve its own guide.

0. Decide four things before create-next-app

These are cheap to decide now and expensive to change later.

  1. One repository, one web deployment. The Studio lives inside the Next app at /studio and ships with it. No separate Studio deployment, no sanity deploy, no studioHost — two Studios drift. The one optional piece that deploys separately is the Sync Tag Invalidate Function (§6), which goes to Sanity's infrastructure via Blueprints, not to your host.
  2. Node version vs. Studio major. Current sanity (6.x) requires Node ≥ 22.12; v5 is the maintenance line and runs on Node >=20.19 <22 || >=22.12. On a fresh project take Node 22+ and sanity 6. If something ties you to Node 20 — a host, a monorepo, a team toolchain — pin v5 knowingly. Either way use the project-local CLI (npx --no-install sanity …): sanity@latest will happily be the wrong major for your Node.
  3. Public dataset, two tokens. A public dataset means the site reads published content with no token at all. You will still create exactly two tokens: a Viewer token for runtime (draft previews) and an Editor token that lives only in .env.local for seeding. The editor token never goes near a host.
  4. The canonical host: www or apex. Choose one. It becomes NEXT_PUBLIC_SITE_URL, the Vercel production domain, the redirect direction, and the Sanity CORS origin. Every canonical, og:url, sitemap entry and robots Host derives from that single value, so the code and the host must agree.

1. Scaffold

npx create-next-app@latest my-site --typescript --tailwind --app --src-dir
cd my-site
npm i next-sanity sanity @sanity/vision @sanity/image-url @sanity/client styled-components

styled-components is a peer dependency of Studio. Pin sanity and @sanity/vision to the same exact version.

File layout that keeps Studio isolated

src/app/layout.tsx                  root: <html>/<body>, fonts, global CSS. Nothing else.
src/app/(site)/layout.tsx           public chrome: header, footer, analytics, <SanityLive />, <VisualEditing />
src/app/(site)/page.tsx             pages live in the group
src/app/(site)/opengraph-image.tsx  see §9 — it must live here, not at the root
src/app/studio/[[...tool]]/page.tsx embedded Studio, OUTSIDE the group
src/app/api/draft-mode/enable/route.ts
src/app/api/draft-mode/disable/route.ts
src/app/not-found.tsx               at the root; brings its own header/footer, fetches nothing
src/sanity/env.ts                   env reading + validation, placeholder project id
src/sanity/lib/client.ts            createClient with stega.studioUrl
src/sanity/lib/live.ts              defineLive → sanityFetch, SanityLive
src/sanity/lib/live-action.ts       the updateTag action (§6)
src/sanity/lib/image.ts             image URL builder with an asset guard
src/sanity/lib/queries.ts           defineQuery() GROQ, consumed by TypeGen
src/sanity/schemaTypes/             documents/, objects/, index.ts
src/sanity/structure.ts             desk structure (singletons open directly)
sanity.config.ts                    'use client' at the top
sanity.cli.ts                       used only by schema extract / typegen
scripts/seed.mjs                    plain ESM, runs with node --env-file

Keep in mind: live-content components (<SanityLive />) mounted around the embedded Studio cause refresh loops inside Studio. Put Studio outside the route group that carries the site chrome. Make the separation structural — a route group — not a conditional inside a shared layout.

The Studio page

// src/app/studio/[[...tool]]/page.tsx
import type { Metadata } from "next";
import { NextStudio } from "next-sanity/studio";
import config from "../../../../sanity.config";

export { viewport } from "next-sanity/studio";
export const dynamic = "force-static";
export const metadata: Metadata = {
  title: "Studio",
  robots: { index: false, follow: false, nocache: true },
};

export default function StudioPage() {
  return <NextStudio config={config} />;
}

Disallow /studio and /api/ in robots.txt as well.

2. Environment variables

  • NEXT_PUBLIC_SITE_URLpubliccanonical origin, no trailing slash — the host you chose in §0
  • NEXT_PUBLIC_SANITY_PROJECT_IDpublicfrom sanity.io/manage
  • NEXT_PUBLIC_SANITY_DATASETpublicusually production
  • NEXT_PUBLIC_SANITY_API_VERSIONpublica date, e.g. 2026-03-01
  • SANITY_API_READ_TOKENserverViewer token. Draft previews; also handed to editors' browsers in draft mode (§6) — so read-only, always
  • SANITY_EDITOR_TOKENlocal onlyseeding. Never set it on a host
  • SANITY_REVALIDATE_TAGS_SECRETservershared secret between the Sync Tag Invalidate Function and /api/revalidate-tags (§6)

Ship .env.example with the shape and comments. Gitignore .env* and un-ignore .env.example:

.env*
!.env.example

env.ts: a placeholder so the app builds before Sanity exists

export const projectId = process.env.NEXT_PUBLIC_SANITY_PROJECT_ID || "placeholder";
export const dataset = process.env.NEXT_PUBLIC_SANITY_DATASET || "production";
export const apiVersion = process.env.NEXT_PUBLIC_SANITY_API_VERSION || "2026-03-01";
export const isSanityConfigured = Boolean(process.env.NEXT_PUBLIC_SANITY_PROJECT_ID);

export function assertSanityConfigured(): void {
  if (isSanityConfigured) return;
  if (process.env.NODE_ENV === "production" && process.env.VERCEL_ENV) {
    throw new Error("NEXT_PUBLIC_SANITY_PROJECT_ID is not set.");
  }
  console.warn("[sanity] no project id — content sections will render empty.");
}

Gate every fetch on isSanityConfigured, and never mount <SanityLive /> without a project id — it retries forever and floods the console. A production deployment with no project id should fail the build, not serve an empty site.

Keep in mind: next build loads .env.local. If you create it halfway through a session, the next build already uses it — see §8 for why that matters.

3. The Sanity project (sanity.io/manage)

  1. Create the project. Note the project id.
  2. Create the dataset as public (or accept that every read needs a token).
  3. API → CORS origins. Add each origin with Allow credentials on — Studio authenticates from the browser:
    • http://localhost:3000
    • your production host, exactly as chosen in §0 (https://www.example.com or https://example.com — the one that redirects needs no origin)
    • the *.vercel.app alias only if you want Studio on preview deploys
  4. API → Tokens. One Viewer token, one Editor token.

Keep in mind:

  • Tokens are per project. If you have several Sanity projects, it is easy to mint a token under the wrong one. The symptoms are indirect: the draft-mode route 500s with "client must have a token", or Presentation says "Unable to connect" because of "Session does not match project host".
  • CORS is checked by the browser, not by your server. Server-side fetches work from anywhere; a missing origin only breaks things that run in the browser — Studio sign-in and the live-events stream. Test it directly:
curl -sI -H "Origin: https://www.example.com" \
  "https://<projectId>.api.sanity.io/v2026-03-01/data/query/production?query=1" \
  | grep -i access-control
  • No access-control-allow-origin header back means the origin is not allowed.

4. Content model, queries, types

  • Singletons (site settings) need three guards in sanity.config.ts: hide them from the "create new" menu, restrict document actions to publish/discard/restore, and open them directly from the desk structure by a fixed id.
const SINGLETONS = new Set(["siteSettings"]);
const SINGLETON_ACTIONS = new Set(["publish", "discardChanges", "restore"]);

document: {
  newDocumentOptions: (prev) => prev.filter((i) => !SINGLETONS.has(i.templateId)),
  actions: (prev, { schemaType }) =>
    SINGLETONS.has(schemaType)
      ? prev.filter(({ action }) => action && SINGLETON_ACTIONS.has(action))
      : prev,
},
  • Write queries with defineQuery and let TypeGen generate the result types:
"typegen": "sanity schema extract && sanity typegen generate",
"prebuild": "npm run typegen"
  • By default every generated field is optional. sanity schema extract --enforce-required-fields makes required() fields non-optional — use it only if every document in the dataset is guaranteed to satisfy today's rules, which documents created before a rule was added, or seeded around Studio, are not. The all-optional default is the defensive choice: each section decides what "missing" means for it. Optional content hides; required content missing means the section returns null. Never substitute invented copy — a half-filled dataset should degrade to fewer sections, not to lorem ipsum in production.
  • Don't hand-edit sanity.types.ts or schema.json. Change the schema or the query and regenerate.
  • Resist a page builder and Portable Text until a real requirement needs them.

5. Stega: the discipline that makes click-to-edit safe

Under Visual Editing, sanityFetch returns strings with invisible characters embedded (stega encoding) so Presentation can map rendered text back to its field. They are harmless in display text and corrupt anything structural.

Two accessors, used deliberately:

import { stegaClean } from "next-sanity";

/** Display copy: keeps the encoding, so click-to-edit works. */
export function text(v: string | null | undefined) {
  const t = v?.trim();
  return t ? t : null;
}

/** Structural values: hrefs, mailto:, ids, JSON-LD, metadata, comparisons. */
export function plain(v: string | null | undefined) {
  const c = stegaClean(v);
  const t = typeof c === "string" ? c.trim() : null;
  return t ? t : null;
}

Rules of thumb:

  • href, mailto:, id= attributes, <title>, JSON-LD, anything parsed → plain().
  • Anything compared to a literal (=== "light", === "LinkedIn") → stegaClean() first. The encoded string will never equal the literal.
  • A safeUrl() helper should clean before validating the scheme; the invisible characters survive a regex test and then break the href.
  • For generateMetadata, Sanity's guidance is to fetch with sanityFetch({ query, stega: false }), so nothing encoded can reach <title>, descriptions or Open Graph tags. plain() on every field gives the same result if you'd rather reuse one memoised fetch. Either way: encoded strings never reach metadata.

6. Live content — and the two traps

// src/sanity/lib/client.ts
export const client = createClient({
  projectId, dataset, apiVersion,
  // Governs only direct client.fetch() calls — here, the draft-mode secret
  // check, which must read live. defineLive() reconfigures its own copy of
  // this client and decides CDN vs. API per request (see below).
  useCdn: false,
  stega: { studioUrl: "/studio" },
});

// src/sanity/lib/live.ts
import { defineLive } from "next-sanity/live";
const token = process.env.SANITY_API_READ_TOKEN;
export const { sanityFetch, SanityLive } = defineLive({
  client,
  serverToken: token ?? false,
  browserToken: token ?? false,
});

sanityFetch in Server Components; <SanityLive /> once, in the site layout. Published content is fetched from Sanity's CDN with sync tags, cached by Next, and revalidated by tag when the Live Content API reports a change.

Keep in mind — useCdn on your client does not decide this. defineLive calls withConfig({ useCdn: true, perspective: "published" }) on its own copy of the client, and sanityFetch then chooses per request: the CDN for published reads (with cacheMode: "noStale" outside the build phase), the live API host for drafts and other perspectives. So useCdn: false on the base client only affects code that calls client.fetch() directly — the draft-mode secret validation, where live reads are exactly right. You can see which host served a build's reads in .next/cache/fetch-cache: the URLs are <projectId>.apicdn.sanity.io.

Trap 1 — Presentation preview only updates on publish

Symptom: inside Studio's Presentation tool, the preview iframe shows the published content while you type into a draft; it catches up only when you hit Publish.

Mechanism: <VisualEditing /> opens a comlink channel called loaders to sync the perspective. Presentation reads a connected loader channel as "a live connection is handling refreshes" and stops sending its own refresh on mutations. If <SanityLive /> has no browserToken, its live connection only carries published events — so nothing refreshes the preview on draft edits.

Fix: pass the Viewer token as browserToken too. next-sanity sends it to the browser only in draft-mode sessions (token: includeDrafts ? browserToken : undefined), i.e. to editors who are already signed into Studio, never to public visitors. That is also why the token must be Viewer-level.

Trap 2 — published changes need a manual reload

Symptom: on the live site, after publishing, the page does not update until you reload it.

Mechanism (next-sanity 13.1–13.3 on Next 16): the default live action calls revalidateTag(tag, "max"), then tells the client to router.refresh(). "max" is Next 16's stale-while-revalidate profile — the refresh is served the old page while regeneration happens in the background. Your manual reload is the request that finally sees the change. In development next-sanity uses updateTag, which expires immediately, so this never shows up locally.

Fix: give <SanityLive /> your own action that expires the tags:

// src/sanity/lib/live-action.ts
"use server";
import { updateTag } from "next/cache";
import { draftMode } from "next/headers";
import { parseTags } from "next-sanity/live";

export async function expireSanityTags(unsafeTags: unknown): Promise<"refresh"> {
  if (!(await draftMode()).isEnabled) {
    for (const tag of parseTags(unsafeTags).tags) updateTag(tag);
  }
  return "refresh";
}
<SanityLive action={expireSanityTags} />

Cost: the first request after a publish waits for one regeneration. For a content site that is the behaviour you want. parseTags validates the tags (server actions are public endpoints — never call updateTag on unvalidated input).

What this still doesn't cover. Both the default action and this one run from a visitor's browser: the Live Content API pushes the event to open tabs, and a tab calls the server action. Publish while nobody has the site open and nothing revalidates — the next visitor gets the pre-publish page. The v13 migration guide says it outright: "by default, content changes are no longer guaranteed to be seen by all visitors within a few seconds."

The recommended fix is a Sync Tag Invalidate Function, deployed to Sanity's infrastructure with Blueprints. On every change it receives the exact syncTags Content Lake generated for your queries — including dependencies pulled in through references, which a hand-maintained webhook tagging scheme slowly drifts away from. The sequence:

  1. Content changes; the Function receives event.data.syncTags.
  2. It POSTs them to your /api/revalidate-tags route with a shared secret (SANITY_REVALIDATE_TAGS_SECRET).
  3. The route validates the secret (timingSafeEqual), the body shape and the tag count/lengths, then calls revalidateTag(`sanity:${tag}`, {expire: 0}) for each. The prefix is required: the raw event tags do not carry the sanity: prefix that sanityFetch uses in Next's cache.
  4. Only on response.ok does the Function call done(syncTags) — and only then does the Live API release the event to browsers, which refresh against an already-invalidated cache.

This invalidates the Next.js and Vercel caches even when nobody has the site open; a CDN you put in front of the app still needs its own purge step. Wire <SanityLive /> per environment. With waitFor="function" a custom action is still respected (migration guide: "action is always respected"), so switch yours off in production or it runs a redundant expiry:

const isProduction = process.env.VERCEL_ENV === "production";

<SanityLive
  action={isProduction ? "refresh" : expireSanityTags}
  waitFor={isProduction ? "function" : undefined}
/>

Only production gets waitFor="function": a preview environment waiting on a Function that invalidates the production endpoint would refresh against its own still-stale cache. For the Function itself — one per dataset (Sanity: "Having multiple sync-tag-invalidate functions set up for a single dataset may lead to race conditions, unexpected results, or increased usage"), so several sites sharing a dataset means one Function calling every production endpoint before done(). Develop it on Node 24, which is what deployed Functions run, even when the app stays on an older Node.

The lighter alternative is a GROQ-powered webhook to a route handler: verify the signature with parseBody from next-sanity/webhook, then revalidateTag(tag, { expire: 0 }). No Blueprints, no second deploy platform, no extra Node runtime — the webhook is a form you fill in once at sanity.io/manage (URL, dataset, trigger on create/update/delete, a filter like !(_type match "sanity.*") to skip system documents, and the signing secret).

The classic objection — a hand-maintained tag mapping that drifts as schemas evolve — disappears on small sites if you refuse to map at all: give every sanityFetch one coarse tag (tags: ["sanity-content"], ideally added in a single shared fetch helper) and have the webhook expire just that tag. Any publish invalidates all content fetches; on a site with a handful of queries that costs a few regenerations and can never miss a document type.

Pick by size, not by maximalism. A brochure site with a few document types and single-digit editors: the webhook with one coarse tag — it fixes the actual bug with a fraction of the machinery. A content platform with many types, reference-heavy queries, per-route caching you can't afford to blow away wholesale, or third-party CDNs to purge: the Function, which receives the exact sync tags and guarantees the invalidate-then-notify ordering. We built both in the course of these sites and shipped the webhook; the Function's plumbing (two platforms, secret synchronization, per-repo blueprint quirks) was out of proportion for three small sites.

7. Draft mode and Presentation

// sanity.config.ts
presentationTool({
  previewUrl: { previewMode: { enable: "/api/draft-mode/enable" } },
}),
// src/app/api/draft-mode/enable/route.ts
import { defineEnableDraftMode } from "next-sanity/draft-mode";
import { client } from "@/sanity/lib/client";

export const { GET } = defineEnableDraftMode({
  client: client.withConfig({ token: process.env.SANITY_API_READ_TOKEN }),
});
// src/app/api/draft-mode/disable/route.ts
import { draftMode } from "next/headers";
import { NextResponse } from "next/server";

export async function GET(request: Request) {
  (await draftMode()).disable();
  return NextResponse.redirect(new URL("/", request.url));
}

In the site layout, only under draft mode: <VisualEditing /> and a plain <a href="/api/draft-mode/disable">Exit preview</a>.

How it fits together: Presentation opens the enable route with a secret and a sanity-preview-perspective parameter; the route validates the secret with the token, enables Next draft mode, and stores the perspective in a cookie; sanityFetch reads that cookie and fetches drafts with the server token; Next bypasses its fetch cache entirely in draft mode, so every render inside the preview is fresh.

Don't fall back to the editor token in the enable route "just in case". A missing Viewer token fails loudly (401 Invalid secret), which is what you want.

8. Seeding, and verifying the site actually reads from Sanity

A seed script with @sanity/client, run with node --env-file=.env.local:

  • Default to createIfNotExists so re-running never clobbers editor changes; put destructive overwrite behind an explicit --replace flag.
  • Document _ids must not contain periods. Sanity treats such documents as private — invisible to the unauthenticated reads a public site uses. docId = (id) => id.replace(/\./g, "-").
  • Every object item in an array — inline objects and references alike — needs a _key, plus a _type naming its type. Arrays of primitives need nothing. Do it for all object arrays in the seed, including the ones that are empty today.
  • Keep the seed payload as plain ESM so both the seed script and an optional development-only content fallback can import it without a build step.

Verify with no token, the way the site will read:

curl -sG "https://<projectId>.api.sanity.io/v2026-03-01/data/query/production" \
  --data-urlencode 'query=*[_type in ["siteSettings","venture"]]{_id,_type}'

Then build and check the output, not the exit code:

npm run build
grep -c "Some string only Sanity has" .next/server/app/index.html

Keep in mind — the build-cache trap. Next persists revalidate: false fetch responses in .next/cache/fetch-cache across local builds. A build that ran while the dataset was empty (say, after you created .env.local but before seeding) stores result: null — and every later build bakes the same empty page, exit code 0, no warning. rm -rf .next/cache/fetch-cache and rebuild. Vercel's data cache persists across deployments too, but there a publish revalidates by tag; locally nothing does.

Keep in mind — the API's default perspective is published (API versions from 2025-02-19). A query for drafts.* ids returns nothing unless you pass perspective=raw or drafts with a token. Easy to misread as "no draft exists".

9. Next.js metadata gotchas (not Sanity, but you'll hit them)

Both of these follow from Next's metadata merge rules (resolve-metadata.js) and reproduce on any App Router site.

  • File-based opengraph-image.tsx must live in the segment whose page sets openGraph. Next only re-attaches a file-based image from the segment that owns the file, and a page-level openGraph object in generateMetadata replaces the parent's wholesale. With the file at the app root and the homepage in a (site) group setting openGraph: { title, description }, the homepage ships with no og:image while /privacy and the 404 get it. Move the file into (site)/; a CMS-provided image still wins when set.
  • Don't put alternates.canonical on the root layout. It is inherited by the 404 and Studio. Let each page declare its own.

Observations from these builds — verify in yours

Specific to the versions and UI kit these sites use; recorded because they cost time, not because they are laws.

  • On Next 16.3.2, a <Link href="/#contact"> clicked from another route landed on the homepage at scrollY: 0 without resolving the fragment, and scroll={false} behaved the same. A plain <a href="/#contact"> does a real navigation and the browser handles the fragment. The root-relative form matters regardless: a bare #contact in a shared header resolves against the current path (/privacy#contact).
  • With a fixed header, scroll-padding-top on html was the only offset needed; adding scroll-mt-* on the targets as well stacked the two.
  • With shadcn/ui on Base UI, rendering an anchor through the Button primitive stamped role="button" onto a navigating link. buttonVariants() as a className on a plain <a> keeps it a link.

10. Deploy on Vercel

  1. Import the repo as its own project. Set the Node version to match §0.
  2. Environment variables: the four NEXT_PUBLIC_* values and SANITY_API_READ_TOKEN. Not the editor token.
  3. Deploy. Studio deploys with the site at /studio.
  4. Domains. Add both the apex and www. Make the host you chose in §0 the production domain and set the other to Redirect to it (308). Vercel does this at the domain level; no vercel.json.
  5. NEXT_PUBLIC_SITE_URL must equal the production host. If Vercel's suggestion made www primary and your env var says apex, every canonical points at a URL that immediately redirects. Pick one and align both.
  6. Add the production origin to Sanity CORS (§3). Studio at https://<host>/studio can't sign in until you do.

11. DNS

Copy the records from your project's Domains panel — Vercel says values can differ per project, and the www CNAME target is always project-specific (<hash>.vercel-dns-0xx.com). At the time of writing the panel gave these sites an apex A → 216.150.1.1; the legacy 76.76.21.21 and cname.vercel-dns.com still work but are no longer what it suggests.

Read Vercel's domain panel carefully: it shows a "remove these conflicting records" table and an "add this record" table in the same layout. It is very easy to add the records it asked you to remove.

Keep in mind — registrar products own DNS records. GoDaddy's Airo / Website Builder and its domain forwarding publish their own apex A records (13.248.243.5, 76.223.105.230) and re-create them while the product is attached to the domain. With those plus Vercel's record, resolvers round-robin between the registrar's page and your site — "it works sometimes". Detach the product first, then delete the records. Leave MX and TXT rows alone; that is your mail.

Verify against the authoritative servers (no cache) and a couple of public resolvers, and ask each IP who it is:

dig +short A example.com @ns01.<registrar-ns>
dig +short A example.com @8.8.8.8
curl -sI http://13.248.243.5/ -H "Host: example.com" | grep -i "^server\|^location"

server: Vercel is yours; anything else is the registrar.

12. Post-launch checklist

Run these against the live host. Each one is thirty seconds and each has caught a real defect.

H=https://www.example.com

# Content really comes from Sanity, and the metadata is right
curl -s $H/ | grep -o '<link rel="canonical"[^>]*>\|<meta property="og:image"[^>]*>\|<meta name="twitter:card"[^>]*>'

# Routes
for p in /robots.txt /sitemap.xml /studio /does-not-exist; do
  printf "%-18s " $p; curl -s -o /dev/null -w "%{http_code}\n" $H$p; done

# Studio is isolated: no site nav, analytics or live components inside it
curl -s $H/studio | grep -c 'aria-label="Primary"\|_vercel/insights\|next-loader.live'   # expect 0

# The read token is nowhere in the public output
grep -rlF "$SANITY_API_READ_TOKEN" .next/static .next/server/app   # expect nothing

# The live-events stream opens from the production origin (CORS)
curl -sN -m 5 -H "Origin: $H" -H "Accept: text/event-stream" \
  "https://<projectId>.api.sanity.io/v2026-03-01/data/live/events/production" | head -3

Then, by hand: open Presentation, type into a draft, watch the preview move; publish, watch a plain tab update without a reload.

13. Keep in mind — the short list

  1. Studio inside the app at /studio, structurally outside the site's layout.
  2. Pin sanity to a major that runs on your Node; use the local CLI.
  3. Two tokens: Viewer for runtime (it reaches editors' browsers in draft mode), Editor for local seeding only. No fallbacks between them.
  4. One canonical host. Env var, Vercel production domain, redirect direction and Sanity CORS all agree.
  5. text() for display, plain() for anything structural.
  6. browserToken on, or Presentation only updates on publish.
  7. Server-side invalidation sized to the site: a signed webhook expiring one coarse tag for small sites, the Sync Tag Invalidate Function for platforms. Plus the updateTag action for connected visitors. Otherwise a publish is seen only by whoever happens to be connected — and their refresh lands on the stale page.
  8. _ids without periods; _key/_type on every object item in an array.
  9. After seeding, rm -rf .next/cache/fetch-cache before trusting a local build.
  10. opengraph-image.tsx next to the page that sets openGraph.
  11. The navigation and Button notes in §9 are observations from specific builds — verify them in yours rather than inheriting them.
  12. Registrar site builders own DNS records; detach before deleting.
  13. Verify output, not exit codes: grep the built HTML, curl the live host, read the headers.