Sanity CMS Setup
Follow these steps when connecting a new project to Sanity.
Create a new Sanity project
Run this in a separate folder from your Next.js project.
npm create sanity@latest
Follow the prompts. Choose production as your dataset. Note your Project ID — you will need it for env vars.
Update sanity.config.js (full version)
Replace the default sanity.config.js with this full version. It wires in custom branding, the theme, the Presentation tool with env-driven preview origin, and the custom layout. Fill in your_project_id and update the title/theme URL before continuing.
Theme URL (copy and swap into import)
https://themer.sanity.build/api/hues?default=6b7280;lightest:f9fafb;darkest:111827&primary=18a1ad;lightest:f0feff;darkest:0d4f56&transparent=f2fcfe;100;darkest:e6f7fb&positive=779e43;lightest:f7f9f2;darkest:3d4f22&caution=f59e0b;lightest:fef3c7;darkest:92400e&critical=ef4444;lightest:fef2f2;darkest:991b1b
/* eslint-disable no-undef */
import {defineConfig} from 'sanity'
import {structureTool} from 'sanity/structure'
import {presentationTool} from 'sanity/presentation'
import {schemaTypes} from './schemaTypes'
// TODO: add once structure.js is set up
// import {structure, singletonActions, singletonNewDocument} from './structure'
import CustomLayout from './components/CustomLayout.jsx'
import clientLogo from './components/clientLogo.jsx'
import {theme} from 'https://themer.sanity.build/api/hues?default=6b7280;lightest:f9fafb;darkest:111827&primary=18a1ad;lightest:f0feff;darkest:0d4f56&transparent=f2fcfe;100;darkest:e6f7fb&positive=779e43;lightest:f7f9f2;darkest:3d4f22&caution=f59e0b;lightest:fef3c7;darkest:92400e&critical=ef4444;lightest:fef2f2;darkest:991b1b'
export default defineConfig({
name: 'default',
title: 'Client Name CMS',
subtitle: 'Powered by Latz Web Design',
icon: clientLogo,
projectId: 'your_project_id',
dataset: 'production',
theme: theme,
releases: {
enabled: false,
},
plugins: [
structureTool({
title: 'Edit Content',
// TODO: uncomment once structure.js is set up
// structure,
}),
presentationTool({
title: 'Live Preview',
previewUrl: {
initial: process.env.SANITY_STUDIO_PREVIEW_ORIGIN || 'http://localhost:3000',
preview: '/',
previewMode: {
enable: '/api/draft-mode/enable',
},
},
}),
],
schema: {
types: schemaTypes,
},
// TODO: uncomment once structure.js is set up
// document: {
// actions: singletonActions,
// newDocumentOptions: singletonNewDocument,
// },
studio: {
components: {
layout: (props) => <CustomLayout {...props} cmsGuideUrl='/static/cms-guide.pdf' />,
},
},
})Customize your theme at sanity.build/themer, then replace the URL above with the new hues before committing.
Add custom Studio branding
Create a components/ folder inside your Sanity Studio project and add these two files.
components/clientLogo.jsx
import React from 'react'
const clientLogo = () => (
<img
src="/static/client-logo.png"
alt="Client Logo"
style={{ width: '25px', height: '25px', objectFit: 'contain' }}
/>
)
export default clientLogoDrop the client's logo at public/static/client-logo.png in the Studio project. 25 × 25 px works well; use a square or icon version of their mark. File must be .jsx — JSX won't parse in a plain .js file.
components/CustomLayout.jsx
/* eslint-env browser */
import {forwardRef, useState} from 'react'
const CustomLayout = forwardRef(({cmsGuideUrl = '/static/cms-guide.pdf', ...props}, ref) => {
const [showPopup, setShowPopup] = useState(false)
const [copied, setCopied] = useState(false)
const handleBrandClick = (e) => {
e.preventDefault()
e.stopPropagation()
setShowPopup(!showPopup)
}
const handleEmailCopy = async (e) => {
e.preventDefault()
e.stopPropagation()
try {
await navigator.clipboard.writeText('jordan@latzwebdesign.com')
setCopied(true)
setTimeout(() => {
setCopied(false)
setShowPopup(false)
}, 2000)
} catch (err) {
console.log('Email: jordan@latzwebdesign.com')
setCopied(true)
setTimeout(() => {
setCopied(false)
setShowPopup(false)
}, 2000)
}
}
return (
<div ref={ref} style={{height: '100vh', display: 'flex', flexDirection: 'column'}}>
<div style={{flex: 1, minHeight: 0, overflow: 'auto'}}>{props.renderDefault(props)}</div>
<footer
style={{
flexShrink: 0,
padding: '10px 20px',
background: '#111827',
borderTop: '1px solid #18a1ad',
textAlign: 'center',
fontSize: '12px',
position: 'relative',
zIndex: 1000,
fontFamily:
'"Roboto", -apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Arial, sans-serif',
}}
>
<span style={{color: '#f9fafb'}}>
Powered by{' '}
<strong
style={{
color: '#18a1ad',
cursor: 'pointer',
textDecoration: 'underline',
userSelect: 'none',
}}
onClick={handleBrandClick}
>
Latz Web Design
</strong>{' '}
<span
style={{
color: '#f2fcfe',
cursor: 'pointer',
fontSize: '14px',
userSelect: 'none',
}}
onClick={handleBrandClick}
>
ⓘ
</span>
</span>
{showPopup && (
<div
style={{
position: 'fixed',
bottom: '60px',
left: '50%',
transform: 'translateX(-50%)',
background: '#f9fafb',
border: '2px solid #18a1ad',
borderRadius: '8px',
padding: '20px',
boxShadow: '0 8px 24px rgba(0,0,0,0.4)',
zIndex: 10000,
minWidth: '280px',
fontFamily:
'"Roboto", -apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Arial, sans-serif',
}}
>
<div
style={{
color: '#111827',
marginBottom: '14px',
fontWeight: 'bold',
fontSize: '14px',
}}
>
Need Help?
</div>
<button
style={{
color: copied ? '#f9fafb' : '#111827',
cursor: 'pointer',
padding: '10px 12px',
background: copied ? '#18a1ad' : '#f2fcfe',
borderRadius: '4px',
border: '1px solid #18a1ad',
width: '100%',
fontSize: '12px',
fontWeight: copied ? 'bold' : 'normal',
fontFamily: 'inherit',
}}
onClick={handleEmailCopy}
>
{copied ? '✓ Copied to Clipboard!' : '📧 jordan@latzwebdesign.com'}
</button>
<a
href={cmsGuideUrl}
target="_blank"
rel="noopener noreferrer"
style={{
display: 'block',
color: '#f9fafb',
padding: '10px 12px',
background: '#111827',
borderRadius: '4px',
border: '1px solid #18a1ad',
width: '100%',
fontSize: '12px',
fontFamily: 'inherit',
textAlign: 'center',
textDecoration: 'none',
marginTop: '8px',
boxSizing: 'border-box',
}}
onClick={(e) => e.stopPropagation()}
>
📖 How to Edit Your Website
</a>
<a
href="https://latzwebdesign.com"
target="_blank"
rel="noopener noreferrer"
style={{
display: 'block',
color: '#6b7280',
fontSize: '11px',
textAlign: 'center',
marginTop: '12px',
textDecoration: 'none',
fontFamily: 'inherit',
}}
onClick={(e) => e.stopPropagation()}
>
latzwebdesign.com
</a>
</div>
)}
{showPopup && (
<div
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
zIndex: 9999,
}}
onClick={() => setShowPopup(false)}
/>
)}
</footer>
</div>
)
})
CustomLayout.displayName = 'CustomLayout'
export default CustomLayout
This adds the “Powered by Latz Web Design” footer with a click-to-copy email popup. Paste in the full component code from your template.
Add the contact form schema
Create schemaTypes/contactForm.js in your Studio project.
schemaTypes/contactForm.js
export default {
name: 'contactForm',
type: 'document',
title: 'Contact Form',
fields: [
{ name: 'name', type: 'string', title: 'Name' },
{ name: 'email', type: 'string', title: 'Email' },
{ name: 'phoneNumber', type: 'string', title: 'Phone Number' },
{ name: 'description', type: 'text', title: 'Description' },
{ name: 'sentAt', type: 'datetime', title: 'Sent At' },
],
}Add the homepage schema
Create schemaTypes/homePage.js — pre-filled with placeholder content so you can test fetching immediately without typing anything.
schemaTypes/homePage.js
export default {
name: 'homePage',
type: 'document',
title: 'Home Page',
fields: [
{
name: 'heading',
type: 'string',
title: 'Heading',
initialValue: 'Welcome to Our Website',
},
{
name: 'subheading',
type: 'string',
title: 'Subheading',
initialValue: 'We build custom websites that convert leads into customers.',
},
{
name: 'body',
type: 'text',
title: 'Body',
initialValue:
'This is a placeholder to test your Sanity connection ' +
'and visual editing preview are working. ' +
'Replace with real content once confirmed working.',
},
],
}In Studio, create a new Home Page document — it will pre-fill automatically. Publish it, then confirm it fetches in your Next.js app.
Add the navigation schema
Create schemaTypes/navigation.js. Each link has a label, URL, and an isButton toggle — check it on whichever link should render as the CTA button.
schemaTypes/navigation.js
export default {
name: 'navigation',
type: 'document',
title: 'Navigation',
preview: {
prepare() {
return { title: 'Navigation' }
},
},
fields: [
{
name: 'logo',
type: 'image',
title: 'Logo',
options: { hotspot: true },
},
{
name: 'navLinks',
type: 'array',
title: 'Navigation Links',
of: [
{
type: 'object',
fields: [
{
name: 'label',
type: 'string',
title: 'Label',
},
{
name: 'url',
type: 'string',
title: 'URL',
description: 'e.g. /about or https://example.com',
},
{
name: 'isButton',
type: 'boolean',
title: 'Show as Button',
description: 'Check to display this link as a CTA button',
initialValue: false,
},
],
preview: {
select: {
title: 'label',
subtitle: 'url',
isButton: 'isButton',
},
prepare({ title, subtitle, isButton }) {
return {
title: isButton ? title + ' [BUTTON]' : title,
subtitle,
}
},
},
},
],
},
],
}Register all three schemas in schemaTypes/index.js:
schemaTypes/index.js
import contactForm from './contactForm' import homePage from './homePage' import navigation from './navigation' export const schemaTypes = [contactForm, homePage, navigation]
In Studio, create a Navigation document, add your links, and check Show as Button on the CTA link.
Fill out .env.local (Next.js project)
Copy .env.template to .env.local and fill in all values. Generate tokens at sanity.io/manage → your project → API → Tokens.
# Sanity (server-side) SANITY_PROJECT_ID=your_project_id # Sanity (public) NEXT_PUBLIC_SANITY_PROJECT_ID=your_project_id NEXT_PUBLIC_SANITY_DATASET=production NEXT_PUBLIC_SANITY_STUDIO_URL=http://localhost:3333 # prod: https://your-project-name.sanity.studio # Sanity tokens SANITY_API_TOKEN= # Editor permissions — for form submissions SANITY_VIEWER_TOKEN= # Viewer permissions — for draft mode/visual editing # Email EMAIL_PASS= EMAIL_USER= CLIENT_EMAIL=
Fill out .env (Sanity Studio project)
The Studio project needs its own env file for the preview origin. This lets Vercel-deployed Studio point at the live domain without hardcoding it.
# Sanity Studio project .env (separate from Next.js) SANITY_STUDIO_PREVIEW_ORIGIN=https://your-production-domain.com
For local dev, SANITY_STUDIO_PREVIEW_ORIGIN falls back to http://localhost:3000 automatically via the config fallback — you only need this set in production.
Start both dev servers
Terminal 1 — Next.js
npm run dev
Terminal 2 — Sanity Studio
npm run dev
Next.js runs on localhost:3000 — Studio on localhost:3333. Open the Studio, click Presentation (now labeled Live Preview), and your site should load with clickable overlays.
Test draft mode
The draft mode route is already in the starter at app/api/draft-mode/enable/route.js. Just make sure SANITY_VIEWER_TOKEN is set in .env.local, then hit this URL to confirm it's working:
http://localhost:3000/api/draft-mode/enable
You should be redirected to / with ?sanity-preview-perspective=drafts in the URL.
Configure Desk Structure
Set up structure.js based on the pages and singletons this project needs. This is manual and changes per project — there is no universal template. Define singleton documents (Home Page, Navigation, etc.) so they open directly without a list view, and group the rest logically. Wire it into sanity.config.js via the structureTool config option.
Sanity CMS Setup
Follow these steps when connecting a new project to Sanity.
Create a new Sanity project
Run this in a separate folder from your Next.js project.
npm create sanity@latest
Follow the prompts. Choose production as your dataset. Note your Project ID — you will need it for env vars.
Update sanity.config.js (full version)
Replace the default sanity.config.js with this full version. It wires in custom branding, the theme, the Presentation tool with env-driven preview origin, and the custom layout. Fill in your_project_id and update the title/theme URL before continuing.
Theme URL (copy and swap into import)
https://themer.sanity.build/api/hues?default=6b7280;lightest:f9fafb;darkest:111827&primary=18a1ad;lightest:f0feff;darkest:0d4f56&transparent=f2fcfe;100;darkest:e6f7fb&positive=779e43;lightest:f7f9f2;darkest:3d4f22&caution=f59e0b;lightest:fef3c7;darkest:92400e&critical=ef4444;lightest:fef2f2;darkest:991b1b
/* eslint-disable no-undef */
import {defineConfig} from 'sanity'
import {structureTool} from 'sanity/structure'
import {presentationTool} from 'sanity/presentation'
import {schemaTypes} from './schemaTypes'
// TODO: add once structure.js is set up
// import {structure, singletonActions, singletonNewDocument} from './structure'
import CustomLayout from './components/CustomLayout.jsx'
import clientLogo from './components/clientLogo.jsx'
import {theme} from 'https://themer.sanity.build/api/hues?default=6b7280;lightest:f9fafb;darkest:111827&primary=18a1ad;lightest:f0feff;darkest:0d4f56&transparent=f2fcfe;100;darkest:e6f7fb&positive=779e43;lightest:f7f9f2;darkest:3d4f22&caution=f59e0b;lightest:fef3c7;darkest:92400e&critical=ef4444;lightest:fef2f2;darkest:991b1b'
export default defineConfig({
name: 'default',
title: 'Client Name CMS',
subtitle: 'Powered by Latz Web Design',
icon: clientLogo,
projectId: 'your_project_id',
dataset: 'production',
theme: theme,
releases: {
enabled: false,
},
plugins: [
structureTool({
title: 'Edit Content',
// TODO: uncomment once structure.js is set up
// structure,
}),
presentationTool({
title: 'Live Preview',
previewUrl: {
initial: process.env.SANITY_STUDIO_PREVIEW_ORIGIN || 'http://localhost:3000',
preview: '/',
previewMode: {
enable: '/api/draft-mode/enable',
},
},
}),
],
schema: {
types: schemaTypes,
},
// TODO: uncomment once structure.js is set up
// document: {
// actions: singletonActions,
// newDocumentOptions: singletonNewDocument,
// },
studio: {
components: {
layout: (props) => <CustomLayout {...props} cmsGuideUrl='/static/cms-guide.pdf' />,
},
},
})Customize your theme at sanity.build/themer, then replace the URL above with the new hues before committing.
Add custom Studio branding
Create a components/ folder inside your Sanity Studio project and add these two files.
components/clientLogo.jsx
import React from 'react'
const clientLogo = () => (
<img
src="/static/client-logo.png"
alt="Client Logo"
style={{ width: '25px', height: '25px', objectFit: 'contain' }}
/>
)
export default clientLogoDrop the client's logo at public/static/client-logo.png in the Studio project. 25 × 25 px works well; use a square or icon version of their mark. File must be .jsx — JSX won't parse in a plain .js file.
components/CustomLayout.jsx
/* eslint-env browser */
import {forwardRef, useState} from 'react'
const CustomLayout = forwardRef(({cmsGuideUrl = '/static/cms-guide.pdf', ...props}, ref) => {
const [showPopup, setShowPopup] = useState(false)
const [copied, setCopied] = useState(false)
const handleBrandClick = (e) => {
e.preventDefault()
e.stopPropagation()
setShowPopup(!showPopup)
}
const handleEmailCopy = async (e) => {
e.preventDefault()
e.stopPropagation()
try {
await navigator.clipboard.writeText('jordan@latzwebdesign.com')
setCopied(true)
setTimeout(() => {
setCopied(false)
setShowPopup(false)
}, 2000)
} catch (err) {
console.log('Email: jordan@latzwebdesign.com')
setCopied(true)
setTimeout(() => {
setCopied(false)
setShowPopup(false)
}, 2000)
}
}
return (
<div ref={ref} style={{height: '100vh', display: 'flex', flexDirection: 'column'}}>
<div style={{flex: 1, minHeight: 0, overflow: 'auto'}}>{props.renderDefault(props)}</div>
<footer
style={{
flexShrink: 0,
padding: '10px 20px',
background: '#111827',
borderTop: '1px solid #18a1ad',
textAlign: 'center',
fontSize: '12px',
position: 'relative',
zIndex: 1000,
fontFamily:
'"Roboto", -apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Arial, sans-serif',
}}
>
<span style={{color: '#f9fafb'}}>
Powered by{' '}
<strong
style={{
color: '#18a1ad',
cursor: 'pointer',
textDecoration: 'underline',
userSelect: 'none',
}}
onClick={handleBrandClick}
>
Latz Web Design
</strong>{' '}
<span
style={{
color: '#f2fcfe',
cursor: 'pointer',
fontSize: '14px',
userSelect: 'none',
}}
onClick={handleBrandClick}
>
ⓘ
</span>
</span>
{showPopup && (
<div
style={{
position: 'fixed',
bottom: '60px',
left: '50%',
transform: 'translateX(-50%)',
background: '#f9fafb',
border: '2px solid #18a1ad',
borderRadius: '8px',
padding: '20px',
boxShadow: '0 8px 24px rgba(0,0,0,0.4)',
zIndex: 10000,
minWidth: '280px',
fontFamily:
'"Roboto", -apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Arial, sans-serif',
}}
>
<div
style={{
color: '#111827',
marginBottom: '14px',
fontWeight: 'bold',
fontSize: '14px',
}}
>
Need Help?
</div>
<button
style={{
color: copied ? '#f9fafb' : '#111827',
cursor: 'pointer',
padding: '10px 12px',
background: copied ? '#18a1ad' : '#f2fcfe',
borderRadius: '4px',
border: '1px solid #18a1ad',
width: '100%',
fontSize: '12px',
fontWeight: copied ? 'bold' : 'normal',
fontFamily: 'inherit',
}}
onClick={handleEmailCopy}
>
{copied ? '✓ Copied to Clipboard!' : '📧 jordan@latzwebdesign.com'}
</button>
<a
href={cmsGuideUrl}
target="_blank"
rel="noopener noreferrer"
style={{
display: 'block',
color: '#f9fafb',
padding: '10px 12px',
background: '#111827',
borderRadius: '4px',
border: '1px solid #18a1ad',
width: '100%',
fontSize: '12px',
fontFamily: 'inherit',
textAlign: 'center',
textDecoration: 'none',
marginTop: '8px',
boxSizing: 'border-box',
}}
onClick={(e) => e.stopPropagation()}
>
📖 How to Edit Your Website
</a>
<a
href="https://latzwebdesign.com"
target="_blank"
rel="noopener noreferrer"
style={{
display: 'block',
color: '#6b7280',
fontSize: '11px',
textAlign: 'center',
marginTop: '12px',
textDecoration: 'none',
fontFamily: 'inherit',
}}
onClick={(e) => e.stopPropagation()}
>
latzwebdesign.com
</a>
</div>
)}
{showPopup && (
<div
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
zIndex: 9999,
}}
onClick={() => setShowPopup(false)}
/>
)}
</footer>
</div>
)
})
CustomLayout.displayName = 'CustomLayout'
export default CustomLayout
This adds the “Powered by Latz Web Design” footer with a click-to-copy email popup. Paste in the full component code from your template.
Add the contact form schema
Create schemaTypes/contactForm.js in your Studio project.
schemaTypes/contactForm.js
export default {
name: 'contactForm',
type: 'document',
title: 'Contact Form',
fields: [
{ name: 'name', type: 'string', title: 'Name' },
{ name: 'email', type: 'string', title: 'Email' },
{ name: 'phoneNumber', type: 'string', title: 'Phone Number' },
{ name: 'description', type: 'text', title: 'Description' },
{ name: 'sentAt', type: 'datetime', title: 'Sent At' },
],
}Add the homepage schema
Create schemaTypes/homePage.js. The Home Page does not get its own SEO fields — it uses the global SEO Settings document for everything. The seoNote field communicates this directly to the client inside Studio.
schemaTypes/homePage.js
export default {
name: 'homePage',
type: 'document',
title: 'Home Page',
fields: [
{
name: 'heading',
type: 'string',
title: 'Heading',
initialValue: 'Welcome to Our Website',
},
{
name: 'subheading',
type: 'string',
title: 'Subheading',
initialValue: 'We build custom websites that convert leads into customers.',
},
{
name: 'body',
type: 'text',
title: 'Body',
initialValue:
'This is a placeholder to test your Sanity connection ' +
'and visual editing preview are working. ' +
'Replace with real content once confirmed working.',
},
],
}Add the navigation schema
Create schemaTypes/navigation.js. Each link has a label, URL, and an isButton toggle — check it on whichever link should render as the CTA button.
schemaTypes/navigation.js
export default {
name: 'navigation',
type: 'document',
title: 'Navigation',
preview: {
prepare() {
return { title: 'Navigation' }
},
},
fields: [
{
name: 'logo',
type: 'image',
title: 'Logo',
options: { hotspot: true },
},
{
name: 'navLinks',
type: 'array',
title: 'Navigation Links',
of: [
{
type: 'object',
fields: [
{
name: 'label',
type: 'string',
title: 'Label',
},
{
name: 'url',
type: 'string',
title: 'URL',
description: 'e.g. /about or https://example.com',
},
{
name: 'isButton',
type: 'boolean',
title: 'Show as Button',
description: 'Check to display this link as a CTA button',
initialValue: false,
},
],
preview: {
select: {
title: 'label',
subtitle: 'url',
isButton: 'isButton',
},
prepare({ title, subtitle, isButton }) {
return {
title: isButton ? title + ' [BUTTON]' : title,
subtitle,
}
},
},
},
],
},
],
}In Studio, create a Navigation document, add your links, and check Show as Button on the CTA link.
Add the SEO Settings schema
Create schemaTypes/seoSettings.js. This is the global SEO document — site name, default title, description, OG image, Schema.org type, contact info, and service areas. The Home Page uses this directly. All other pages build on top of it.
schemaTypes/seoSettings.js
export default {
name: 'seoSettings',
type: 'document',
title: 'SEO Settings',
fields: [
// ── Site Identity ──────────────────────────────────────────────────────
{
name: 'siteName',
type: 'string',
title: 'Site Name',
description: 'Business name as it appears in the title tag. e.g. "Acme Cleaning Co."',
},
{
name: 'siteUrl',
type: 'url',
title: 'Site URL',
description: 'Production URL with https. e.g. "https://www.acmecleaning.com"',
},
{
name: 'titleTemplate',
type: 'string',
title: 'Title Template',
description: 'Use %s as the page title placeholder. e.g. "%s | Acme Cleaning Co."',
initialValue: '%s | Site Name',
},
// ── Default Meta ───────────────────────────────────────────────────────
{
name: 'defaultTitle',
type: 'string',
title: 'Default Title',
description: 'Homepage title and fallback for pages with no custom title. 50-60 chars.',
},
{
name: 'defaultDescription',
type: 'text',
title: 'Default Description',
rows: 3,
description: 'Fallback meta description site-wide. 150-160 chars.',
},
{
name: 'keywords',
type: 'array',
title: 'Keywords',
description: 'Primary site keywords. 5-10 is plenty.',
of: [{ type: 'string' }],
options: { layout: 'tags' },
},
{
name: 'ogImage',
type: 'image',
title: 'Default OG Image',
description: 'Social share image. 1200x630px. Used on all pages unless overridden.',
options: { hotspot: true },
},
// ── Social ────────────────────────────────────────────────────────────
{
name: 'twitterHandle',
type: 'string',
title: 'Twitter / X Handle',
description: 'Include the @. Leave blank if client has no presence.',
},
// ── Schema.org ────────────────────────────────────────────────────────
{
name: 'schemaType',
type: 'string',
title: 'Schema Type',
description: 'Organization for non-physical businesses. LocalBusiness for everyone else.',
options: {
list: [
{ title: 'Organization', value: 'Organization' },
{ title: 'LocalBusiness', value: 'LocalBusiness' },
{ title: 'Restaurant', value: 'Restaurant' },
{ title: 'HomeAndConstructionBusiness', value: 'HomeAndConstructionBusiness' },
{ title: 'CleaningService', value: 'CleaningService' },
{ title: 'Photographer', value: 'Photographer' },
{ title: 'InsuranceAgency', value: 'InsuranceAgency' },
],
layout: 'radio',
},
initialValue: 'LocalBusiness',
},
{
name: 'phone',
type: 'string',
title: 'Phone Number',
description: 'e.g. 715-000-0000',
},
{
name: 'email',
type: 'string',
title: 'Email Address',
},
{
name: 'address',
type: 'object',
title: 'Address',
fields: [
{ name: 'street', type: 'string', title: 'Street Address' },
{ name: 'city', type: 'string', title: 'City' },
{ name: 'state', type: 'string', title: 'State', initialValue: 'WI' },
{ name: 'zip', type: 'string', title: 'ZIP Code' },
],
},
{
name: 'serviceAreas',
type: 'array',
title: 'Service Areas',
description: 'Cities or counties served. Used in schema areaServed.',
of: [{ type: 'string' }],
options: { layout: 'tags' },
},
{
name: 'priceRange',
type: 'string',
title: 'Price Range',
description: 'For LocalBusiness schema. $, $$, $$$, or $$$$',
options: { list: ['$', '$$', '$$$', '$$$$'], layout: 'radio' },
},
],
preview: {
select: { title: 'siteName', subtitle: 'siteUrl' },
},
}Register all four schemas in schemaTypes/index.js:
schemaTypes/index.js
import contactForm from './contactForm' import homePage from './homePage' import navigation from './navigation' import seoSettings from './seoSettings' export const schemaTypes = [contactForm, homePage, navigation, seoSettings]
In Studio, create an SEO Settings document and fill in the site name, URL, default title, and description before launch.
Fill out .env.local (Next.js project)
Copy .env.template to .env.local and fill in all values. Generate tokens at sanity.io/manage → your project → API → Tokens.
# Sanity (server-side) SANITY_PROJECT_ID=your_project_id # Sanity (public) NEXT_PUBLIC_SANITY_PROJECT_ID=your_project_id NEXT_PUBLIC_SANITY_DATASET=production NEXT_PUBLIC_SANITY_STUDIO_URL=http://localhost:3333 # prod: https://your-project-name.sanity.studio # Sanity tokens SANITY_API_TOKEN= # Editor permissions — for form submissions SANITY_VIEWER_TOKEN= # Viewer permissions — for draft mode/visual editing # Email EMAIL_PASS= EMAIL_USER= CLIENT_EMAIL=
Fill out .env (Sanity Studio project)
The Studio project needs its own env file for the preview origin. This lets Vercel-deployed Studio point at the live domain without hardcoding it.
# Sanity Studio project .env (separate from Next.js) SANITY_STUDIO_PREVIEW_ORIGIN=https://your-production-domain.com
For local dev, SANITY_STUDIO_PREVIEW_ORIGIN falls back to http://localhost:3000 automatically via the config fallback — you only need this set in production.
Start both dev servers
Terminal 1 — Next.js
npm run dev
Terminal 2 — Sanity Studio
npm run dev
Next.js runs on localhost:3000 — Studio on localhost:3333. Open the Studio, click Presentation (now labeled Live Preview), and your site should load with clickable overlays.
Test draft mode
The draft mode route is already in the starter at app/api/draft-mode/enable/route.js. Just make sure SANITY_VIEWER_TOKEN is set in .env.local, then hit this URL to confirm it's working:
http://localhost:3000/api/draft-mode/enable
You should be redirected to / with ?sanity-preview-perspective=drafts in the URL.
Configure Desk Structure
Set up structure.js based on the pages and singletons this project needs. This is manual and changes per project — there is no universal template. Define singleton documents (Home Page, Navigation, etc.) so they open directly without a list view, and group the rest logically. Wire it into sanity.config.js via the structureTool config option.
- Set color palette in
tailwind.config.js - Configure navigation in
data/navigationLinks.js - Choose fonts and update metadata in
layout.js - Replace logo in
/public/images/business-name - Review and update Privacy Policy
- Test contact form
Hero Title
Hero Title
H1
Heading 1
H2
Heading 2
H3
Heading 3
H4
Heading 4
H5
Heading 5
H6
Heading 6
Overline
OVERLINE TEXT
Subheading
This is a subheading for additional context
Paragraph
This is body text for longer paragraphs and general content. It is designed for readability and comfortable reading at various screen sizes.
Caption
This is caption text for small details
Button Text
Color Palette
Primary
#025088
bg-primary
Secondary
#35B3E4
bg-secondary
Tertiary
bg-tertiary
Accent
#DAEAEF
bg-accent
Dark
#001A2C
bg-dark
Light
#F1F1F1
bg-light
Spacing Scale
Space 0
0
p-0 or m-0
Space 1
1rem
p-1 or m-1
Space 2
2rem
p-2 or m-2
Space 3
3rem
p-3 or m-3
Space 4
4rem
p-4 or m-4
Space 5
5rem
p-5 or m-5
Space 6
6rem
p-6 or m-6
Space 8
8rem
p-8 or m-8
Space 10
10rem
p-10 or m-10
Space 12
12rem
p-12 or m-12
Space 16
16rem
p-16 or m-16
Space 22
22rem
p-22 or m-22
Space 24
24rem
p-24 or m-24
Space 32
32rem
p-32 or m-32
Space 0.25
0.25rem
p-0.25 or m-0.25
Space 0.5
0.5rem
p-0.5 or m-0.5
Space 0.75
0.75rem
p-0.75 or m-0.75
Space 1.25
1.25rem
p-1.25 or m-1.25
Space 1.5
1.5rem
p-1.5 or m-1.5
Space 2.5
2.5rem
p-2.5 or m-2.5
Space 3.75
3.75rem
p-3.75 or m-3.75
Space 5.5
5.5rem
p-5.5 or m-5.5
Space 7.5
7.5rem
p-7.5 or m-7.5
Space 8.75
8.75rem
p-8.75 or m-8.75
Space none
0
p-none or m-none
Space xxs
1rem
p-xxs or m-xxs
Space xs
1.5rem
p-xs or m-xs
Space sm
2rem
p-sm or m-sm
Space md
2.5rem
p-md or m-md
Space lg
4rem
p-lg or m-lg
Space xl
5rem
p-xl or m-xl
Space xxl
8.75rem
p-xxl or m-xxl