Skip to main content
YBH Pulse Content uses Sanity.io as its headless CMS for storing episodes, guest profiles, generated content, and social media assets.

Project setup

1

Create Sanity project

If you don’t have a Sanity project yet:
npm install -g @sanity/cli
sanity init
Note your project ID and dataset name.
2

Configure environment variables

Add to .env (client-side):
VITE_SANITY_PROJECT_ID=your-project-id
VITE_SANITY_DATASET=production
Add to .dev.vars (server-side):
SANITY_API_TOKEN=your-editor-token
3

Generate API token

  1. Go to https://sanity.io/manage
  2. Select your project
  3. Navigate to API > Tokens
  4. Create a token with Editor permissions
  5. Copy the token to SANITY_API_TOKEN
4

Deploy schemas

npm run sanity:deploy

Schema structure

Pulse Content uses four primary schemas in Sanity:

Episode content

Document type: episodeContent Stores all generated content for each episode:
  • Transcript (raw text)
  • PRF document (HTML)
  • Viral hooks (HTML)
  • Social posts (LinkedIn, Instagram, Twitter)
  • Generated assets (infographics, quote cards, thumbnails)
  • Episode metadata (title, guest name, episode number, topics, summary)
  • Sanity page metadata (for podcast website)
  • Visual suggestions (AI-generated design ideas)
  • Brand kit assets (curated assets for sharing)
  • Video assets (square and vertical clips)
  • Scheduled posts (Late API tracking)
  • Approval status and timestamps
  • Fact-check results and verified facts bank

Guest

Document type: guest Guest directory with contact information:
  • Name, position, company
  • Email, mobile, address
  • LinkedIn URL and follower count
  • Circle community membership
  • Status (booked, recorded, in production, complete)
  • Episode references
  • Share links
  • Notes
  • Transcript upload tracking

Guest profile

Document type: guestProfile Cached LinkedIn data to avoid repeated API calls:
  • Name and headline
  • LinkedIn URL
  • Professional summary
  • Career history (positions, companies, dates, descriptions)
  • Scraped timestamp (cached for 24 hours)

User

Document type: user Team authentication:
  • Email (must be @popularit.net)
  • Password hash
  • Created and updated timestamps

API access patterns

Client-side (read-only)

