DEV Community

Cover image for How to Migrate from Sanity to Strapi: Complete Step-by-Step Guide
Theodore Kelechukwu Onyejiaku for Strapi

Posted on • Originally published at strapi.io

How to Migrate from Sanity to Strapi: Complete Step-by-Step Guide

Introduction

Sanity and Strapi are both headless CMSs, but that's where the similarities end. Moving from Sanity's schema-first approach with GROQ queries to Strapi's collection-based CMS with built-in admin panels isn't as straightforward as exporting and importing data.

Brief Summary

This guide covers the complete migration process from Sanity to Strapi using real-world examples.

We'll work through migrating a typical blog and e-commerce setup with posts, authors, categories, pages, and products to show you what a real Sanity-to-Strapi migration actually looks like.

Goal

By the end of this tutorial, readers will have successfully migrated a complete Sanity project to Strapi, including all content types, entries, relationships, and media assets.

The tutorial provides practical experience with headless CMS migrations and establishes best practices for maintaining data integrity.

Understanding Sanity vs Strapi

Key Architectural Differences

Sanity and Strapi are both headless CMSs, but that's where the similarities end. Here are the fundamental differences:

Sanity's Approach:

  • Schema-first with JavaScript/TypeScript schema definitions
  • GROQ queries for data fetching
  • Real-time collaboration and live preview
  • Document-based content structure
  • Sanity Studio for content editing

Strapi's Approach:

  • Collection-based CMS with JSON schema definitions
  • REST/GraphQL APIs for data access
  • Plugin ecosystem and built-in admin panels
  • Relational database approach
  • Built-in admin interface

When to Consider Migration

Based on recent migration requests, the reasons usually fall into these categories:

  • Team familiarity: Your team is more comfortable with traditional CMS structures
  • Plugin ecosystem: Strapi's extensive plugin library matches your needs better
  • Self-hosting requirements: You need full control over your infrastructure
  • Cost considerations: Different pricing models might work better for your scale
  • Integration needs: Your tech stack aligns better with Strapi's architecture

Sanity to Strapi Migration Benefits and Challenges

Benefits:

  • More traditional CMS experience for content editors
  • Extensive plugin ecosystem
  • Built-in user management and permissions
  • Self-hosting flexibility
  • Strong REST and GraphQL APIs

Challenges:

  • Schema structures require complete transformation
  • Query patterns change from GROQ to REST/GraphQL
  • Asset handling uses different CDN approaches
  • Relationship resolution works completely differently
  • Custom functionality needs rebuilding

Prerequisites

Before starting this migration, ensure you have:

Technical Requirements:

  • Node.js 18+ installed
  • Access to your Sanity project with admin permissions
  • Sanity CLI installed globally
  • Basic knowledge of both Sanity and Strapi
  • Command line familiarity

Required Tools:

# Install global CLI tools
npm install -g @sanity/cli @strapi/strapi
Enter fullscreen mode Exit fullscreen mode

Recommended Knowledge:

Migration Overview

This migration process consists of six main phases:

  1. Pre-Migration Assessment - Analyze your current setup and plan transformations
  2. Environment Setup - Prepare tools and create a fresh Strapi instance
  3. Data Export - Extract all content and assets from Sanity
  4. Schema Transformation - Convert Sanity schemas to Strapi content types
  5. Content Import - Migrate data while preserving relationships
  6. Testing & Deployment - Validate migration and update frontend integration

We'll use a custom CLI tool that automates much of the heavy lifting while providing detailed reports and error handling throughout the process.

Phase 1: Pre-Migration Assessment

Analyzing Your Current Sanity Setup

Before touching any code, we need to audit what we're working with. This step is critical for understanding the scope and complexity of your migration.

Content Type Inventory

First, let's examine your Sanity schemas. Navigate to your Sanity studio project and create an analysis script - ./listSchemas.ts:

// ./listSchemas.ts
import {createClient} from '@sanity/client'
import {schemaTypes} from './schemaTypes'

const client = createClient({
  projectId: 'your-project-id', // Replace with your project ID
  dataset: 'production', // or your dataset name
  useCdn: false,
  apiVersion: '2023-05-03',
})

console.log('Schema Analysis:')
console.log('================')

schemaTypes.forEach((schema) => {
  console.log(`\nSchema: ${schema.name}`)
  console.log(`Type: ${schema.type}`)

  if ('fields' in schema && schema.fields) {
    console.log('Fields:')
    schema.fields.forEach((field) => {
      console.log(`  - ${field.name}: ${field.type}`)

      const fieldAny = field as any

      if (fieldAny.of) {
        console.log(`    of: ${JSON.stringify(fieldAny.of, null, 4)}`)
      }
      if (fieldAny.to) {
        console.log(`    to: ${JSON.stringify(fieldAny.to, null, 4)}`)
      }
      if (fieldAny.options) {
        console.log(`    options: ${JSON.stringify(fieldAny.options, null, 4)}`)
      }
    })
  }
})

// Optional: Get document counts
async function getDocumentCounts() {
  console.log('\nDocument Counts:')
  console.log('================')

  for (const schema of schemaTypes) {
    try {
      const count = await client.fetch(`count(*[_type == "${schema.name}"])`)
      console.log(`${schema.name}: ${count} documents`)
    } catch (error) {
      const errorMessage = error instanceof Error? error.message : 'Unknown error'
      console.log(`${schema.name}: Error getting count - ${errorMessage}`)
    }
  }
}

getDocumentCounts()

Enter fullscreen mode Exit fullscreen mode

The code above inspects our Sanity schemaTypes by printing each schema’s name, type, and field details (including of, to, and options), then queries the Sanity API to log document counts per schema.

Run the analysis:

npx sanity exec listSchemas.ts --with-user-token
Enter fullscreen mode Exit fullscreen mode

We should have something like this:

001 run analysis.png

Identifying Content Types and Relationships

Next, we'll create a comprehensive relationship analyzer that works with any schema structure - ./analyzeRelationships.ts:

// ./analyzeRelationships.ts
import {createClient} from '@sanity/client'
import {schemaTypes} from './schemaTypes'

