Build a Headless CMS with Sanity
Build a client-ready headless CMS with Next.js, Sanity, and shadcn/ui.
Introduction
30 Second Summary
When clients can edit their own website content, they inevitably break the design. Text overflows, layouts collapse, and you get a support ticket at midnight.
In this project, you will build a headless CMS engine using Next.js, Sanity, and shadcn/ui that separates content editing from visual presentation. Your client gets a full editing dashboard. Your design stays pixel-perfect.
What You'll Build
A headless CMS-powered website where content lives in Sanity's Content Lake and renders through strictly controlled shadcn/ui components.
By the end of this project, you'll have:
- An embedded Sanity Studio (the client's editing dashboard) mounted at /studio inside your Next.js app.
- A page-builder content schema with hero and text block section types that the client stacks to compose pages.
- A component-mapping rendering layer that fetches structured content via GROQ queries and renders it through locked shadcn/ui components.
- Secret Mission: Add dynamic slug-based page routing with static generation so every page the client creates in Studio automatically gets a production URL.
Are there any prerequisites?
This is an advanced project. You should have Node.js 22 LTS and pnpm installed, a Sanity account provisioned, and a Vercel account ready.
Familiarity with TypeScript and React is expected.
Scaffold the Project and Lock the Stack
Every headless CMS project starts with the same three pillars: a frontend framework, a component library, and a content platform. Getting these installed and configured correctly at the start prevents compatibility issues later.
In this step, you will scaffold a Next.js 16 project, initialize shadcn/ui for locked UI components, and embed Sanity Studio inside the app. By the end, your entire stack will be running locally.
In this step, get ready to:
- Create a Next.js 16 project and initialize shadcn/ui.
- Add the shadcn/ui components and Tailwind Typography plugin.
- Initialize Sanity Studio inside the project and verify the dev server.
Create the Next.js project and initialize shadcn/ui
Next.js 16 ships with TypeScript, Tailwind CSS, and the App Router enabled by default. You will scaffold the project using the recommended defaults, then layer shadcn/ui on top as your component library.
- Open your terminal. Press the Windows key to open your search bar, type Terminal, and press Enter.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create the Next.js project by running this command:
pnpm create next-app@latest agency-cms
What will the CLI ask?
The CLI will ask you to name your project (already provided as agency-cms) and whether you want to use recommended defaults.
Select Yes, use recommended defaults when prompted. This enables TypeScript, ESLint, Tailwind CSS, and the App Router automatically.
- Move into your new project folder by running:
cd agency-cms
- Initialize shadcn/ui in the project by running:
pnpm dlx shadcn@latest init
What will shadcn init ask?
The shadcn CLI will detect your Next.js project and ask about base configuration. Accept the defaults (Radix base, Nova preset) by pressing Enter at each prompt.
This adds shadcn/tailwind.css imports to your app/globals.css and sets up the components/ui directory where your locked components will live.
Now update the project layout to use the Inter font and set the project metadata.
- Open the agency-cms folder in your code editor.
- Open app/layout.tsx and replace its entire contents with the following:
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import './globals.css'
const inter = Inter({ subsets: ['latin'] })
export const metadata: Metadata = {
title: 'Agency CMS',
description: 'Client-ready headless CMS powered by Next.js and Sanity',
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body className={inter.className}>{children}</body>
</html>
)
}
What does this code do?
This layout sets Inter as the project's typeface and defines metadata that appears in browser tabs and search results.
The RootLayout component wraps every page in the app, so the font and HTML structure apply globally.
- Save app/layout.tsx.
Seeing import errors in your editor?
If your editor shows red underlines on the imports, the TypeScript language server may not have loaded the project yet. Try closing and reopening the file, or restarting the editor.
help me fix TypeScript import errors in my Next.js layout
✔️ Awesome, I've got everything!
Great. Make sure you have saved app/layout.tsx before moving on.
ⓧ I'd like to double check the full code
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import './globals.css'
const inter = Inter({ subsets: ['latin'] })
export const metadata: Metadata = {
title: 'Agency CMS',
description: 'Client-ready headless CMS powered by Next.js and Sanity',
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body className={inter.className}>{children}</body>
</html>
)
}
Add shadcn/ui components and the typography plugin
The rendering layer you will build later uses two specific shadcn/ui components. You will also need the Tailwind Typography plugin so rich text from Sanity renders with proper prose styling.
- Install the shadcn Button and Separator components, then add the typography plugin, by running these commands:
pnpm dlx shadcn@latest add button separator
pnpm add @tailwindcss/typography
What did these commands do?
The first command copies the Button and Separator component source files into components/ui/. These are now part of your project's source code, not an external dependency.
The second command installs @tailwindcss/typography as a package. You still need to activate it in your CSS file.
- Open app/globals.css in your editor.
- Add the typography plugin on a new line after the existing imports, so the file looks like this:
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@plugin "@tailwindcss/typography";
Why a @plugin directive instead of a config file?
Tailwind CSS v4 uses CSS-based configuration. Instead of a tailwind.config.ts file, you activate plugins directly in your CSS with the @plugin directive.
- Save app/globals.css.
Editor warning about @plugin?
Some editors flag @plugin as an unknown CSS at-rule. This is valid Tailwind v4 syntax. The warning is cosmetic and will not affect your build.
help me resolve CSS linting warnings about the @plugin directive
✔️ Awesome, I've got everything!
Great. Confirm you have saved app/globals.css with all four lines.
ⓧ I'd like to double check the full code
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@plugin "@tailwindcss/typography";
Initialize Sanity inside the project
Sanity Studio is the content editing dashboard your client will use. Embedding it inside the Next.js app means one deployment gives you both the frontend and the editor at the same URL.
- Initialize Sanity in your project by running:
pnpm dlx sanity@latest init
What will the Sanity CLI ask?
The CLI detects your Next.js project and walks you through several prompts. Respond to each as follows:
- Create a new project (name it anything you like, such as agency-cms).
- Create a dataset with the default settings: public, named production.
- Add configuration files to your Next.js project.
- Select TypeScript.
- Embed Sanity Studio at /studio.
- Choose the clean (empty) schema template.
- When asked about adding project details to .env.local, confirm yes.
The CLI installs next-sanity, sanity, and styled-components as dependencies. It also creates sanity.config.ts, sanity.cli.ts, and the Studio route at app/studio/[[...tool]]/page.tsx.
Now create the environment variables file so the app can connect to your Sanity project.
- Check if a .env.local file exists at the root of your agency-cms folder. If it does not exist, create it. Make sure it contains these two lines with your actual Sanity project ID:
NEXT_PUBLIC_SANITY_PROJECT_ID=your-project-id
NEXT_PUBLIC_SANITY_DATASET=production
Where do I find my Sanity project ID?
Your project ID was displayed in the terminal during the sanity init prompts. You can also find it at sanity.io/manage under your project's settings.
- Verify the full stack is working by starting the dev server:
pnpm dev
- Open http://localhost:3000 in your browser. You should see the Next.js default welcome page.
- Open http://localhost:3000/studio in your browser. You should see the Sanity Studio login screen.
Studio not loading at /studio?
Make sure your .env.local file is at the project root (same level as package.json) and contains NEXT_PUBLIC_SANITY_PROJECT_ID with a valid project ID.
If you changed .env.local after starting the server, stop it with Ctrl+C and run pnpm dev again. Next.js only reads environment variables at startup.
help me debug why Sanity Studio is not loading at /studio in my Next.js app
Your full stack is now running. Next up, you will define the content schema that powers the page-builder pattern.
Define the Page-Builder Content Schema
Your stack is locked and the Studio shell is running. Now you need to give the Studio something to work with: a content schema that defines exactly what your client can create and edit.
The page-builder pattern lets clients compose pages by stacking predefined section types. You define the section shapes (a hero, a text block). The client picks which sections to add and fills in the content. The design never breaks because each section type maps to a single locked component later.
In this step, get ready to:
- Create hero and text block section types that model exact UI data needs.
- Create a page document type with a sections array that accepts any combination of section types.
- Register the schemas and test content creation in Studio.
Create the hero section type
Each section type in Sanity is a schema definition that declares the fields the client can fill in. The hero section needs a heading, subheading, and a call-to-action button with text and a link.
- Create a new file at sanity/schemaTypes/hero.ts and paste the following code:
import { defineType, defineField } from 'sanity'
export const hero = defineType({
name: 'hero',
title: 'Hero Section',
type: 'object',
fields: [
defineField({
name: 'heading',
title: 'Heading',
type: 'string',
validation: (rule) => rule.required(),
}),
defineField({
name: 'subheading',
title: 'Subheading',
type: 'text',
rows: 3,
}),
defineField({
name: 'ctaText',
title: 'CTA Button Text',
type: 'string',
}),
defineField({
name: 'ctaLink',
title: 'CTA Button Link',
type: 'url',
}),
],
preview: {
select: { title: 'heading' },
},
})
What does this code do?
defineType creates a reusable Sanity schema type. Setting type: 'object' means this is an inline block (not a standalone document). It can only exist inside another document's array.
Each defineField call declares one editable field for the client. The heading field has validation: (rule) => rule.required() so the client cannot leave it blank. The preview config tells Studio which field to display as the section label in the editor sidebar.
- Save sanity/schemaTypes/hero.ts.
You won't see a change yet. The next file defines the second section type, and then you'll wire both into Studio.
Create the text block section type
The text block section gives clients a rich text editor for longer content like paragraphs, lists, and links. Sanity calls this rich text format Portable Text.
- Create a new file at sanity/schemaTypes/textBlock.ts and paste the following code:
import { defineType, defineField } from 'sanity'
export const textBlock = defineType({
name: 'textBlock',
title: 'Text Block',
type: 'object',
fields: [
defineField({
name: 'heading',
title: 'Heading',
type: 'string',
}),
defineField({
name: 'body',
title: 'Body',
type: 'array',
of: [{ type: 'block' }],
}),
],
preview: {
select: { title: 'heading' },
},
})
What does this code do?
The body field uses type: 'array' with of: [{ type: 'block' }]. This is Sanity's way of enabling a rich text editor. The block type gives clients formatting options like bold, italic, headings, bullet lists, and links.
- Save sanity/schemaTypes/textBlock.ts.
You won't see a change yet. The next substep creates the page document type and wires everything together.
Create the page document type
The page document is the container that clients interact with directly. It has a title, a URL slug, and a sections array where the client stacks hero and text block sections in any order they choose.
- Create a new file at sanity/schemaTypes/page.ts and paste the following code:
import { defineType, defineField } from 'sanity'
export const page = defineType({
name: 'page',
title: 'Page',
type: 'document',
fields: [
defineField({
name: 'title',
title: 'Title',
type: 'string',
validation: (rule) => rule.required(),
}),
defineField({
name: 'slug',
title: 'Slug',
type: 'slug',
options: { source: 'title', maxLength: 96 },
validation: (rule) => rule.required(),
}),
defineField({
name: 'sections',
title: 'Page Sections',
type: 'array',
of: [{ type: 'hero' }, { type: 'textBlock' }],
}),
],
})
What does this code do?
Setting type: 'document' makes this a top-level content type that appears in Studio's sidebar. Documents are the things clients create and publish directly.
The slug field auto-generates a URL-friendly string from the title (with source: 'title'). The sections array accepts both hero and textBlock objects. This is the page-builder pattern: the client adds sections in any order, and each one maps to a locked UI component on the frontend.
- Save sanity/schemaTypes/page.ts.
You won't see a change yet. You need to register these types in the schema index so Studio knows about them.
Register the schema types and test in Studio
Sanity only loads schema types that are explicitly exported from the schema index file. Your sanity.config.ts already imports from this file, so you just need to populate it with the three types you created.
- Open sanity/schemaTypes/index.ts and replace its contents with the following code:
import { page } from './page'
import { hero } from './hero'
import { textBlock } from './textBlock'
export const schemaTypes = [page, hero, textBlock]
What does this code do?
This exports a schemaTypes array containing all three types. The sanity.config.ts file already imports this array and passes it to schema.types. Once you save, Studio picks up the new types automatically.
- Save sanity/schemaTypes/index.ts.
- Switch to your browser and navigate to http://localhost:3000/studio. You should see Page listed in the Studio sidebar on the left.
Don't see Page in the sidebar?
Make sure you saved all four files (hero.ts, textBlock.ts, page.ts, index.ts). Check your terminal for error messages. A common issue is a typo in the import path or a missing comma in the schemaTypes array.
Help me debug why my schema types aren't showing in Sanity Studio.
Now create your first page document to confirm everything works end-to-end.
- Click the pencil icon (compose) at the top of the Studio sidebar to create a new document.
- Select Page as the document type.
- Type Home in the Title field.
- Click Generate next to the Slug field. It should auto-fill with home.
- Scroll down to Page Sections and click Add item....
- Select Hero Section.
- Fill in the hero fields with sample content. Type Welcome to Our Agency for the heading, We build beautiful websites for the subheading, and Get Started for the CTA button text.
- Click Add item... again and select Text Block.
- Type About Us for the heading and write a short paragraph in the body field.
- Click Publish in the bottom right corner of the editor.
What is Sanity's Content Lake?
When you click Publish, the document is stored in Sanity's Content Lake. This is a hosted cloud database that persists your content independently from your code. Your Next.js frontend will query this same data in the next step.
✔️ Awesome, I've got everything!
Great. Double check you've saved all your schema files before moving on.
ⓧ I'd like to double check the full code
import { defineType, defineField } from 'sanity'
export const hero = defineType({
name: 'hero',
title: 'Hero Section',
type: 'object',
fields: [
defineField({
name: 'heading',
title: 'Heading',
type: 'string',
validation: (rule) => rule.required(),
}),
defineField({
name: 'subheading',
title: 'Subheading',
type: 'text',
rows: 3,
}),
defineField({
name: 'ctaText',
title: 'CTA Button Text',
type: 'string',
}),
defineField({
name: 'ctaLink',
title: 'CTA Button Link',
type: 'url',
}),
],
preview: {
select: { title: 'heading' },
},
})
import { defineType, defineField } from 'sanity'
export const textBlock = defineType({
name: 'textBlock',
title: 'Text Block',
type: 'object',
fields: [
defineField({
name: 'heading',
title: 'Heading',
type: 'string',
}),
defineField({
name: 'body',
title: 'Body',
type: 'array',
of: [{ type: 'block' }],
}),
],
preview: {
select: { title: 'heading' },
},
})
import { defineType, defineField } from 'sanity'
export const page = defineType({
name: 'page',
title: 'Page',
type: 'document',
fields: [
defineField({
name: 'title',
title: 'Title',
type: 'string',
validation: (rule) => rule.required(),
}),
defineField({
name: 'slug',
title: 'Slug',
type: 'slug',
options: { source: 'title', maxLength: 96 },
validation: (rule) => rule.required(),
}),
defineField({
name: 'sections',
title: 'Page Sections',
type: 'array',
of: [{ type: 'hero' }, { type: 'textBlock' }],
}),
],
})
import { page } from './page'
import { hero } from './hero'
import { textBlock } from './textBlock'
export const schemaTypes = [page, hero, textBlock]
Your content model is live and your client has a structured editing experience. Next up, you'll connect the frontend to this content so the data flows from Sanity's Content Lake straight into your Next.js pages.
Configure the Sanity Client and Fetch Content
Your content schema is live and you have real data sitting in Sanity's Content Lake. The next challenge is pulling that content out of Sanity and into your Next.js frontend.
To bridge this gap, you'll configure a Sanity client that connects to your project's API and fetches content using GROQ, Sanity's query language. By the end of this step, your homepage will display real content pulled directly from the Content Lake.
In this step, get ready to:
- Create a centralized environment config for Sanity connection details.
- Set up the Sanity client with CDN caching for fast reads.
- Fetch and display your "home" page content on the Next.js homepage.
Create the environment config file
Your .env.local file already contains your Sanity project ID and dataset name. Now you need a TypeScript module that reads those values and exports them for use throughout your app.
- Create a new file at sanity/env.ts inside your agency-cms project folder and paste the following code:
export const projectId = process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!
export const dataset = process.env.NEXT_PUBLIC_SANITY_DATASET!
export const apiVersion = '2026-06-01'
What does this code do?
- projectId and dataset pull values from your .env.local environment variables. The ! tells TypeScript these values will always exist at runtime.
- apiVersion locks your GROQ queries to a specific API date so future Sanity changes don't break your app.
- Centralizing these values here means every file that needs Sanity config imports from one place.
- Save sanity/env.ts.
Create the Sanity client
The Sanity client is what actually makes API requests to fetch your content. It uses the config values you just exported.
- Create a new folder lib inside the sanity folder, then create a file at sanity/lib/client.ts with the following code:
import { createClient } from 'next-sanity'
import { projectId, dataset, apiVersion } from '../env'
export const client = createClient({
projectId,
dataset,
apiVersion,
useCdn: true,
})
What does this code do?
- createClient from next-sanity creates a configured Sanity client that knows how to talk to your project's Content Lake.
- useCdn: true routes read requests through Sanity's global CDN. This makes content fetches fast for production visitors.
- Importing from ../env keeps connection details in one place. If you ever change projects or datasets, you update one file.
- Save sanity/lib/client.ts.
Seeing a module not found error?
Make sure the lib folder is inside sanity/ (not at the project root). The path should be sanity/lib/client.ts.
Also check that sanity/env.ts exists at the same level. The import ../env goes up one folder from lib/ to sanity/.
Help me debug a module not found error in my Sanity client file
Fetch the homepage content with a GROQ query
Now for the payoff. You'll replace the default Next.js homepage with a React Server Component that fetches your "home" page document from Sanity and displays its title.
- Open app/page.tsx and replace its entire contents with the following code:
import { client } from '@/sanity/lib/client'
// GROQ query to fetch the page document with slug "home"
const HOME_PAGE_QUERY = `*[_type == "page" && slug.current == "home"][0]{
title,
sections
}`
export default async function HomePage() {
// Fetch the home page data from Sanity's Content Lake
const page = await client.fetch(HOME_PAGE_QUERY)
// Show a fallback if no home page exists yet
if (!page) {
return (
<main className="container mx-auto min-h-screen max-w-4xl p-8">
<h1 className="text-4xl font-bold">No content yet</h1>
<p className="mt-4 text-muted-foreground">
Create a page with slug "home" in your Sanity Studio at /studio
</p>
</main>
)
}
// Display the page title to verify the data pipeline works
return (
<main className="container mx-auto min-h-screen max-w-4xl p-8">
<h1 className="text-4xl font-bold">{page.title}</h1>
<p className="mt-4 text-muted-foreground">
Content fetched successfully. Sections: {page.sections?.length || 0}
</p>
</main>
)
}
What does this code do?
- HOME_PAGE_QUERY is a GROQ query that finds the page document whose slug is "home" and returns its title and sections array.
- The async function makes this a React Server Component. It fetches data on the server before sending HTML to the browser.
- The if (!page) fallback handles the case where no home page exists yet, pointing the user to Studio.
- For now, you're just displaying the raw title to confirm the data pipeline works. In the next step, you'll render full sections through locked UI components.
What is GROQ?
GROQ (Graph-Relational Object Queries) is Sanity's query language. The *[_type == "page"] part filters all documents of type "page". The [0] grabs the first match. The { title, sections } projection picks only the fields you need.
Think of it like SQL for your content, but designed specifically for document-based data.
- Save app/page.tsx.
- Return to your browser and refresh http://localhost:3000. You should see your page title (the one you typed in Studio) displayed as a large heading, with a count of how many sections you added.
Seeing "No content yet" instead of your title?
This means the GROQ query returned nothing. Check these common causes:
- Make sure your Page document in Studio has a slug field set to exactly home. Open Studio at /studio and check the Slug field.
- Make sure you clicked Publish in Studio. Draft documents are not visible to the CDN client.
- Verify your .env.local has the correct project ID. You can find it at sanity.io/manage.
Help me debug why my Sanity query is returning no data
- To confirm the live connection works, switch to Studio at /studio, change your page title to something new, and click Publish.
- Refresh http://localhost:3000. The new title appears instantly.
✔️ Awesome, I've got everything!
Great. Double check you've saved all three files.
ⓧ I'd like to double check the full code
Here are the three files you created or edited in this step.
export const projectId = process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!
export const dataset = process.env.NEXT_PUBLIC_SANITY_DATASET!
export const apiVersion = '2026-06-01'
import { createClient } from 'next-sanity'
import { projectId, dataset, apiVersion } from '../env'
export const client = createClient({
projectId,
dataset,
apiVersion,
useCdn: true,
})
import { client } from '@/sanity/lib/client'
const HOME_PAGE_QUERY = `*[_type == "page" && slug.current == "home"][0]{
title,
sections
}`
export default async function HomePage() {
const page = await client.fetch(HOME_PAGE_QUERY)
if (!page) {
return (
<main className="container mx-auto min-h-screen max-w-4xl p-8">
<h1 className="text-4xl font-bold">No content yet</h1>
<p className="mt-4 text-muted-foreground">
Create a page with slug "home" in your Sanity Studio at /studio
</p>
</main>
)
}
return (
<main className="container mx-auto min-h-screen max-w-4xl p-8">
<h1 className="text-4xl font-bold">{page.title}</h1>
<p className="mt-4 text-muted-foreground">
Content fetched successfully. Sections: {page.sections?.length || 0}
</p>
</main>
)
}
Your data pipeline is live. Content flows from Sanity's Content Lake through your GROQ query and renders on the page. Next up, you'll build the shadcn/ui rendering layer that turns raw section data into polished, locked-down UI components.
Build the shadcn/ui Rendering Layer
Your Sanity content is flowing into the app, but right now there is nothing to render it. The app/page.tsx file already references a PageBuilder component that does not exist yet, so your dev server is throwing an error.
In this step, you will build the rendering layer that transforms structured Sanity data into pixel-perfect UI. Each section type from your schema gets its own locked shadcn/ui component, and a central PageBuilder routes data to the correct component based on its type.
In this step, get ready to:
- Create a hero section component with heading, subheading, and CTA button.
- Create a text block component with rich text rendering and a separator.
- Create a page builder component that maps section types to their UI components.
Create the hero section component
The hero section is the large, attention-grabbing banner at the top of a page. Your Sanity schema defined fields for a heading, subheading, CTA text, and CTA link. Now you need a React component that receives those fields and renders them using shadcn/ui's Button component.
- Create a new folder called sections inside the components folder.
- Create a new file called hero.tsx inside components/sections/ and paste the following code:
import { Button } from '@/components/ui/button'
interface HeroProps {
heading?: string
subheading?: string
ctaText?: string
ctaLink?: string
[key: string]: unknown
}
export function HeroSection({ heading, subheading, ctaText, ctaLink }: HeroProps) {
return (
<section className="flex min-h-[60vh] flex-col items-center justify-center gap-6 px-4 py-24 text-center">
{heading && (
<h1 className="max-w-3xl text-5xl font-bold tracking-tight">
{heading}
</h1>
)}
{subheading && (
<p className="max-w-2xl text-xl text-muted-foreground">
{subheading}
</p>
)}
{ctaText && (
<Button size="lg" asChild>
<a href={ctaLink || '#'}>{ctaText}</a>
</Button>
)}
</section>
)
}
What does this code do?
The HeroProps interface defines the shape of data this component expects. All fields are optional because a client might not fill in every field in Studio.
The [key: string]: unknown index signature allows the component to accept extra props (like _key and _type) from Sanity without TypeScript errors.
The shadcn Button with asChild renders the CTA as an anchor tag styled as a button. This keeps the design locked regardless of what text the client types.
- Save components/sections/hero.tsx.
Seeing a module not found error?
Make sure the file is at components/sections/hero.tsx (not components/hero.tsx). The folder structure matters for the imports in the next files.
Also confirm that components/ui/button.tsx exists. You added it in Step 1 with the shadcn add command.
Help me fix my hero component import error.
Create the text block section component
The text block renders rich text content that the client writes in Studio. Sanity stores rich text as Portable Text, a JSON-based format. The PortableText component from next-sanity converts that JSON into rendered HTML, and Tailwind Typography's prose classes style it.
- Create a new file called text-block.tsx inside components/sections/ and paste the following code:
import { PortableText } from 'next-sanity'
import { Separator } from '@/components/ui/separator'
interface TextBlockProps {
heading?: string
body?: Array<Record<string, unknown>>
[key: string]: unknown
}
export function TextBlockSection({ heading, body }: TextBlockProps) {
return (
<section className="mx-auto max-w-3xl px-4 py-16">
{heading && (
<>
<h2 className="text-3xl font-semibold tracking-tight">{heading}</h2>
<Separator className="my-6" />
</>
)}
{body && (
<div className="prose prose-neutral dark:prose-invert max-w-none">
<PortableText value={body} />
</div>
)}
</section>
)
}
What does this code do?
PortableText takes the raw block content array from Sanity and renders it as HTML elements (paragraphs, headings, lists, links). You do not need to parse anything manually.
The shadcn Separator component draws a horizontal line between the heading and body. The prose prose-neutral dark:prose-invert classes from Tailwind Typography automatically style all the rendered HTML with proper spacing, font sizes, and colors.
- Save components/sections/text-block.tsx.
Separator not found?
The Separator component was added in Step 1 with the shadcn add command. If you see a module error, confirm components/ui/separator.tsx exists in your project.
Help me fix my text block component.
Create the page builder mapping layer
The PageBuilder is the integration layer between Sanity's data and your UI components. It receives the sections array from a page document and routes each section to the correct component based on its _type field.
Why a mapping layer?
Without a central mapper, every page template would need its own switch statement to render sections. The PageBuilder centralizes this logic so you can add new section types in one place and every page in the app picks them up automatically.
This is also where design integrity lives. The client can stack sections in any order, but each section always renders through a locked component.
- Create a new file called page-builder.tsx inside the components folder and paste the following code:
import { HeroSection } from '@/components/sections/hero'
import { TextBlockSection } from '@/components/sections/text-block'
interface Section {
_type: string
_key: string
[key: string]: unknown
}
interface PageBuilderProps {
sections: Section[] | null
}
export function PageBuilder({ sections }: PageBuilderProps) {
if (!sections) return null
return (
<div className="flex flex-col">
{sections.map((section) => {
switch (section._type) {
case 'hero':
return <HeroSection key={section._key} {...section} />
case 'textBlock':
return <TextBlockSection key={section._key} {...section} />
default:
return (
<div key={section._key} className="p-8 text-center text-muted-foreground">
Unknown section type: {section._type}
</div>
)
}
})}
</div>
)
}
What does this code do?
The Section interface requires _type and _key fields, which Sanity automatically adds to every array item. The switch statement matches each _type to its component.
The default case renders a visible message for any unrecognized section type. This makes debugging easy if you add a new schema type but forget to map it here.
- Save components/page-builder.tsx.
- Switch back to your browser and refresh http://localhost:3000.
- You should see your hero section rendered with a large heading, subheading text, and a styled CTA button. Below that, the text block section displays formatted rich text with a separator line under the heading.
Page still showing raw title or an error?
Your app/page.tsx imports PageBuilder from @/components/page-builder. Confirm that components/page-builder.tsx exists at that exact path (not inside components/sections/).
If the page loads but shows no content, open Studio at /studio and confirm your home page document has at least one section added and is published.
Help me debug my PageBuilder rendering.
- To confirm the content-code separation is working, open Studio at http://localhost:3000/studio.
- Edit the heading in your hero section to something different, then click Publish.
- Refresh the homepage. Your updated heading appears in the exact same styled layout.
Why does this matter?
This is the core thesis of your CMS engine. The client changed content, and the design stayed intact. No developer intervention was needed. The heading renders at the same size, same font weight, same position every time.
✔️ Awesome, I've got everything!
Great. Double check that you have saved all three new files before moving on.
ⓧ I'd like to double check the full code
Here is each of the three new component files in full:
import { Button } from '@/components/ui/button'
interface HeroProps {
heading?: string
subheading?: string
ctaText?: string
ctaLink?: string
[key: string]: unknown
}
export function HeroSection({ heading, subheading, ctaText, ctaLink }: HeroProps) {
return (
<section className="flex min-h-[60vh] flex-col items-center justify-center gap-6 px-4 py-24 text-center">
{heading && (
<h1 className="max-w-3xl text-5xl font-bold tracking-tight">
{heading}
</h1>
)}
{subheading && (
<p className="max-w-2xl text-xl text-muted-foreground">
{subheading}
</p>
)}
{ctaText && (
<Button size="lg" asChild>
<a href={ctaLink || '#'}>{ctaText}</a>
</Button>
)}
</section>
)
}
import { PortableText } from 'next-sanity'
import { Separator } from '@/components/ui/separator'
interface TextBlockProps {
heading?: string
body?: Array<Record<string, unknown>>
[key: string]: unknown
}
export function TextBlockSection({ heading, body }: TextBlockProps) {
return (
<section className="mx-auto max-w-3xl px-4 py-16">
{heading && (
<>
<h2 className="text-3xl font-semibold tracking-tight">{heading}</h2>
<Separator className="my-6" />
</>
)}
{body && (
<div className="prose prose-neutral dark:prose-invert max-w-none">
<PortableText value={body} />
</div>
)}
</section>
)
}
import { HeroSection } from '@/components/sections/hero'
import { TextBlockSection } from '@/components/sections/text-block'
interface Section {
_type: string
_key: string
[key: string]: unknown
}
interface PageBuilderProps {
sections: Section[] | null
}
export function PageBuilder({ sections }: PageBuilderProps) {
if (!sections) return null
return (
<div className="flex flex-col">
{sections.map((section) => {
switch (section._type) {
case 'hero':
return <HeroSection key={section._key} {...section} />
case 'textBlock':
return <TextBlockSection key={section._key} {...section} />
default:
return (
<div key={section._key} className="p-8 text-center text-muted-foreground">
Unknown section type: {section._type}
</div>
)
}
})}
</div>
)
}
import { client } from '@/sanity/lib/client'
import { PageBuilder } from '@/components/page-builder'
const HOME_PAGE_QUERY = `*[_type == "page" && slug.current == "home"][0]{
title,
sections
}`
export default async function HomePage() {
const page = await client.fetch(HOME_PAGE_QUERY)
if (!page) {
return (
<main className="container mx-auto min-h-screen max-w-4xl p-8">
<h1 className="text-4xl font-bold">No content yet</h1>
<p className="mt-4 text-muted-foreground">
Create a page with slug "home" in your Sanity Studio at /studio
</p>
</main>
)
}
return (
<main>
<PageBuilder sections={page.sections} />
</main>
)
}
Your rendering layer is complete. Content flows from Sanity through the PageBuilder into locked shadcn/ui components. The client can edit freely and the design never breaks. Next up, you will tackle the Secret Mission and add dynamic page routing so every page the client creates gets its own URL automatically.
Secret mission
Add Dynamic Page Routing
Right now, only the homepage works. What if your client wants an About page? A Services page? In this mission, you'll add dynamic slug-based routing so every page created in Studio automatically gets a live URL with static generation.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally with a free-tier Sanity project, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- Local agency-cms project folder on your Desktop.
- Sanity project and dataset (free tier, stored in the cloud).
- Local Next.js development server (runs only when you start it).
Keep everything running
No action needed. Choose this if you are still actively building on this project.
- Your Sanity project persists indefinitely on the free tier at no cost.
- Your local agency-cms folder stays on your Desktop until you move or delete it.
- The dev server only runs when you start it with pnpm dev.
Pause - I'll come back to this later
Stop the dev server to free up your terminal. Your Sanity content persists in the cloud and your local files stay intact.
- In your terminal, press Ctrl+C to stop the Next.js dev server.
- Your Sanity project and all content remain accessible whenever you return.
- To resume later, open your terminal, navigate to agency-cms, and run pnpm dev.
Delete - I don't want to use this again
Remove all project resources and start fresh. This is permanent.
- Stop the dev server by pressing Ctrl+C in your terminal.
- Delete the local project folder by running the command for your OS below.
macOS
cd ~/Desktop
rm -rf agency-cms
Windows
cd ~\Desktop
rmdir /s /q agency-cms
- Delete the Sanity project from the cloud:
- Go to manage.sanity.io and sign in.
- Select the project you created for this tutorial.
- Click Settings.
- Scroll down to the Danger zone section and click Delete.
- Follow the confirmation prompts to permanently remove the project and its dataset.
Nice Work!
Nice Work!
You just built a production-grade headless CMS engine powered by Next.js, Sanity, and shadcn/ui. This is the foundation of a high-margin agency offering where clients edit content safely and the design never breaks.
You've learned how to:
- Build a Next.js 16 application with an embedded Sanity Studio at /studio, giving clients a full content editing dashboard alongside the frontend.
- Design a page-builder content schema with typed hero and text block sections that clients compose pages from, while you control the rendering of each block type.
- Create a component-mapping rendering layer that fetches structured content via GROQ queries in React Server Components and renders through locked shadcn/ui components to guarantee design integrity.
- Secret Mission: Add dynamic slug-based routing with static generation so every page created in Studio automatically gets a production URL.
Ready to quiz yourself?