Skip to content
Sign inJoin waitlist

Connect Sanity to a Next.js template

Sanity is a hosted CMS: your content lives in Sanity’s cloud, and editors work in Sanity Studio, an editor you run inside your own site at /studio. This guide connects it to the Nitip Next.js edition step by step — at the end, editors manage services, projects, posts, jobs, team and testimonials in the Studio, and the site updates a few seconds after they publish.

We followed every step below on a fresh copy of Nitip 1.0.0 with Sanity Studio 5.31, next-sanity 13.3 and Next.js 16.3 — the setup, the import, the pages, editing and reordering in the Studio, and publishing. Every other Framebrick Next.js template works the same way: it reads its collections through the same src/lib/content.ts, so only the document types and fields differ — its Using a headless CMS page lists them.

  • Sanity Studio at /studio with a list for each collection, image uploads, and drag-and-drop order.
  • The demo content imported — every entry and image — so the site looks exactly the same on day one.
  • Pages that update on publish. Sanity calls your site after every change; the next visit shows it. No rebuild.
  • The same pages and components. Only src/lib/content.ts changes where it reads from.
  • The Nitip Next.js edition, unzipped, with npm install done and npm run dev working (Install and run).
  • A free Sanity account — the first command below asks you to sign in or create one.
  • About 45 minutes.

In the project folder:

Terminal window
npx sanity@latest init

Sign in when it asks, then answer:

Question Answer
Project Create a new project, and give it a name
Dataset The default: production, public
Add configuration files to this Next.js folder? Yes
TypeScript? Yes
Embed Sanity Studio? Yes, at /studio
Template Clean — no predefined schema types
Add the project ID and dataset to .env.local? Yes

It installs sanity and next-sanity, adds sanity.config.ts, sanity.cli.ts, the src/sanity/ folder and the Studio page at src/app/studio, writes NEXT_PUBLIC_SANITY_PROJECT_ID and NEXT_PUBLIC_SANITY_DATASET to .env.local, and allows http://localhost:3000 to use the project.

Then add the plugin that gives the Studio its drag-and-drop lists, and the library the import script uses to number them:

Terminal window
npm install @sanity/orderable-document-list lexorank

Next.js renders every page inside src/app/layout.tsx — the site’s header, footer, fonts and styles — and the Studio would end up inside them. Route groups (folders in parentheses, which don’t change any URL) give each its own layout.

  1. Create the folders src/app/(frontend) and src/app/(studio).

  2. Move everything in src/app into (frontend), except favicon.ico, robots.ts, sitemap.ts and the studio folder.

  3. Move the studio folder into (studio), so the Studio page is src/app/(studio)/studio/[[...tool]]/page.tsx, and fix its import, now one folder deeper:

    src/app/(studio)/studio/[[...tool]]/page.tsx
    import config from '../../../../sanity.config'
    import config from '../../../../../sanity.config'
  4. Give the Studio its layout:

    src/app/(studio)/layout.tsx
    /* The Studio's own root layout, without the site's header, footer, fonts and styles. */
    export default function StudioLayout({ children }: { children: React.ReactNode }) {
    return (
    <html lang="en">
    <body style={{ margin: 0 }}>{children}</body>
    </html>
    );
    }
  5. In src/app/(frontend)/globals.css, the theme is now one folder further away:

    src/app/(frontend)/globals.css
    @import "tailwindcss";
    @import "../design-system/theme.css";
    @import "../../design-system/theme.css";

src/app now looks like this:

src/app/
├── (frontend)/ ← about, blog, careers, contact, projects, services, style-guide,
│ layout.tsx, page.tsx, not-found.tsx, globals.css, icons, share image
├── (studio)/
│ ├── layout.tsx
│ └── studio/[[...tool]]/page.tsx
├── favicon.ico
├── robots.ts
└── sitemap.ts

Each document type mirrors a type in src/content/types.ts, field for field, so the pages get exactly the data they get today. Replace src/sanity/schemaTypes/index.ts:

