Sanity CMS Setup

Follow these steps when connecting a new project to Sanity.

1

Create a new Sanity project

Run this in a separate folder from your Next.js project.

terminal
npm create sanity@latest

Follow the prompts. Choose production as your dataset. Note your Project ID — you will need it for env vars.

2

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)

sanity themer url
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
sanity.config.js
/* 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.

3

Add custom Studio branding

Create a components/ folder inside your Sanity Studio project and add these two files.

components/clientLogo.jsx

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 clientLogo

Drop 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

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}
          >
            &#9432;
          </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.

4

Add the contact form schema

Create schemaTypes/contactForm.js in your Studio project.

schemaTypes/contactForm.js

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' },
  ],
}
5

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

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.

6

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

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

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.

7

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.

.env.local
# 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=
8

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/.env
# 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.

9

Start both dev servers

Terminal 1 — Next.js

terminal
npm run dev

Terminal 2 — Sanity Studio

terminal
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.

10

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:

browser
http://localhost:3000/api/draft-mode/enable

You should be redirected to / with ?sanity-preview-perspective=drafts in the URL.

11

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.

1

Create a new Sanity project

Run this in a separate folder from your Next.js project.

terminal
npm create sanity@latest

Follow the prompts. Choose production as your dataset. Note your Project ID — you will need it for env vars.

2

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)

sanity themer url
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
sanity.config.js
/* 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.

3

Add custom Studio branding

Create a components/ folder inside your Sanity Studio project and add these two files.

components/clientLogo.jsx

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 clientLogo

Drop 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

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}
          >
            &#9432;
          </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.

4

Add the contact form schema

Create schemaTypes/contactForm.js in your Studio project.

schemaTypes/contactForm.js

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' },
  ],
}
5

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

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.',
    },
  ],
}
6

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

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.

7

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

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

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.

8

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.

.env.local
# 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=
9

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/.env
# 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.

10

Start both dev servers

Terminal 1 — Next.js

terminal
npm run dev

Terminal 2 — Sanity Studio

terminal
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.

11

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:

browser
http://localhost:3000/api/draft-mode/enable

You should be redirected to / with ?sanity-preview-perspective=drafts in the URL.

12

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.

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

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