const client = createClient({
  projectId: 'your-project-id',
  dataset: 'production',
  useCdn: false,
  apiVersion: '2023-05-03'
})

interface RelationshipInfo {
  fieldName: string
  fieldType: string
  targetType?: string
  isArray: boolean
  isReference: boolean
  isAsset: boolean
}

interface SchemaAnalysis {
  typeName: string
  relationships: RelationshipInfo[]
  documentCount: number
}

async function analyzeRelationships() {
  console.log('Analyzing Content Relationships:')
  console.log('================================\n')

  try {
    const analysisResults: SchemaAnalysis[] = []

    for (const schema of schemaTypes) {
      const analysis = await analyzeSchemaType(schema)
      analysisResults.push(analysis)
    }

    generateRelationshipReport(analysisResults)
    await sampleContentAnalysis(analysisResults)

  } catch (error) {
    console.error('Error analyzing relationships:', error)
  }
}

async function analyzeSchemaType(schema: any): Promise<SchemaAnalysis> {
  const relationships: RelationshipInfo[] = []

  if (schema.type !== 'document') {
    return {
      typeName: schema.name,
      relationships: [],
      documentCount: 0
    }
  }

  const documentCount = await client.fetch(`count(*[_type == "${schema.name}"])`)

  if ('fields' in schema && schema.fields) {
    schema.fields.forEach((field: any) => {
      const relationshipInfo = analyzeField(field)
      if (relationshipInfo) {
        relationships.push(relationshipInfo)
      }
    })
  }

  return {
    typeName: schema.name,
    relationships,
    documentCount
  }
}

function analyzeField(field: any): RelationshipInfo | null {
  const fieldAny = field as any
  let relationshipInfo: RelationshipInfo | null = null

  if (field.type === 'reference') {
    relationshipInfo = {
      fieldName: field.name,
      fieldType: 'reference',
      targetType: fieldAny.to?.[0]?.type || 'unknown',
      isArray: false,
      isReference: true,
      isAsset: false
    }
  }
  else if (field.type === 'array') {
    const arrayItemType = fieldAny.of?.[0]
    if (arrayItemType?.type === 'reference') {
      relationshipInfo = {
        fieldName: field.name,
        fieldType: 'array of references',
        targetType: arrayItemType.to?.[0]?.type || 'unknown',
        isArray: true,
        isReference: true,
        isAsset: false
      }
    } else if (arrayItemType?.type === 'image' || arrayItemType?.type === 'file') {
      relationshipInfo = {
        fieldName: field.name,
        fieldType: `array of ${arrayItemType.type}`,
        isArray: true,
        isReference: false,
        isAsset: true
      }
    }
  }
  else if (field.type === 'image' || field.type === 'file') {
    relationshipInfo = {
      fieldName: field.name,
      fieldType: field.type,
      isArray: false,
      isReference: false,
      isAsset: true
    }
  }
  else if (field.type === 'object') {
    const nestedFields = fieldAny.fields || []
    const hasNestedAssets = nestedFields.some((f: any) => f.type === 'image' || f.type === 'file')
    const hasNestedReferences = nestedFields.some((f: any) => f.type === 'reference')

    if (hasNestedAssets || hasNestedReferences) {
      relationshipInfo = {
        fieldName: field.name,
        fieldType: 'object with nested relationships',
        isArray: false,
        isReference: hasNestedReferences,
        isAsset: hasNestedAssets
      }
    }
  }

  return relationshipInfo
}

function generateRelationshipReport(analyses: SchemaAnalysis[]) {
  console.log('RELATIONSHIP MAPPING SUMMARY:')
  console.log('=============================\n')

  analyses.forEach(analysis => {
    if (analysis.relationships.length === 0 && analysis.documentCount === 0) return

    console.log(`📋 ${analysis.typeName.toUpperCase()} (${analysis.documentCount} documents)`)
    console.log(''.repeat(50))

    if (analysis.relationships.length === 0) {
      console.log('  No relationships found')
    } else {
      analysis.relationships.forEach(rel => {
        let description = `  ${rel.fieldName}: ${rel.fieldType}`
        if (rel.targetType) {
          description += ` → ${rel.targetType}`
        }
        if (rel.isArray) {
          description += ' (multiple)'
        }
        console.log(description)
      })
    }
    console.log('')
  })
}

async function sampleContentAnalysis(analyses: SchemaAnalysis[]) {
  console.log('SAMPLE CONTENT ANALYSIS:')
  console.log('========================\n')

  for (const analysis of analyses) {
    if (analysis.documentCount === 0 || analysis.relationships.length === 0) continue

    console.log(`Sampling ${analysis.typeName} content...`)

    try {
      const relationshipFields = analysis.relationships.map(rel => {
        if (rel.isReference && rel.isArray) {
          return `${rel.fieldName}[]->{ _id, _type }`
        } else if (rel.isReference) {
          return `${rel.fieldName}->{ _id, _type }`
        } else if (rel.isAsset) {
          return rel.fieldName
        } else {
          return rel.fieldName
        }
      }).join(',\n    ')

      const query = `*[_type == "${analysis.typeName}"][0...3]{
        _id,
        _type,
        ${relationshipFields}
      }`

      const sampleDocs = await client.fetch(query)

      sampleDocs.forEach((doc: any, index: number) => {
        console.log(`  Sample ${index + 1}:`)

        analysis.relationships.forEach(rel => {
          const value = doc[rel.fieldName]
          let display = 'None'

          if (value) {
            if (rel.isReference && Array.isArray(value)) {
              display = `${value.length} references`
            } else if (rel.isReference && value._type) {
              display = `1 reference to ${value._type}`
            } else if (rel.isAsset && Array.isArray(value)) {
              display = `${value.length} assets`
            } else if (rel.isAsset) {
              display = '1 asset'
            } else {
              display = 'Has data'
            }
          }

          console.log(`    ${rel.fieldName}: ${display}`)
        })
        console.log('')
      })

    } catch (error) {
      console.log(`    Error sampling ${analysis.typeName}:`, error)
    }
  }
}

analyzeRelationships()
Enter fullscreen mode Exit fullscreen mode

