- How to Programmatically Import Markdown Blog Articles to Sanity CMS
How to Programmatically Import Markdown Blog Articles to Sanity CMS
Build a CLI script that transforms frontmatter-powered markdown into structured Sanity documents

I was migrating a client's blog from a static site generator to Sanity CMS when I realized I'd be copying and pasting dozens of markdown articles manually. After spending hours researching the Sanity API and testing different approaches, I built a CLI script that automatically transforms markdown files with frontmatter into properly structured Sanity documents. This guide shows you exactly how to create your own markdown import tool that handles everything from basic posts to complex How-To guides with FAQs.
The Problem with Manual Content Migration
Moving content between systems typically involves tedious manual work. You have markdown files with frontmatter containing all your metadata, but Sanity expects structured documents with specific field mappings. Copying content manually means losing time and introducing errors, especially when you need to create related documents like FAQ items that reference back to your main posts.
The solution I developed parses YAML frontmatter, maps fields to your Sanity schema, handles complex content types, and provides proper error handling for production use. By the end of this guide, you'll have a robust CLI tool that transforms markdown files into Sanity documents with a single command.
Setting Up the Foundation
The script requires gray-matter to parse frontmatter and the Sanity client for API operations. Start by installing the necessary dependency in your existing Sanity project.
# Install gray-matter for frontmatter parsing
pnpm add gray-matter
Next, create the scripts directory structure to organize your automation tools.
# Create scripts directory
mkdir -p scripts
This approach keeps your CLI tools separate from your main application code while making them easily accessible through package.json scripts. The gray-matter package specifically handles the complex task of separating YAML frontmatter from markdown content, which forms the core of our parsing logic.
Building the Core Script Structure
The script needs TypeScript interfaces that match your Sanity schema and handle the various content types you'll encounter. Create the main script file with comprehensive type definitions.
// File: scripts/push-markdown.ts
#!/usr/bin/env ts-node
import { createClient } from '@sanity/client'
import * as fs from 'fs'
import * as path from 'path'
import matter from 'gray-matter'
import dotenv from 'dotenv'
dotenv.config()
interface FrontMatter {
title?: string
subtitle?: string
excerpt?: string
metaDescription?: string
slug?: string
authorId?: string
publishedAt?: string
categories?: string[]
keywords?: string[]
isHowTo?: boolean
difficulty?: 'beginner' | 'intermediate' | 'advanced' | 'expert'
tools?: string[]
totalTime?: string
rating?: number
faqs?: FAQ[]
steps?: HowToStep[]
}
interface FAQ {
question: string
answer: string
category?: 'general' | 'technical' | 'pricing' | 'support'
order?: number
}
interface HowToStep {
name: string
text: string
url?: string
}
These interfaces define the expected structure of your frontmatter and ensure type safety throughout the script. The FrontMatter interface maps directly to your Sanity post schema, while FAQ and HowToStep interfaces handle the more complex content types. Notice how the interfaces are optional for most fields, allowing flexibility in your markdown files while enforcing required fields through validation.
Implementing Sanity Client Integration
The script needs robust connection handling and environment variable validation to work reliably across different environments. Add the client setup with proper error handling.
// File: scripts/push-markdown.ts (continued)
function calculateReadingTime(content: string): number {
const words = content.trim().split(/\s+/).length
return Math.ceil(words / 200)
}
function generateSlugFromFilename(filename: string): string {
return path.basename(filename, '.md')
.toLowerCase()
.replace(/\s+/g, '-')
.replace(/[^a-z0-9-]/g, '')
}
async function pushMarkdownToSanity(filePath: string) {
try {
if (!fs.existsSync(filePath)) {
console.error(`❌ File not found: ${filePath}`)
process.exit(1)
}
const projectId = process.env.NEXT_PUBLIC_SANITY_PROJECT_ID
const dataset = process.env.NEXT_PUBLIC_SANITY_DATASET
const token = process.env.SANITY_API_TOKEN
if (!projectId || !dataset || !token) {
console.error('❌ Missing required environment variables:')
if (!projectId) console.error(' - NEXT_PUBLIC_SANITY_PROJECT_ID')
if (!dataset) console.error(' - NEXT_PUBLIC_SANITY_DATASET')
if (!token) console.error(' - SANITY_API_TOKEN')
console.error('\nAdd these to your .env file or set them as environment variables.')
process.exit(1)
}
const client = createClient({
projectId,
dataset,
useCdn: false,
token,
apiVersion: '2023-05-03'
})
const fileContent = fs.readFileSync(filePath, 'utf8')
const { data: frontmatter, content: markdownContent } = matter(fileContent)
const typedFrontmatter = frontmatter as FrontMatter
This setup validates all required environment variables before attempting any API calls, preventing confusing error messages later. The client configuration uses useCdn: false
because we're writing data, not just reading it. The gray-matter library cleanly separates the frontmatter from the markdown content, giving us structured data to work with.
Adding Comprehensive Validation
Content validation prevents incomplete or malformed documents from being created in Sanity. Implement field validation that matches your schema requirements.
// File: scripts/push-markdown.ts (continued)
const requiredFields: (keyof FrontMatter)[] = ['title', 'authorId', 'publishedAt']
const missingFields = requiredFields.filter(field => !typedFrontmatter[field])
if (missingFields.length > 0) {
console.error(`❌ Missing required fields: ${missingFields.join(', ')}`)
console.error('Required fields: title, authorId, publishedAt')
process.exit(1)
}
const slug = typedFrontmatter.slug || generateSlugFromFilename(filePath)
console.log(`📝 Processing: ${path.basename(filePath)}`)
console.log(`🔗 Slug: ${slug}`)
The validation uses TypeScript's keyof operator to ensure type safety when checking required fields. If any required fields are missing, the script exits early with a clear error message. The slug generation fallback uses the filename when no explicit slug is provided, ensuring every document has a unique identifier.
Creating FAQ Documents and References
FAQ items are separate documents in Sanity that get referenced by posts. This approach allows FAQs to be reused across multiple articles and managed independently.
// File: scripts/push-markdown.ts (continued)
async function createFAQItems(client: any, faqs: FAQ[], slug: string): Promise<string[]> {
const faqIds: string[] = []
for (let i = 0; i < faqs.length; i++) {
const faq = faqs[i]
const faqId = `faq-${slug}-${i + 1}`
try {
const faqDoc = {
_id: faqId,
_type: 'faqItem',
question: faq.question,
answer: [
{
_type: 'block',
_key: 'answer-block',
style: 'normal',
children: [
{
_type: 'span',
_key: 'answer-span',
text: faq.answer
}
]
}
],
category: faq.category || 'general',
order: faq.order || i
}
await client.createOrReplace(faqDoc)
faqIds.push(faqId)
console.log(`✅ Created FAQ: "${faq.question}")`)
} catch (error) {
console.error(`❌ Failed to create FAQ: ${faq.question}`, error)
}
}
return faqIds
}
The FAQ creation function generates unique IDs based on the post slug and FAQ index, ensuring no conflicts between posts. Each FAQ answer is converted to Sanity's block content format, which allows for rich text formatting in the Sanity Studio interface. The function returns the created FAQ IDs so they can be referenced in the main post document.
Handling How-To Steps as Embedded Objects
How-To steps are embedded directly within posts rather than being separate documents. This structure makes them easier to manage as part of the article content while still providing structured schema markup benefits.
// File: scripts/push-markdown.ts (continued)
function parseHowToSteps(steps: HowToStep[]): any[] {
return steps.map((step, index) => ({
_type: 'howToStep',
_key: `step-${index + 1}`,
name: step.name,
text: [
{
_type: 'block',
_key: `step-${index + 1}-block`,
style: 'normal',
children: [
{
_type: 'span',
_key: `step-${index + 1}-span`,
text: step.text
}
]
}
],
...(step.url && { url: step.url })
}))
}
// In the main function, add FAQ and step processing:
// Handle FAQs if provided
let faqReferences: any[] = []
if (typedFrontmatter.faqs && typedFrontmatter.faqs.length > 0) {
console.log(`📝 Creating ${typedFrontmatter.faqs.length} FAQ items...`)
const faqIds = await createFAQItems(client, typedFrontmatter.faqs, slug)
faqReferences = faqIds.map(id => ({
_type: 'reference',
_ref: id
}))
}
// Handle How-To steps if provided
let howToSteps: any[] = []
if (typedFrontmatter.isHowTo && typedFrontmatter.steps && typedFrontmatter.steps.length > 0) {
console.log(`📋 Creating ${typedFrontmatter.steps.length} How-To steps...`)
howToSteps = parseHowToSteps(typedFrontmatter.steps)
}
This approach creates the FAQ documents first, then processes the How-To steps as embedded objects. The step parsing converts simple text instructions into Sanity's block content format, maintaining consistency with how other rich text content is stored. The conditional URL inclusion demonstrates how optional fields are handled cleanly.
Building the Complete Document Structure
The final document creation combines all the parsed data into a structure that matches your Sanity schema. This includes handling both new document creation and updates to existing documents.
// File: scripts/push-markdown.ts (continued)
const existingPostQuery = `*[_type == "post" && slug.current == $slug][0]{_id}`
const existingPost = await client.fetch(existingPostQuery, { slug })
const postData = {
_type: 'post',
title: typedFrontmatter.title!,
subtitle: typedFrontmatter.subtitle || '',
excerpt: typedFrontmatter.excerpt || '',
metaDescription: typedFrontmatter.metaDescription || '',
slug: {
_type: 'slug',
current: slug
},
author: {
_type: 'reference',
_ref: typedFrontmatter.authorId!
},
publishedAt: typedFrontmatter.publishedAt!,
dateModified: new Date().toISOString(),
categories: typedFrontmatter.categories?.map(id => ({
_type: 'reference',
_ref: id
})) || [],
keywords: typedFrontmatter.keywords || [],
estimatedReadingTime: calculateReadingTime(markdownContent),
claps: 0,
markdownContent: markdownContent.trim(),
...(typedFrontmatter.isHowTo !== undefined && { isHowTo: typedFrontmatter.isHowTo }),
...(typedFrontmatter.difficulty && { difficulty: typedFrontmatter.difficulty }),
...(typedFrontmatter.tools && { tools: typedFrontmatter.tools }),
...(typedFrontmatter.totalTime && { totalTime: typedFrontmatter.totalTime }),
...(typedFrontmatter.rating && { rating: typedFrontmatter.rating }),
...(faqReferences.length > 0 && { faq: faqReferences }),
...(howToSteps.length > 0 && { steps: howToSteps })
}
let result
if (existingPost) {
console.log(`🔄 Updating existing post...`)
result = await client
.patch(existingPost._id)
.set(postData)
.commit()
console.log(`✅ Updated post: ${result._id}`)
} else {
console.log(`✨ Creating new post as draft...`)
const draftId = `drafts.${slug}-${Date.now()}`
result = await client.create({
...postData,
_id: draftId
})
console.log(`✅ Created draft post: ${result._id}`)
}
The document structure uses Sanity's reference format for relationships and includes optional field spreading to avoid undefined values. The update-or-create logic checks for existing posts by slug, making the script idempotent. New posts are created as drafts with timestamp-based IDs, allowing you to add images manually before publishing.
Adding CLI Integration and Error Handling
The final step makes the script accessible as a command-line tool with proper argument handling and helpful usage information.
// File: scripts/push-markdown.ts (continued)
async function main() {
const args = process.argv.slice(2)
if (args.length === 0) {
console.log(`📝 Push Markdown to Sanity`)
console.log(`Usage: pnpm run push-md <path-to-markdown-file>`)
console.log(`Example: pnpm run push-md ./content/posts/my-post.md`)
console.log(`Required frontmatter fields:`)
console.log(` - title: "Post Title"`)
console.log(` - authorId: "author-reference-id"`)
console.log(` - publishedAt: "2025-08-11T12:00:00Z"`)
console.log(`Optional frontmatter fields:`)
console.log(` - faqs: [{question: "Q?", answer: "A.", category: "general"}]`)
console.log(` - steps: [{name: "Step 1", text: "Do this...", url: "https://..."}]`)
process.exit(0)
}
const filePath = path.resolve(args[0])
await pushMarkdownToSanity(filePath)
}
main()
Add the script command to your package.json file to make it easily executable:
// File: package.json
{
"scripts": {
"push-md": "ts-node scripts/push-markdown.ts"
}
}
The CLI integration provides helpful usage information when no arguments are provided and resolves file paths properly regardless of where the command is run from. This makes the tool user-friendly and prevents common path-related errors.
Testing Your Implementation
Create a sample markdown file to verify everything works correctly with various content types:
# File: content/posts/example-post.md
---
title: "Sample How-To Guide"
authorId: "your-author-id"
publishedAt: "2025-08-11T12:00:00Z"
isHowTo: true
difficulty: "intermediate"
faqs:
- question: "Does this really work?"
answer: "Yes, it processes all content types correctly."
category: "technical"
steps:
- name: "First step"
text: "This demonstrates step parsing"
- name: "Second step"
text: "This includes a reference URL"
url: "https://example.com"
---
# Your markdown content here
This content will be imported along with all the structured metadata.
Run the import command to test your implementation:
pnpm run push-md ./content/posts/example-post.md
The script will create FAQ documents first, then the main post with all embedded How-To steps and references properly structured. You can then open Sanity Studio to add images and publish the imported content.
Conclusion
This CLI script solves the tedious problem of manually migrating markdown content to Sanity CMS by automating the entire import process. The solution parses YAML frontmatter, creates complex document relationships, and handles both simple posts and advanced content types like How-To guides with FAQs. You now have a robust tool that transforms markdown files into properly structured Sanity documents with a single command, saving hours of manual work and eliminating migration errors.
The script handles validation, error cases, and provides clear feedback throughout the import process, making it suitable for production use. Whether you're migrating existing content or establishing a workflow for future articles, this approach streamlines content management while preserving the flexibility of markdown authoring.
Let me know in the comments if you have questions about extending the script for your specific schema, and subscribe for more practical development guides.
Thanks, Matija