src/sanity/schemaTypes/index.ts
import { orderRankField, orderRankOrdering } from "@sanity/orderable-document-list";
import { defineArrayMember, defineField, defineType, type SchemaTypeDefinition } from "sanity";
/*
* The document types editors manage in /studio. Each one mirrors a type in src/content/types.ts, so src/lib/content.ts
* only renames a few fields on the way out. Every type is sorted by drag and drop (orderRank, see structure.ts).
*/
/* Shared fields */
const slug = defineField({
name: "slug",
type: "slug",
description: "The page address: lowercase words joined by hyphens.",
options: { source: "title" },
validation: (rule) => rule.required(),
});
const required = (name: string, type: "string" | "text" = "string") => defineField({ name, type, validation: (rule) => rule.required() });
const image = (name: string, title?: string) => defineField({ name, title, type: "image", validation: (rule) => rule.required() });
const imagePair = (name: string) =>
defineField({ name, type: "array", of: [defineArrayMember({ type: "image" })], validation: (rule) => rule.required().length(2) });
const lines = (name: string) => defineField({ name, type: "array", of: [defineArrayMember({ type: "text", rows: 2 })] });
/* Text that may start with a bold phrase ("Instant Traffic: Drive qualified visitors…"). */
const highlighted = (name: string) =>
defineField({
name,
type: "array",
of: [
defineArrayMember({
type: "object",
name: "item",
fields: [
defineField({ name: "highlight", type: "string", description: "Bold phrase at the start (optional)." }),
defineField({ name: "text", type: "text", rows: 2 }),
],
preview: { select: { title: "highlight", subtitle: "text" } },
}),
],
});
/* The hidden field the drag-and-drop order is stored in. */
const rank = (type: string) => orderRankField({ type });
/* Post body: the blocks the template renders (src/content/types.ts → PostBlock), two list levels deep. */
const heading = defineType({
name: "postHeading",
title: "Heading",
type: "object",
fields: [defineField({ name: "bold", type: "string" }), defineField({ name: "text", type: "string" })],
preview: { select: { title: "text", subtitle: "bold" } },
});
const paragraph = defineType({
name: "postParagraph",
title: "Paragraph",
type: "object",
fields: [defineField({ name: "highlight", type: "string" }), defineField({ name: "text", type: "text" })],
preview: { select: { title: "text", subtitle: "highlight" } },
});
const listFields = (inner: string[]) => [
defineField({ name: "ordered", title: "Numbered list", type: "boolean", initialValue: false }),
defineField({
name: "items",
type: "array",
of: [
defineArrayMember({
type: "object",
name: "item",
fields: [defineField({ name: "content", type: "array", of: inner.map((type) => defineArrayMember({ type })) })],
preview: { select: { title: "content.0.text" } },
}),
],
}),
];
const listPreview = {
select: { ordered: "ordered", items: "items" },
prepare: ({ ordered, items }: { ordered?: boolean; items?: unknown[] }) => ({
title: ordered ? "Numbered list" : "Bulleted list",
subtitle: `${items?.length ?? 0} items`,
}),
};
const subList = defineType({ name: "postSubList", title: "List", type: "object", fields: listFields(["postParagraph", "postHeading"]), preview: listPreview });
const list = defineType({ name: "postList", title: "List", type: "object", fields: listFields(["postParagraph", "postHeading", "postSubList"]), preview: listPreview });
const service = defineType({
name: "service",
type: "document",
orderings: [orderRankOrdering],
fields: [
rank("service"),
required("title"),
slug,
defineField({ name: "number", type: "string", description: "Shown before the title, e.g. 01." }),
imagePair("images"),
defineField({ name: "description", type: "text" }),
defineField({
name: "tools",
type: "array",
of: [defineArrayMember({ type: "object", name: "tool", fields: [defineField({ name: "label", type: "string" }), defineField({ name: "href", type: "url" })] })],
}),
highlighted("offer"),
highlighted("benefits"),
defineField({
name: "plans",
type: "array",
of: [
defineArrayMember({
type: "object",
name: "plan",
fields: [
defineField({ name: "key", type: "string", description: "Short id, e.g. weekly.", validation: (rule) => rule.required() }),
defineField({ name: "label", type: "string" }),
defineField({ name: "price", type: "string" }),
defineField({ name: "note", type: "string" }),
defineField({ name: "included", type: "array", of: [defineArrayMember({ type: "string" })] }),
],
preview: { select: { title: "label", subtitle: "price" } },
}),
],
}),
],
});
const project = defineType({
name: "project",
type: "document",
orderings: [orderRankOrdering],
fields: [
rank("project"),
required("title"),
slug,
defineField({ name: "year", type: "string" }),
image("image", "Card image"),
defineField({ name: "services", type: "array", of: [defineArrayMember({ type: "reference", to: [{ type: "service" }] })] }),
defineField({ name: "summary", type: "text" }),
defineField({ name: "industry", type: "string" }),
defineField({ name: "overview", type: "object", fields: [defineField({ name: "text", type: "text" }), image("image")] }),
defineField({ name: "process", type: "object", fields: [defineField({ name: "text", type: "text" }), imagePair("images")] }),
defineField({ name: "result", type: "object", fields: [highlighted("items"), image("image")] }),
],
preview: { select: { title: "title", subtitle: "year", media: "image" } },
});
const post = defineType({
name: "post",
type: "document",
orderings: [orderRankOrdering],
fields: [
rank("post"),
required("title"),
slug,
defineField({ name: "excerpt", type: "text", rows: 2 }),
image("image"),
defineField({ name: "category", type: "string" }),
defineField({ name: "date", type: "date", validation: (rule) => rule.required() }),
defineField({
name: "body",
type: "array",
of: [defineArrayMember({ type: "postHeading" }), defineArrayMember({ type: "postParagraph" }), defineArrayMember({ type: "postList" })],
}),
],
preview: { select: { title: "title", subtitle: "category", media: "image" } },
});
const career = defineType({
name: "career",
type: "document",
orderings: [orderRankOrdering],
fields: [rank("career"), required("title"), slug, defineField({ name: "summary", type: "text" }), lines("responsibilities"), lines("requirements"), lines("benefits")],
});
const teamMember = defineType({
name: "teamMember",
title: "Team member",
type: "document",
orderings: [orderRankOrdering],
fields: [rank("teamMember"), required("name"), defineField({ name: "role", type: "string" }), defineField({ name: "email", type: "string" }), image("photo")],
preview: { select: { title: "name", subtitle: "role", media: "photo" } },
});
const testimonial = defineType({
name: "testimonial",
type: "document",
orderings: [orderRankOrdering],
fields: [
rank("testimonial"),
required("quote", "text"),
required("name"),
defineField({ name: "company", type: "string" }),
defineField({ name: "service", type: "string", description: "The service the client used, shown under the name." }),
image("photo"),
image("logo"),
],
preview: { select: { title: "name", subtitle: "company", media: "photo" } },
});
export const schema: { types: SchemaTypeDefinition[] } = {
types: [service, project, post, career, teamMember, testimonial, heading, paragraph, list, subList],
};