All client-side requests go through /api/sanity/* proxy endpoints. The Sanity API token is never exposed to the browser.
import { fetchEpisodes, fetchEpisode } from '@/services/sanity'

// List all episodes
const episodes = await fetchEpisodes()

// Get single episode
const episode = await fetchEpisode('episode-id')

Server-side (read/write)

Server-side functions access Sanity directly using the editor token:
import { createClient } from '@sanity/client'

const client = createClient({
  projectId: env.VITE_SANITY_PROJECT_ID,
  dataset: env.VITE_SANITY_DATASET,
  token: env.SANITY_API_TOKEN,
  useCdn: false,
  apiVersion: '2024-03-01',
})

// Create document
await client.create({
  _type: 'episodeContent',
  transcript: 'Episode transcript...',
  status: 'draft',
})

// Update document
await client.patch('episode-id')
  .set({ prf: 'PRF content...' })
  .commit()

// Query documents
const episodes = await client.fetch(
  `*[_type == "episodeContent"] | order(_createdAt desc)`
)

Data flow

Episode creation flow

1

Upload transcript

User uploads transcript file or pulls from Google Drive.
2

Parse metadata

System extracts episode number, guest name, and LinkedIn URL from transcript header:
385-Chris Pacifico
Host: Phil Howard
Guest: Chris Pacifico
https://www.linkedin.com/in/chris-pacifico/
3

Create episode document

const episode = await createEpisode({
  transcript,
  guestId, // Optional
})
4

Auto-generate PRF

AI generates Podcast Repurposing Framework document from transcript.
5

Auto-generate hooks

AI generates viral hooks from PRF and transcript.
6

Generate on demand

  • Episode metadata (title, descriptions, takeaways, show notes)
  • LinkedIn posts
  • Instagram posts
  • Visual suggestions
  • Career timeline

Guest profile caching

1

Check Sanity cache

Look for existing guestProfile by LinkedIn URL.
2

Check cache freshness

If cached profile exists and is less than 24 hours old, use it.
3

Scrape LinkedIn

If cache is stale or missing, fetch fresh data from RapidAPI.
4

Update cache

Upsert guestProfile with new data and timestamp.
5

Link to episode

Store guest reference in episodeContent.guestRef.

Content production workflow

Document linking

Episodes and guests are linked via references:
// Episode content references guest
{
  _type: 'episodeContent',
  guestRef: {
    _type: 'reference',
    _ref: 'guest-document-id',
  },
}

// Guest references episodes
{
  _type: 'guest',
  episodeRefs: [
    { _type: 'reference', _ref: 'episode-1-id' },
    { _type: 'reference', _ref: 'episode-2-id' },
  ],
}

Status tracking

Episode status values:
  • draft - Initial state after upload
  • in_progress - Generating content
  • running - Active generation process
  • complete - All content generated
  • partial - Some content generated
  • failed - Generation failed
  • degraded - Generation completed with warnings

Approval workflow

Content requires approval before sharing:
// PRF approval
await updateEpisode(episodeId, {
  prfApproved: true,
  prfApprovedAt: new Date().toISOString(),
})

// Hooks approval
await updateEpisode(episodeId, {
  hooksApproved: true,
  hooksApprovedAt: new Date().toISOString(),
})

// LinkedIn posts approval
await updateEpisode(episodeId, {
  linkedinApproved: true,
  linkedinApprovedAt: new Date().toISOString(),
})

// Visual asset approval
const suggestions = episode.visualSuggestions?.map(s => ({
  ...s,
  status: s._key === selectedKey ? 'approved' : s.status,
  approvedAt: s._key === selectedKey ? new Date().toISOString() : s.approvedAt,
}))

await updateEpisode(episodeId, { visualSuggestions: suggestions })

Brand kit curation

1

Select assets

Team curates 4-10 assets for guest brand kit from approved visuals.
2

Add to brand kit

await updateEpisode(episodeId, {
  brandKitAssets: [
    {
      type: 'infographic',
      imageUrl: 'https://...',
      title: 'Key Insight',
      caption: 'Share this on LinkedIn',
      order: 1,
      addedAt: new Date().toISOString(),
    },
  ],
})
3

Lock brand kit

When finalized:
await updateEpisode(episodeId, {
  brandKitLocked: true,
  brandKitLockedAt: new Date().toISOString(),
})
4

Generate share link

Creates public URL: /share/{episodeNumber}-{token}

Sanity Studio

Manage content directly in Sanity Studio:
# Start Sanity Studio locally
npm run sanity:dev

# Deploy Sanity Studio
npm run sanity:deploy
Access at: https://your-project.sanity.studio/
Pulse Content is the primary interface for content production. Use Sanity Studio only for administrative tasks or manual data corrections.

Database maintenance

Backup episodes

sanity dataset export production backup.tar.gz

Import episodes

sanity dataset import backup.tar.gz production

Clean up old drafts

*[_type == "episodeContent" && status == "draft" && _createdAt < "2024-01-01"]

Common queries

Recent episodes

*[_type == "episodeContent"] | order(metadata.episodeNumber desc) [0...10] {
  _id,
  metadata,
  status,
  prfApproved,
  hooksApproved,
  linkedinApproved,
}

Episodes by guest

*[_type == "episodeContent" && references($guestId)] {
  _id,
  metadata,
  guestRef->,
}

Approved assets ready for sharing

*[_type == "episodeContent" && prfApproved == true && hooksApproved == true && linkedinApproved == true] {
  _id,
  metadata,
  brandKitAssets,
  shareToken,
}

Performance optimization

Use CDN for reads

Enable CDN for read-only queries:
const client = createClient({
  projectId: env.VITE_SANITY_PROJECT_ID,
  dataset: env.VITE_SANITY_DATASET,
  useCdn: true, // Enable CDN for faster reads
  apiVersion: '2024-03-01',
})

Disable CDN for writes

Always use useCdn: false for mutations:
const client = createClient({
  projectId: env.VITE_SANITY_PROJECT_ID,
  dataset: env.VITE_SANITY_DATASET,
  token: env.SANITY_API_TOKEN,
  useCdn: false, // Disable CDN for writes
  apiVersion: '2024-03-01',
})

Batch updates

Use transactions for multiple related updates:
const transaction = client.transaction()
transaction
  .patch(episodeId, { set: { prfApproved: true } })
  .patch(episodeId, { set: { hooksApproved: true } })
  .commit()

Troubleshooting

Connection errors

Verify project ID and dataset:
echo $VITE_SANITY_PROJECT_ID
echo $VITE_SANITY_DATASET

Permission denied

Ensure API token has Editor role:
  1. Go to https://sanity.io/manage
  2. Select project > API > Tokens
  3. Check token permissions
  4. Regenerate if needed

Data not updating

Check CDN cache:
  • Use useCdn: false for real-time data
  • CDN cache can take up to 60 seconds to update

Schema validation errors

Redeploy schemas:
npm run sanity:deploy

Build docs developers (and LLMs) love