The code above scans our Sanity schemaTypes to detect and summarize relationships (references, arrays, assets, nested in objects), fetches per-type document counts, and samples a few documents to report what related data each field actually contains.

Planning Schema Transformations

From our example schemas, here's what we're working with:

  • PostsAuthors (array of references to person type)
  • PostsCategories (array of references to category type)
  • PostsImages (image assets)
  • PagesSEO Images (nested image assets)
  • ProductsGallery Images (array of image assets)

Run the relationship analyzer:

npx sanity exec analyzeRelationships.ts --with-user-token
Enter fullscreen mode Exit fullscreen mode

With that, we should have something like this:

002 run relationship analyzer.png

Asset Inventory and Preparation

Finally, let's create a comprehensive asset audit - ./auditAssets.ts:

// ./auditAssets.ts
import {createClient} from '@sanity/client'

const client = createClient({
  projectId: 'your-project-id',
  dataset: process.env.SANITY_STUDIO_DATASET || 'production',
  useCdn: false,
  apiVersion: '2023-05-03',
  token: process.env.SANITY_API_TOKEN,
})

interface AssetInfo {
  _id: string
  _type: string
  url: string
  originalFilename: string
  size: number
  mimeType: string
  extension: string
  metadata?: {
    dimensions?: {
      width: number
      height: number
    }
  }
}

async function auditAssets() {
  console.log('Starting asset audit...')
  console.log('========================\n')

  try {
    const assets = await client.fetch<AssetInfo[]>(`
      *[_type in ["sanity.imageAsset", "sanity.fileAsset"]] {
        _id,
        _type,
        url,
        originalFilename,
        size,
        mimeType,
        extension,
        metadata
      }
    `)

    console.log(`Found ${assets.length} total assets\n`)

    const imageAssets = assets.filter((asset) => asset._type === 'sanity.imageAsset')
    const fileAssets = assets.filter((asset) => asset._type === 'sanity.fileAsset')

    console.log('ASSET BREAKDOWN:')
    console.log('================')
    console.log(`Images: ${imageAssets.length}`)
    console.log(`Files: ${fileAssets.length}`)

    const totalSize = assets.reduce((sum, asset) => sum + (asset.size || 0), 0)
    const totalSizeMB = (totalSize / 1024 / 1024).toFixed(2)
    console.log(`Total size: ${totalSizeMB} MB\n`)

    if (imageAssets.length > 0) {
      console.log('IMAGE ANALYSIS:')
      console.log('===============')

      const withDimensions = imageAssets.filter((img) => img.metadata?.dimensions)
      const avgWidth = withDimensions.reduce((sum, img) => sum + (img.metadata?.dimensions?.width || 0), 0) / withDimensions.length
      const avgHeight = withDimensions.reduce((sum, img) => sum + (img.metadata?.dimensions?.height || 0), 0) / withDimensions.length

      console.log(`Images with dimensions: ${withDimensions.length}/${imageAssets.length}`)
      if (withDimensions.length > 0) {
        console.log(`Average dimensions: ${Math.round(avgWidth)}x${Math.round(avgHeight)}`)
      }

      const imageTypes = imageAssets.reduce((acc, img) => {
        const type = img.mimeType || 'unknown'
        acc[type] = (acc[type] || 0) + 1
        return acc
      }, {} as Record<string, number>)

      console.log('Image types:')
      Object.entries(imageTypes).forEach(([type, count]) => {
        console.log(`  ${type}: ${count}`)
      })
      console.log('')
    }

    await analyzeAssetUsage(assets)
    await generateAssetInventory(assets)

    console.log('Asset audit complete!')
  } catch (error) {
    console.error('Error during asset audit:', error)
  }
}

async function analyzeAssetUsage(assets: AssetInfo[]) {
  console.log('ASSET USAGE ANALYSIS:')
  console.log('=====================')

  let unusedAssets = 0
  let usedAssets = 0

  for (const asset of assets) {
    const referencingDocs = await client.fetch(`
      *[references("${asset._id}")] {
        _id,
        _type
      }
    `)

    if (referencingDocs.length > 0) {
      usedAssets++
    } else {
      unusedAssets++
    }
  }

  console.log(`Used assets: ${usedAssets}`)
  console.log(`Unused assets: ${unusedAssets}`)
  console.log('')
}

async function generateAssetInventory(assets: AssetInfo[]) {
  const inventory = {
    generatedAt: new Date().toISOString(),
    summary: {
      totalAssets: assets.length,
      totalImages: assets.filter((a) => a._type === 'sanity.imageAsset').length,
      totalFiles: assets.filter((a) => a._type === 'sanity.fileAsset').length,
      totalSizeBytes: assets.reduce((sum, asset) => sum + (asset.size || 0), 0),
    },
    assets: assets.map((asset) => ({
      id: asset._id,
      type: asset._type,
      filename: asset.originalFilename,
      url: asset.url,
      size: asset.size,
      mimeType: asset.mimeType,
      extension: asset.extension,
      dimensions: asset.metadata?.dimensions,
    })),
  }

  const fs = require('fs')
  fs.writeFileSync('assets-inventory.json', JSON.stringify(inventory, null, 2))
  console.log('Asset inventory saved to assets-inventory.json')
}

auditAssets()
Enter fullscreen mode Exit fullscreen mode

The code above fetches all Sanity image/file assets, reports counts/size/types and average image dimensions, checks which assets are referenced vs unused, and writes a detailed assets-inventory.json export.

Run the asset audit:

npx sanity exec auditAssets.ts --with-user-token
Enter fullscreen mode Exit fullscreen mode

With that, we should have something like this:

003 run asset audit.png

And we can inspect the newly created ./assets-inventory.json file generated, here's mine:

{
  "generatedAt": "2025-08-28T12:32:29.993Z",
  "summary": {
    "totalAssets": 6,
    "totalImages": 6,
    "totalFiles": 0,
    "totalSizeBytes": 9788624
  },
  "assets": [
    {
      "id": "image-87d44663b620c92e956dbfbd3080a6398589c289-1080x1080-png",
      "type": "sanity.imageAsset",
      "filename": "image.png",
      "url": "<https://cdn.sanity.io/images/lhmeratw/production/87d44663b620c92e956dbfbd3080a6398589c289-1080x1080.png>",
      "size": 1232943,
      "mimeType": "image/png",
      "extension": "png",
      "dimensions": {
        "_type": "sanity.imageDimensions",
        "aspectRatio": 1,
        "height": 1080,
        "width": 1080
      }
    },
  ]
}
Enter fullscreen mode Exit fullscreen mode