A few choices worth knowing:

  • Order. Every type has a hidden orderRank field; the Studio’s lists (next step) sort by it with drag and drop, and the site shows entries in that order. New entries go to the end.
  • Post bodies are blocks — Heading, Paragraph and List — the same blocks the template renders, with one list level inside another, which covers every post in the demo. (Sanity’s own rich-text editor, Portable Text, stores text differently; these blocks keep the template’s design without a converter.)
  • Pricing plans have a key field (weekly, monthly…) for the plan’s id.

Then list the types in the Studio, each sortable. Replace src/sanity/structure.ts:

src/sanity/structure.ts
import { orderableDocumentListDeskItem } from "@sanity/orderable-document-list";
import type { StructureResolver } from "sanity/structure";
/* One list per collection, each sorted by drag and drop: the site shows entries in this order. */
const LISTS = [
{ type: "service", title: "Services" },
{ type: "project", title: "Projects" },
{ type: "post", title: "Posts" },
{ type: "career", title: "Careers" },
{ type: "teamMember", title: "Team" },
{ type: "testimonial", title: "Testimonials" },
];
export const structure: StructureResolver = (S, context) =>
S.list()
.title("Content")
.items(LISTS.map(({ type, title }) => orderableDocumentListDeskItem({ type, title, S, context })));

The demo content still lives in src/content/collections/. This script copies it into Sanity, uploading every image from public/images. Create src/sanity/seed.ts:

src/sanity/seed.ts
/*
* Copies the demo content from src/content/collections into Sanity:
* npx sanity exec src/sanity/seed.ts --with-user-token
* Images are uploaded from public/images. Document ids follow the slugs (service-<slug>, …), so running it again
* replaces the demo entries instead of adding copies; entries you created yourself are left alone.
*/
import fs from "node:fs";
import path from "node:path";
import { LexoRank } from "lexorank";
import { getCliClient } from "sanity/cli";
import { careers } from "../content/collections/careers";
import { posts } from "../content/collections/posts";
import { projects } from "../content/collections/projects";
import { services } from "../content/collections/services";
import { team } from "../content/collections/team";
import { testimonials } from "../content/collections/testimonials";
import type { Highlighted, PostBlock } from "../content/types";
const client = getCliClient({ apiVersion: "2026-10-01" });
/* "/images/blog-1.jpg" → an image field, uploading each file once. */
const uploaded = new Map<string, string>();
async function image(src: string) {
if (!uploaded.has(src)) {
const asset = await client.assets.upload("image", fs.createReadStream(path.resolve("public", src.slice(1))), { filename: path.basename(src) });
uploaded.set(src, asset._id);
}
return { _type: "image", asset: { _type: "reference", _ref: uploaded.get(src)! } };
}
/* Array items need a _key in Sanity. */
const keyed = <T extends object>(items: T[]) => items.map((item, i) => ({ _key: `k${i}`, ...item }));
const images = async (srcs: string[]) => keyed(await Promise.all(srcs.map(image)));
const highlighted = (items: Highlighted[]) => keyed(items.map((item) => ({ _type: "item", ...item })));
const slug = (current: string) => ({ _type: "slug", current });
/* The drag-and-drop order (orderRank), in the order of the demo files. */
const ranks = (count: number) => {
let rank = LexoRank.min();
return Array.from({ length: count }, () => (rank = rank.genNext().genNext()).toString());
};
function block(item: PostBlock, nested = false): Record<string, unknown> {
if (item.type === "heading") return { _type: "postHeading", text: item.text, bold: item.bold };
if (item.type === "paragraph") return { _type: "postParagraph", text: item.text, highlight: item.highlight };
return {
_type: nested ? "postSubList" : "postList",
...(item.ordered && { ordered: true }),
items: keyed(item.items.map((content) => ({ _type: "item", content: keyed(content.map((inner) => block(inner, true))) }))),
};
}
/* sanity exec runs the script as CommonJS, so the work sits in a function instead of top-level await. */
async function seed() {
const documents: Record<string, unknown>[] = [];
const add = async <T>(items: T[], make: (item: T) => Promise<Record<string, unknown>>) => {
const rank = ranks(items.length);
for (const [i, item] of items.entries()) documents.push({ ...(await make(item)), orderRank: rank[i] });
};
await add(services, async ({ slug: s, images: pair, tools, offer, benefits, plans, ...service }) => ({
_id: `service-${s}`,
_type: "service",
...service,
slug: slug(s),
images: await images(pair),
tools: keyed(tools.map((tool) => ({ _type: "tool", ...tool }))),
offer: highlighted(offer),
benefits: highlighted(benefits),
plans: keyed(plans.map(({ id, ...plan }) => ({ _type: "plan", key: id, ...plan }))),
}));
await add(projects, async ({ slug: s, image: img, services: refs, overview, process, result, ...project }) => ({
_id: `project-${s}`,
_type: "project",
...project,
slug: slug(s),
image: await image(img),
services: keyed(refs.map((ref) => ({ _type: "reference", _ref: `service-${ref.slug}` }))),
overview: { text: overview.text, image: await image(overview.image) },
process: { text: process.text, images: await images(process.images) },
result: { items: highlighted(result.items), image: await image(result.image) },
}));
await add(posts, async ({ slug: s, image: img, body, ...post }) => ({
_id: `post-${s}`,
_type: "post",
...post,
slug: slug(s),
image: await image(img),
body: keyed(body.map((item) => block(item))),
}));
await add(careers, async ({ slug: s, ...career }) => ({ _id: `career-${s}`, _type: "career", ...career, slug: slug(s) }));
await add(team, async ({ photo, ...member }) => ({
_id: `team-${member.email.split("@")[0]}`,
_type: "teamMember",
...member,
photo: await image(photo),
}));
await add(testimonials, async ({ photo, logo, ...testimonial }) => ({
_id: `testimonial-${testimonial.company.toLowerCase()}`,
_type: "testimonial",
...testimonial,
photo: await image(photo),
logo: await image(logo),
}));
/* Services first in the same transaction is fine: references are checked when it commits. */
const transaction = client.transaction();
for (const doc of documents) transaction.createOrReplace(doc as { _id: string; _type: string });
await transaction.commit();
console.log(`Seeded ${documents.length} documents and ${uploaded.size} images.`);
}
seed().catch((error) => {
console.error(error);
process.exit(1);
});

Run it with your Sanity login (no API token needed):

Terminal window
npx sanity exec src/sanity/seed.ts --with-user-token

It ends with:

Seeded 49 documents and 80 images.

Running it again replaces the demo entries rather than adding copies. Sanity stores identical files once, so the download’s grey placeholder images may share one asset — your own photos each get theirs.

The client. Pages are pre-rendered and rebuilt when Sanity calls the site (step 6), so they should read the API directly rather than Sanity’s CDN, which can lag a few seconds behind a publish. Replace src/sanity/lib/client.ts:

src/sanity/lib/client.ts
import { createClient } from "next-sanity";
import { apiVersion, dataset, projectId } from "../env";
export const client = createClient({
projectId,
dataset,
apiVersion,
// Pages are pre-rendered and rebuilt when Sanity calls /api/revalidate, so read the API directly: the CDN can lag a
// few seconds behind a publish.
useCdn: false,
});

sanity init also created src/sanity/lib/live.ts for Sanity’s Live Content API. This guide doesn’t use it — it refreshes pre-rendered pages only while someone has the site open — so you can delete it.

The content functions. Replace src/lib/content.ts. The function names and return types stay the same, so no page or component changes; each one runs a GROQ query whose result already has the template’s shape:

src/lib/content.ts
/*
* Where pages get their collections — now from Sanity (src/sanity). Each function runs one GROQ query whose projection
* already has the shape of a type in src/content/types.ts, in the order editors set in the Studio (orderRank), so
* sections and design-system components don't change.
*
* `cache` makes each call run once per request, even when metadata and the page ask for the same entry.
*/
import { cache } from "react";
import type { Career, Post, PostBlock, Project, Service, TeamMember, Testimonial } from "@/content/types";
import { client } from "@/sanity/lib/client";
/* Text with an optional bold start (Highlighted). */
const highlighted = `{ highlight, "text": coalesce(text, "") }`;
const SERVICES = `*[_type == "service"] | order(orderRank) {
"slug": slug.current, "number": coalesce(number, ""), title, "images": images[].asset->url,
"description": coalesce(description, ""), "tools": coalesce(tools[]{ label, href }, []),
"offer": coalesce(offer[]${highlighted}, []), "benefits": coalesce(benefits[]${highlighted}, []),
"plans": coalesce(plans[]{ "id": key, "label": coalesce(label, ""), "price": coalesce(price, ""), "note": coalesce(note, ""), "included": coalesce(included, []) }, [])
}`;
const PROJECTS = `*[_type == "project"] | order(orderRank) {
"slug": slug.current, title, "year": coalesce(year, ""), "image": image.asset->url,
"services": coalesce(services[]->{ "label": title, "slug": slug.current }, []),
"summary": coalesce(summary, ""), "industry": coalesce(industry, ""),
"overview": { "text": coalesce(overview.text, ""), "image": overview.image.asset->url },
"process": { "text": coalesce(process.text, ""), "images": process.images[].asset->url },
"result": { "items": coalesce(result.items[]${highlighted}, []), "image": result.image.asset->url }
}`;
const POSTS = `*[_type == "post"] | order(orderRank) {
"slug": slug.current, title, "excerpt": coalesce(excerpt, ""), "image": image.asset->url,
"category": coalesce(category, ""), date, "body": coalesce(body, [])
}`;
const CAREERS = `*[_type == "career"] | order(orderRank) {
"slug": slug.current, title, "summary": coalesce(summary, ""),
"responsibilities": coalesce(responsibilities, []), "requirements": coalesce(requirements, []), "benefits": coalesce(benefits, [])
}`;
const TEAM = `*[_type == "teamMember"] | order(orderRank) {
name, "role": coalesce(role, ""), "email": coalesce(email, ""), "photo": photo.asset->url
}`;
const TESTIMONIALS = `*[_type == "testimonial"] | order(orderRank) {
quote, name, "company": coalesce(company, ""), "service": coalesce(service, ""), "photo": photo.asset->url, "logo": logo.asset->url
}`;
/* Leaves out null fields, as the JSON in src/content/collections does. */
const clean = <T>(value: T): T => JSON.parse(JSON.stringify(value, (_, v) => (v === null ? undefined : v)));
/* A post body block from the Studio → the block the template renders. */
type BodyBlock = { _type: string; text?: string; bold?: string; highlight?: string; ordered?: boolean; items?: { content?: BodyBlock[] }[] };
function block(item: BodyBlock): PostBlock {
if (item._type === "postHeading") return { type: "heading", text: item.text ?? "", bold: item.bold };
if (item._type === "postParagraph") return { type: "paragraph", highlight: item.highlight, text: item.text ?? "" };
return { type: "list", ordered: item.ordered, items: (item.items ?? []).map((row) => (row.content ?? []).map(block)) };
}
export const getProjects = cache(async (): Promise<Project[]> => clean(await client.fetch(PROJECTS)));
export const getProject = cache(async (slug: string) => (await getProjects()).find((item) => item.slug === slug));
/** The project after this one, back to the first after the last ("Next Project" on a project page). */
export const getNextProject = cache(async (slug: string) => {
const projects = await getProjects();
return projects[(projects.findIndex((item) => item.slug === slug) + 1) % projects.length];
});
export const getServices = cache(async (): Promise<Service[]> => clean(await client.fetch(SERVICES)));
export const getService = cache(async (slug: string) => (await getServices()).find((item) => item.slug === slug));
export const getPosts = cache(async (): Promise<Post[]> =>
clean((await client.fetch(POSTS)).map((post: Omit<Post, "body"> & { body: BodyBlock[] }) => ({ ...post, body: post.body.map(block) }))),
);
export const getPost = cache(async (slug: string) => (await getPosts()).find((item) => item.slug === slug));
export const getCareers = cache(async (): Promise<Career[]> => clean(await client.fetch(CAREERS)));
export const getCareer = cache(async (slug: string) => (await getCareers()).find((item) => item.slug === slug));
export const getTeam = cache(async (): Promise<TeamMember[]> => clean(await client.fetch(TEAM)));
export const getTestimonials = cache(async (): Promise<Testimonial[]> => clean(await client.fetch(TESTIMONIALS)));