Phase 2: Setting Up the Migration Environment

Installing the Sanity-to-Strapi CLI Tool

Create a dedicated workspace for this migration:

# Create migration workspace
mkdir sanity-to-strapi-migration
cd sanity-to-strapi-migration

# Set up directories
mkdir sanity-export      # For exported Sanity data
mkdir strapi-project     # New Strapi instance
mkdir migration-scripts  # Custom migration code
mkdir logs              # Migration logs and reports
Enter fullscreen mode Exit fullscreen mode

Configuring Your Development Environment

Install the required tools:

# Install global CLI tools
npm install -g @sanity/cli @strapi/strapi

# Initialize package.json for migration scripts
npm init -y

# Install migration-specific packages
npm install @sanity/client axios fs-extra path csvtojson
Enter fullscreen mode Exit fullscreen mode

Setting Up a Fresh Strapi Instance

Create and configure your new Strapi project:

# Create new Strapi project
npx create-strapi-app@latest strapi-project --quickstart
Enter fullscreen mode Exit fullscreen mode

With that, we'll install Strapi.

# Start Strapi server

cd strapi-project
npm run develop
Enter fullscreen mode Exit fullscreen mode

And start the Strapi server.

Set up an admin account:

004 strapi admin.png

After successful account creation, you should see the admin dashboard:

005 Strapi admin dashboard.jpg

Obtain API token

Let’s quickly get and save our API token so we can make authenticated requests to our Strapi API. Navigate to Settings > API Tokens

006 obtain api token.png

Once you’re here, click on Full Access > View Token > Copy

007 save token.png

Save your token, we’ll need it later.

Backup Strategies and Safety Measures

Critical: Always back up before migration!

For Sanity backups (run from your existing Sanity project):

cd path/to/your/sanity-studio
sanity dataset export production backup-$(date +%Y%m%d).tar.gz
Enter fullscreen mode Exit fullscreen mode

For Strapi backups (if you already have a Strapi project):

# SQLite (development)
cp .tmp/data.db .tmp/data-backup.db

# PostgreSQL (production)
pg_dump your_strapi_db > strapi-backup-$(date +%Y%m%d).sql
Enter fullscreen mode Exit fullscreen mode

Phase 3: Exporting from Sanity

Using Sanity's Export Capabilities

Sanity provides built-in export capabilities that we'll leverage for our migration.

Important: Run these commands from your existing Sanity studio project directory:

# Make sure you're in your Sanity project directory
cd path/to/your/sanity-studio

# Export everything to your migration workspace
sanity dataset export production ../sanity-to-strapi-migration/sanity-export/

# For specific document types (optional)
sanity dataset export production --types post,person,category,page,product ../sanity-to-strapi-migration/sanity-export/filtered-export
Enter fullscreen mode Exit fullscreen mode

008 sanity export.png

Understanding the Exported Data Structure

The export creates a compressed .tar.gz file. Let's examine its structure:

# Navigate to migration workspace
cd sanity-to-strapi-migration

# Extract the export
tar -xvzf sanity-export/production.tar.gz -C sanity-export
Enter fullscreen mode Exit fullscreen mode

009 export extracted data.png

This creates a data.ndjson file where each line is a JSON document representing your content.

010 .png

Handling Large Datasets and Assets

For large datasets, you might want to export in batches. Create this analysis script - ./sanity-to-strapi-migration/migration-scripts/analyze-export.js:

// migration-scripts/analyze-export.js
const fs = require('fs')
const readline = require('readline')

async function analyzeExport() {
  const fileStream = fs.createReadStream('../sanity-export/data.ndjson')
  const rl = readline.createInterface({
    input: fileStream,
    crlfDelay: Infinity
  })

  const typeCount = {}
  const sampleDocs = {}

  for await (const line of rl) {
    const doc = JSON.parse(line)

    // Count document types
    typeCount[doc._type] = (typeCount[doc._type] || 0) + 1

    // Store samples for known types
    if (['post', 'person', 'category', 'page', 'product'].includes(doc._type)) {
      if (!sampleDocs[doc._type]) {
        sampleDocs[doc._type] = doc
      }
    }
  }

  console.log('Document type counts:', typeCount)

  // Save analysis results
  fs.writeFileSync('export-analysis.json', JSON.stringify({
    typeCount,
    sampleDocs
  }, null, 2))
}

analyzeExport()
Enter fullscreen mode Exit fullscreen mode

The code above reads a Sanity NDJSON export, tallies document counts per _type, saves one sample doc for key types, logs the counts, and writes everything to export-analysis.json.

Run the analysis:

cd migration-scripts
node analyze-export.js
Enter fullscreen mode Exit fullscreen mode

We should have something like this:

011 Handling Large Datasets and Assets.png

Validation and Quality Checks

Review the generated export-analysis.json to understand your data structure and ensure all content types are present.

012 Validation and Quality Checks.png

Phase 4: Schema and Content Transformation

Running the CLI Tool for Schema Mapping

Now we'll use the automated CLI tool to handle the complex schema transformation process:

Basic Schema Generation

# Generate schemas only
npx @untools/sanity-strapi-cli@latest schemas \
  --sanity-project ../studio-first-project \
  --sanity-export ./sanity-export \
  --strapi-project ./strapi-project
Enter fullscreen mode Exit fullscreen mode

013 Basic Schema Generation.png

Content Type Generation in Strapi

The CLI creates a complete Strapi project structure:

strapi-project/src/
├── api/
│   ├── post/
│   │   ├── content-types/post/schema.json
│   │   ├── controllers/post.ts
│   │   ├── routes/post.ts
│   │   └── services/post.ts
│   ├── person/
│   └── category/
└── components/
    ├── blocks/
    └── media/
Enter fullscreen mode Exit fullscreen mode