Images. Sanity serves uploads from its CDN; allow it in next.config.ts so next/image resizes them like the local files:

next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* Images uploaded in Sanity are served from its CDN; next/image resizes them like the local ones. */
images: { remotePatterns: [{ protocol: "https", hostname: "cdn.sanity.io" }] },
};
export default nextConfig;

Let new entries render. The pages in blog/[slug], careers/[slug], projects/[slug] and services/[slug] (inside (frontend)) only allow the slugs that existed at build time. Allow new ones:

src/app/(frontend)/blog/[slug]/page.tsx
/* Only the posts in the collection exist; any other slug is a 404. */
export const dynamicParams = false;
/* Entries published in the CMS after the build render on their first visit; unknown slugs are a 404. */
export const dynamicParams = true;

Keep the site’s 404 page. With two layouts Next.js has no site-wide 404 any more, so an unknown address would show its plain default page. Create src/app/(frontend)/[...missing]/page.tsx:

src/app/(frontend)/[...missing]/page.tsx
import { notFound } from "next/navigation";
/* With two root layouts (site and Studio) there is no app-wide 404, so any other URL lands here and shows the site's
not-found page inside the site layout. */
export default function Missing() {
notFound();
}

Sanity tells the site about every change through a webhook: a request to a URL of yours, signed with a secret so nobody else can send it. Create the route it calls, src/app/api/revalidate/route.ts:

src/app/api/revalidate/route.ts
import { revalidatePath } from "next/cache";
import { parseBody } from "next-sanity/webhook";
import type { NextRequest } from "next/server";
/* Sanity calls this after every publish or delete (a GROQ-powered webhook signed with SANITY_REVALIDATE_SECRET). Every
page is then rebuilt from Sanity on its next visit: pages share content — a renamed service shows on project pages
too. */
export async function POST(request: NextRequest) {
const { isValidSignature } = await parseBody(request, process.env.SANITY_REVALIDATE_SECRET);
if (!isValidSignature) return new Response("Invalid signature", { status: 401 });
revalidatePath("/", "layout");
return Response.json({ revalidated: true });
}

Add the secret to .env.local — any long random string:

.env.local
SANITY_REVALIDATE_SECRET=replace-with-a-long-random-string

To make one:

Terminal window
node -e "console.log(require('crypto').randomBytes(24).toString('hex'))"

Sanity can’t reach localhost, so the webhook itself is set up when the site is online (Going live). You don’t need it while you work: with npm run dev every reload reads Sanity again.

Terminal window
npm run dev
  1. Open localhost:3000 — the site looks exactly as before, now read from Sanity.
  2. Open localhost:3000/studio and sign in with your Sanity account.
  3. Open Posts, pick one, change the title and click Publish. Reload the post on the site: in development every page reads Sanity again, so the new title is there.
  4. Drag a post to another place in the list and reload the blog page: the order follows.

Then check the production build:

Terminal window
npm run build
npm start
  • Page copy — headings, intros, the About text, button labels — is in src/content/pages/, and the site details (name, contact, social links) in src/content/site.ts. They change rarely, so they stay in the repository. Move them to Sanity singletons later if editors need them.
  • src/content/collections/ keeps the demo data: the import script reads it, and the style guide page shows samples from it. The site’s pages no longer use it.
  • The contact and newsletter forms work as before (Forms).

Deploy the site as usual (Publish); the content is already online in Sanity. Then, in sanity.io/manage → your project → API:

  1. Environment variables on your host: NEXT_PUBLIC_SANITY_PROJECT_ID, NEXT_PUBLIC_SANITY_DATASET and SANITY_REVALIDATE_SECRET, the same values as in .env.local.

  2. CORS origins → add your site’s address (https://yourcompany.com) with Allow credentials on, so the Studio at /studio can sign editors in there too.

  3. Webhooks → create one:

    Setting Value
    URL https://yourcompany.com/api/revalidate
    Dataset production
    Trigger on Create, Update, Delete
    Filter _type in ["service", "project", "post", "career", "teamMember", "testimonial"]
    HTTP method POST
    Secret your SANITY_REVALIDATE_SECRET

Publishing then refreshes the site a few seconds later: the route waits about three seconds so Sanity’s API has the new version, then every page is rebuilt on its next visit.

We tested the route with requests signed exactly as Sanity signs them, on a production build, including a new post and a deleted one; the webhook settings themselves follow Sanity’s webhook docs, since Sanity can’t call a local machine. Keep SANITY_REVALIDATE_SECRET out of Git (.env.local already is).

Missing environment variable: NEXT_PUBLIC_SANITY_PROJECT_ID. .env.local doesn’t have the project ID — run npx sanity@latest init again, or copy it from sanity.io/manage. On a host, set the variables there.

The Studio shows the site’s header, or the site loses its styles. The route groups from step 2 aren’t complete: the Studio needs src/app/(studio)/layout.tsx, and globals.css the extra ../.

Top-level await is currently not supported with the "cjs" output format when running the seed. The script must keep its work inside seed(), as above — sanity exec runs it as CommonJS.

The Studio asks you to add a CORS origin. The address you opened it on isn’t allowed yet — add it under API → CORS origins with credentials allowed.

A published change doesn’t show online. Check the webhook’s attempts log in sanity.io/manage: a 401 means the secret differs between the webhook and SANITY_REVALIDATE_SECRET on the host.

Unknown addresses show a plain “404: This page could not be found.” The catch-all page from step 5 is missing.

Still stuck? Contact support with the step and the error message.