Example transformation - A Sanity post schema:

// sanity/schemaTypes/post.js
export default {
  name: 'post',
  type: 'document',
  fields: [
    { name: 'title', type: 'string', validation: Rule => Rule.required() },
    { name: 'slug', type: 'slug', options: { source: 'title' } },
    { name: 'author', type: 'reference', to: [{ type: 'person' }] },
    { name: 'categories', type: 'array', of: [{ type: 'reference', to: [{ type: 'category' }] }] },
    { name: 'body', type: 'array', of: [{ type: 'block' }] },
    { name: 'publishedAt', type: 'datetime' }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Becomes this Strapi schema:

{
  "kind": "collectionType",
  "collectionName": "posts",
  "info": {
    "singularName": "post",
    "pluralName": "posts",
    "displayName": "Post"
  },
  "attributes": {
    "title": {
      "type": "string",
      "required": true
    },
    "slug": {
      "type": "uid",
      "targetField": "title"
    },
    "author": {
      "type": "relation",
      "relation": "manyToOne",
      "target": "api::person.person",
      "inversedBy": "posts"
    },
    "categories": {
      "type": "relation",
      "relation": "manyToMany",
      "target": "api::category.category",
      "mappedBy": "posts"
    },
    "body": {
      "type": "blocks"
    },
    "publishedAt": {
      "type": "datetime"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Data Transformation and Relationship Mapping

The CLI automatically handles:

  • Type mapping: String → string, reference → relation, etc.
  • Relationship analysis: Detects bidirectional relationships
  • Component creation: Complex objects become reusable components
  • Asset transformation: Images and files are properly mapped

Asset Processing and Migration

Once schemas are ready, migrate your actual content and media assets.

Prerequisites:

  1. Strapi server running with generated schemas
  2. Strapi API token with full permissions
# Start your Strapi server first
cd strapi-project && npm run develop

# In another terminal, run content migration
STRAPI_API_TOKEN=your_full_access_token npx @untools/sanity-strapi-cli@latest content \
  --sanity-export ./sanity-export \
  --strapi-project ./strapi-project \
  --strapi-url http://localhost:1337
Enter fullscreen mode Exit fullscreen mode

014 Asset Processing and Migration.png

Choose your asset strategy:

Option 1: Strapi Native Media (Default)

STRAPI_API_TOKEN=your_token npx @untools/sanity-strapi-cli@latest content \
  --sanity-export ./sanity-export \
  --strapi-project ./strapi-project \
  --asset-provider strapi
Enter fullscreen mode Exit fullscreen mode

Option 2: Cloudinary Integration

CLOUDINARY_CLOUD_NAME=your_cloud \
CLOUDINARY_API_KEY=your_key \
CLOUDINARY_API_SECRET=your_secret \
STRAPI_API_TOKEN=your_token npx @untools/sanity-strapi-cli@latest content \
  --sanity-export ./sanity-export \
  --strapi-project ./strapi-project \
  --asset-provider cloudinary
Enter fullscreen mode Exit fullscreen mode

Phase 5: Importing into Strapi

Batch Importing Transformed Content

For a complete end-to-end migration, run:

# Complete migration (schemas + content)
STRAPI_API_TOKEN=your_token npx @untools/sanity-strapi-cli@latest migrate \
  --sanity-project ../studio-first-project \
  --sanity-export ./sanity-export \
  --strapi-project ./strapi-project \
  --strapi-url http://localhost:1337

Enter fullscreen mode Exit fullscreen mode

Verifying Data Integrity

The migration generates detailed reports:

  • schema-generation-report.json - Schema creation details
  • universal-migration-report.json - Content migration results

Interactive Mode (Recommended)

For a guided setup experience that will handle both schema generation and content migration:

# Guided setup with prompts
npx @untools/sanity-strapi-cli@latest --interactive
Enter fullscreen mode Exit fullscreen mode

This will prompt you for:

  • Path to Sanity studio project
  • Path to Sanity export data
  • Path to Strapi project
  • Strapi server URL
  • Asset provider preference (Strapi native or Cloudinary)

015 Interactive Prompting.png

A successful migration will show:

Migration Summary:
Assets: 6/6 (0 failed)
Entities: 7/7 (0 failed)
Relationships: 3/3 (0 failed)
Total errors: 0
Schemas used: 5
Components used: 3
✅ Content migration completed

🎉 Universal migration completed successfully!

📋 Next Steps:
1. Review generated files:
   - Check schema-generation-report.json for schema analysis
   - Review generated schemas in your Strapi project
   - Check universal-migration-report.json for migration results

2. Start your Strapi server:
   cd ../strapi-project && npm run develop

3. Review migrated content in the Strapi admin panel

4. Adjust content types and components as needed
✓ 
Full migration completed successfully!
ℹ Generated files:
ℹ   - schema-generation-report.json
ℹ   - universal-migration-report.json
Enter fullscreen mode Exit fullscreen mode

And if we visit our Strapi Admin Dashboard, we should see our content.

016 content in Strapi Admin Dashboard.png

Handling Import Errors and Retries

The CLI includes automatic retry logic and error handling. If issues occur:

  1. Check the migration logs for specific errors
  2. Verify your Strapi server is running
  3. Ensure API tokens have proper permissions
  4. Review schema conflicts in the generated reports

Post-Import Validation

After migration, verify your content in the Strapi admin dashboard:

  1. Check that all content types are present
  2. Verify relationships are properly connected
  3. Ensure assets are uploaded and accessible
  4. Test API endpoints for data consistency

Phase 6: Testing and Going Live

Content Comparison and Validation

Before going live, perform thorough validation:

  1. Content freeze: Stop updates in Sanity during final testing
  2. Data comparison: Spot-check content between systems
  3. Relationship testing: Verify all references work correctly
  4. Asset verification: Ensure all media files are accessible

Frontend Integration Updates

Your frontend code will need systematic updates to work with Strapi's API structure. This section walks through migrating a real Next.js project from Sanity to Strapi integration.

Project Overview

We'll migrate an example Next.js site with:

Step 1: Replace Sanity Client with Strapi API Client

Before - src/sanity/client.ts:

import { createClient } from "next-sanity";

export const client = createClient({
  projectId: "lhmeratw",
  dataset: "production",
  apiVersion: "2024-01-01",
  useCdn: false,
});
Enter fullscreen mode Exit fullscreen mode

After - Create src/lib/strapi-client.ts:

const STRAPI_URL =
  process.env.NEXT_PUBLIC_STRAPI_URL || "http://localhost:1337";
const STRAPI_TOKEN = process.env.STRAPI_API_TOKEN;

export async function strapiRequest(
  endpoint: string,
  options: RequestInit = {}
) {
  const url = `${STRAPI_URL}/api/${endpoint}`;

  const response = await fetch(url, {
    headers: {
      "Content-Type": "application/json",
      ...(STRAPI_TOKEN && { Authorization: `Bearer ${STRAPI_TOKEN}` }),
      ...options.headers,
    },
    ...options,
  });

  if (!response.ok) {
    throw new Error(`Strapi request failed: ${response.statusText}`);
  }

  return response.json();
}
Enter fullscreen mode Exit fullscreen mode

Step 2: Create Data Adapters

Create src/utils/strapi-adapter.ts:

/* eslint-disable @typescript-eslint/no-explicit-any */
// ./src/utils/strapi-adapter.ts

const STRAPI_URL =
  process.env.NEXT_PUBLIC_STRAPI_URL || "http://localhost:1337";

export interface StrapiResponse<T = any> {
  data: T;
  meta?: {
    pagination?: {
      page: number;
      pageSize: number;
      pageCount: number;
      total: number;
    };
  };
}

export interface StrapiEntity {
  id?: string | number;
  documentId?: string | number;
  [key: string]: any;
}

/* -------------------------
   Helpers
------------------------- */

// Standardized slug adapter
const adaptSlug = (slug?: string) => ({ current: slug ?? "" });

// Standardized image adapter
const adaptImage = (img?: StrapiEntity) => img?.data ?? null;

// Standardized authors adapter
const adaptAuthors = (authors?: { data?: StrapiEntity[] }) =>
  authors?.data?.map(({ name, profilePicture }) => ({
    name,
    profilePicture: adaptImage(profilePicture),
  })) ?? [];

// Standardized categories adapter
const adaptCategories = (categories?: { data?: StrapiEntity[] }) =>
  categories?.data?.map(({ title, slug }) => ({
    title,
    slug: adaptSlug(slug),
  })) ?? [];

// Standardized gallery adapter
const adaptGallery = (gallery?: { data?: StrapiEntity[] }) =>
  gallery?.data?.map((img) => ({
    ...img,
    asset: { _ref: `image-${img.id}` }, // Sanity-like reference
  })) ?? [];

/* -------------------------
   Adapters
------------------------- */

export function adaptStrapiPost(post: StrapiEntity): any {
  return {
    _id: String(post.documentId),
    slug: adaptSlug(post.slug),
    image: adaptImage(post.image),
    authors: adaptAuthors(post.authors),
    categories: adaptCategories(post.categories),
    ...post, // spread last so overrides don't break critical fields
  };
}

export function adaptStrapiProduct(product: StrapiEntity): any {
  return {
    _id: String(product.documentId),
    specifications: {
      ...product.specifications, // spread instead of manual copy
    },
    gallery: adaptGallery(product.gallery),
    ...product,
  };
}

export function adaptStrapiPage(page: StrapiEntity): any {
  return {
    _id: String(page.documentId),
    slug: adaptSlug(page.slug),
    seo: {
      ...page.seo,
      image: adaptImage(page.seo?.image),
    },
    ...page,
  };
}

/* -------------------------
   Image URL builder
------------------------- */
export function getStrapiImageUrl(
  imageAttributes: any,
  baseUrl = STRAPI_URL
): string | null {
  const url = imageAttributes?.url;
  if (!url) return null;
  return url.startsWith("http") ? url : `${baseUrl}${url}`;
}
Enter fullscreen mode Exit fullscreen mode

These utility functions transform Strapi responses into a Sanity-like format for consistent, frontend-friendly data handling.

Step 3: Update Navigation Logic

Before - src/lib/navigation.ts:

import { client } from "@/sanity/client";

const PAGES_QUERY = `*[_type == "page" && defined(slug.current)]|order(title asc){
  _id, title, slug
}`;

export interface NavigationPage {
  _id: string;
  title: string;
  slug: { current: string };
}

export async function getNavigationPages(): Promise<NavigationPage[]> {
  const options = { next: { revalidate: 60 } };
  return client.fetch<NavigationPage[]>(PAGES_QUERY, {}, options);
}
Enter fullscreen mode Exit fullscreen mode

After - Update src/lib/navigation.ts:

import { strapiRequest } from "./strapi-client";
import {
  adaptStrapiPage,
  type StrapiResponse,
  type StrapiEntity,
} from "@/utils/strapi-adapter";

export interface NavigationPage {
  _id: string;
  title: string;
  slug: { current: string };
}

export async function getNavigationPages(): Promise<NavigationPage[]> {
  try {
    const response: StrapiResponse<StrapiEntity[]> = await strapiRequest(
      "pages?fields[0]=title&fields[1]=slug&sort=title:asc",
      { next: { revalidate: 60 } }
    );

    return response.data.map(adaptStrapiPage)
  } catch (error) {
    console.error("Failed to fetch navigation pages:", error);
    return [];
  }
}

// Keep your existing navigation constants
export const MAIN_NAV_SLUGS = ["about", "contact"];
export const FOOTER_QUICK_LINKS_SLUGS = ["about", "contact"];
export const FOOTER_SUPPORT_SLUGS = ["help", "shipping", "returns", "privacy"];
export const FOOTER_LEGAL_SLUGS = ["terms", "privacy", "cookies"];
Enter fullscreen mode Exit fullscreen mode

Step 4: Update Homepage

Before - src/app/page.tsx:

import { client } from "@/sanity/client";

const POSTS_QUERY = `*[
  _type == "post"
  && defined(slug.current)
]|order(publishedAt desc)[0...3]{_id, title, slug, publishedAt, image}`;

const PRODUCTS_QUERY = `*[
  _type == "product"
  && available == true
]|order(_createdAt desc)[0...4]{_id, name, price, gallery}`;

const options = { next: { revalidate: 30 } };

export default async function HomePage() {
  const [posts, products] = await Promise.all([
    client.fetch<SanityDocument[]>(POSTS_QUERY, {}, options),
    client.fetch<SanityDocument[]>(PRODUCTS_QUERY, {}, options),
  ]);
Enter fullscreen mode Exit fullscreen mode

After - Update src/app/page.tsx:

/* eslint-disable @typescript-eslint/no-explicit-any */
// ./src/app/page.tsx (improved with design system)
import { strapiRequest } from "@/lib/strapi-client";
import {
  adaptStrapiPost,
  adaptStrapiProduct,
  getStrapiImageUrl,
} from "@/utils/strapi-adapter";
import Image from "next/image";
import Link from "next/link";

const options = { next: { revalidate: 30 } };

export default async function HomePage() {
  const [postsResponse, productsResponse] = await Promise.all([
    strapiRequest(
      "posts?populate=*&sort=publishedAt:desc&pagination[limit]=3",
      options
    ),
    strapiRequest(
      "products?populate=*&filters[available][$eq]=true&sort=createdAt:desc&pagination[limit]=4",
      options
    ),
  ]);

  const posts = postsResponse.data.map(adaptStrapiPost);
  const products = productsResponse.data.map(adaptStrapiProduct);
Enter fullscreen mode Exit fullscreen mode

Update Image Handling in the same file:

// Replace urlFor() with getStrapiImageUrl()
{products.map((product) => {
  const imageUrl = product.gallery?.[0]
    ? getStrapiImageUrl(product.gallery[0])
    : null;

  return (
    <Link key={product._id} href={`/products/${product._id}`}>
      {imageUrl && (
        <Image
          src={imageUrl}
          alt={product.name}
          width={300}
          height={200}
        />
      )}
      {/* Rest of component */}
    </Link>
  );
})}

Enter fullscreen mode Exit fullscreen mode

Step 5: Update Products Page

Before - src/app/products/page.tsx:

const PRODUCTS_QUERY = `*[
  _type == "product"
]|order(name asc){_id, name, price, available, tags, gallery}`;

export default async function ProductsPage() {
  const products = await client.fetch<SanityDocument[]>(
    PRODUCTS_QUERY,
    {},
    options
  );

Enter fullscreen mode Exit fullscreen mode

After - Update the data fetching:

export default async function ProductsPage() {
  const response = await strapiRequest(
    "products?populate=*&sort=name:asc",
    options
  );
  const products = response.data.map(adaptStrapiProduct);
Enter fullscreen mode Exit fullscreen mode

Step 6: Update Blog Pages

Blog Index - src/app/blog/page.tsx:

// Before
const POSTS_QUERY = `*[
  _type == "post"
  && defined(slug.current)
]|order(publishedAt desc){
  _id, title, slug, publishedAt, image,
  authors[]->{ name },
  categories[]->{ title }
}`;

// After
export default async function BlogPage() {
  const response = await strapiRequest(
    "posts?populate=*&sort=publishedAt:desc",
    options
  );

  const posts = response.data.map(adaptStrapiPost);
}
Enter fullscreen mode Exit fullscreen mode

Blog Post Detail - src/app/blog/[slug]/page.tsx:

// Before
const POST_QUERY = `*[_type == "post" && slug.current == $slug][0]`;

export default async function PostPage({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const post = await client.fetch<SanityDocument>(
    POST_QUERY,
    await params,
    options
  );

// After
export default async function PostPage({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;

  const response = await strapiRequest(
    `posts?populate=*&filters[slug][$eq]=${slug}`,
    options
  );

  const post = response.data[0] ? adaptStrapiPost(response.data[0]) : null;

  if (!post) {
    notFound();
  }
Enter fullscreen mode Exit fullscreen mode

Step 7: Update Dynamic Pages

Before - src/app/(pages)/[slug]/page.tsx:

const PAGE_QUERY = `*[_type == "page" && slug.current == $slug][0]{
  _id, title, slug, body, seo
}`;

export default async function DynamicPage({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;
  const page = await client.fetch<SanityDocument>(
    PAGE_QUERY,
    { slug },
    options
  );
Enter fullscreen mode Exit fullscreen mode

After:

export default async function DynamicPage({
  params,
}: {
  params: Promise<{ slug: string }>;
}) {
  const { slug } = await params;

  const response = await strapiRequest(
    `pages?populate=*&filters[slug][$eq]=${slug}`,
    options
  );

  const page = response.data[0] ? adaptStrapiPage(response.data[0]) : null;

  if (!page) {
    notFound();
  }
Enter fullscreen mode Exit fullscreen mode

Step 8: Replace Rich Text Renderer

Install Strapi Blocks Renderer:

npm install @strapi/blocks-react-renderer
Enter fullscreen mode Exit fullscreen mode

Before - Using Sanity's PortableText:

import { PortableText } from "next-sanity";

// In component
<div className="prose prose-lg prose-emerald max-w-none">
  {Array.isArray(post.body) && <PortableText value={post.body} />}
</div>
Enter fullscreen mode Exit fullscreen mode

After - Using Strapi's BlocksRenderer:

import { BlocksRenderer, type BlocksContent } from '@strapi/blocks-react-renderer';

// In component
<div className="prose prose-lg prose-emerald max-w-none">
  {post.body && <BlocksRenderer content={post.body as BlocksContent} />}
</div>
Enter fullscreen mode Exit fullscreen mode

Step 9: Update Environment Variables

Before - .env.local:

NEXT_PUBLIC_SANITY_PROJECT_ID=lhmeratw
NEXT_PUBLIC_SANITY_DATASET=production
SANITY_API_TOKEN=your-token
Enter fullscreen mode Exit fullscreen mode

After - .env.local:

NEXT_PUBLIC_STRAPI_URL=http://localhost:1337
STRAPI_API_TOKEN=your-full-access-token
Enter fullscreen mode Exit fullscreen mode

Step 10: Error Handling and Fallbacks

Create src/lib/strapi-client.ts with robust error handling:

export async function strapiRequest(endpoint: string, options: RequestInit = {}) {
  try {
    const url = `${STRAPI_URL}/api/${endpoint}`

    const response = await fetch(url, {
      headers: {
        'Content-Type': 'application/json',
        ...(STRAPI_TOKEN && { Authorization: `Bearer ${STRAPI_TOKEN}` }),
        ...options.headers,
      },
      ...options,
    })

    if (!response.ok) {
      console.error(`Strapi API Error: ${response.status} ${response.statusText}`)

      // Return empty data structure for graceful fallback
      return { data: [], meta: {} }
    }

    return response.json()
  } catch (error) {
    console.error('Strapi request failed:', error)
    return { data: [], meta: {} }
  }
}
Enter fullscreen mode Exit fullscreen mode

With that, we have a fully migrated site, from Sanity to Strapi 🎊

017 Successful Migration from Sanity to Strapi.png

GitHub Source Code

Sanity vs Strapi: Key Differences Summary

Aspect Sanity Strapi
Query Language GROQ REST with query parameters
Data Structure Flat documents Flat documents
Relationships -> references populate parameter
Images urlFor() builder Direct URL
Rich Text PortableText Blocks renderer
Filtering GROQ expressions filters[field][$eq]=value
Sorting order(field desc) sort=field:desc
Limiting [0...3] pagination[limit]=3

Testing Your Migration

  1. Start both systems during transition:
# Terminal 1: Start Strapi
cd strapi-project && npm run develop
# Terminal 2: Start Next.js
cd frontend && npm run dev
Enter fullscreen mode Exit fullscreen mode
  1. Compare outputs by temporarily logging both data structures:
console.log('Sanity data:', sanityPosts)
console.log('Strapi data:', strapiPosts)
Enter fullscreen mode Exit fullscreen mode
  1. Validate all pages load without errors
  2. Check image rendering and links work correctly
  3. Test rich text content displays properly

This systematic approach ensures your frontend continues working seamlessly after the CMS migration while maintaining the same user experience.

Performance Testing

Monitor these key metrics during the transition:

  • API response times: Strapi's performance characteristics differ from Sanity
  • Content editor experience: Ensure your team adapts well to Strapi's interface
  • Build times: Static generation patterns may change
  • CDN cache hit rates: Asset serving patterns will be different

Final Deployment Considerations

Pre-Launch Checklist:

  1. Content freeze: Stop all content updates in Sanity
  2. Final sync: Run one last migration to catch any changes
  3. DNS preparation: Have CDN/DNS changes ready to deploy
  4. Rollback plan: Document exactly how to revert if things go wrong
  5. Team notification: Make sure everyone knows the switch is happening

Going Live Process:

  1. Deploy your updated frontend with Strapi integration
  2. Deploy your Strapi project to Strapi Cloud.
  3. Update environment variables to point to production Strapi
  4. Test all critical user flows
  5. Monitor error rates and performance metrics
  6. Have your rollback plan ready to execute if needed

Key Success Factors:

  1. Plan extensively - The more you understand your current setup, the smoother the migration
  2. Validate everything - Don't trust that the migration worked until you've verified it
  3. Have rollback ready - Things can go wrong, and you need to be able to recover quickly
  4. Train your team - The best technical migration is worthless if your content creators can't use the new system

What You've Accomplished:

By following this guide, you've successfully:

  • Analyzed your existing Sanity schema and content relationships
  • Transformed complex schema structures into Strapi-compatible formats
  • Migrated all content while preserving data integrity and relationships
  • Set up proper asset handling for your media files
  • Updated your frontend integration to work with Strapi's API structure
  • Established monitoring and rollback procedures for a safe production deployment

Benefits Achieved:

  • Team familiarity: Content editors now work with a more traditional CMS interface
  • Plugin ecosystem: Access to Strapi's extensive plugin library
  • Self-hosting control: Full control over your content infrastructure
  • Flexible APIs: Both REST and GraphQL endpoints for your frontend
  • Built-in features: User management, permissions, and admin interface out of the box

Ongoing Maintenance:

Your new Strapi setup requires different maintenance considerations:

  • Regular plugin updates and security patches
  • Database backup strategies for your chosen database system
  • Performance monitoring as your content scales
  • Team training on Strapi's content management workflows

Most importantly, don't rush the process. Take time to test thoroughly, and your future self will thank you. The patterns shown here handle the most common content types you'll encounter - posts with authors and categories, product catalogs with image galleries, static pages with SEO metadata, and user profiles. These examples provide a solid foundation to adapt to your specific schema and content structure.

Team Training Guide:

Your content team will need guidance on Strapi's interface:

## Quick Strapi Guide for Content Editors

### Creating a Blog Post
1. Navigate to Content Manager → Posts
2. Click "Create new entry"
3. Fill in title (slug will auto-generate)
4. Set published date
5. Select authors from the dropdown (multiple selection available)
6. Choose categories
7. Upload a featured image
8. Write content in the rich text editor
9. Save & Publish

### Managing Authors (People)
1. Go to Content Manager → People
2. Add name and email
3. Write bio using the rich text editor
4. Upload profile picture
5. Save & Publish

### Creating Products
1. Navigate to Content Manager → Products
2. Enter product name and price
3. Set availability status
4. Add tags as JSON array: ["tag1", "tag2"]
5. Upload multiple images to the gallery
6. Fill in specifications (weight, dimensions, material)
7. Save & Publish
Enter fullscreen mode Exit fullscreen mode

Final Thoughts

Migrating from Sanity to Strapi is no small task - you're essentially rebuilding your entire content infrastructure. When done carefully with proper planning, validation, and rollback strategies, it can be a smooth transition that opens up new possibilities for your content management workflow.

We have coverd the complete migration process from Sanity to Strapi using real-world examples. Here are the next steps:

  1. Deploy your new Strapi project in just few clicks to Strapi cloud.
  2. Visit the Strapi marketplace to install plugins to power up your Strapi application.
  3. Check out the Strapi documentation to learn more about Strapi.

Remember: this migration is a journey, not a destination. Your new Strapi setup should be the foundation for even better content management experiences ahead.

Top comments (0)