Migrate from Sanity CMS to Strapi: A Complete 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
Recommended Knowledge:
- Understanding of headless CMS concepts
- Basic JavaScript/TypeScript knowledge
- Experience with database relationships
- Familiarity with REST APIs
Migration Overview
This migration process consists of six main phases:
- Pre-Migration Assessment - Analyze your current setup and plan transformations
- Environment Setup - Prepare tools and create a fresh Strapi instance
- Data Export - Extract all content and assets from Sanity
- Schema Transformation - Convert Sanity schemas to Strapi content types
- Content Import - Migrate data while preserving relationships
- 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:
1// ./listSchemas.ts
2import {createClient} from '@sanity/client'
3import {schemaTypes} from './schemaTypes'
4
5const client = createClient({
6 projectId: 'your-project-id', // Replace with your project ID
7 dataset: 'production', // or your dataset name
8 useCdn: false,
9 apiVersion: '2023-05-03',
10})
11
12console.log('Schema Analysis:')
13console.log('================')
14
15schemaTypes.forEach((schema) => {
16 console.log(`\nSchema: ${schema.name}`)
17 console.log(`Type: ${schema.type}`)
18
19 if ('fields' in schema && schema.fields) {
20 console.log('Fields:')
21 schema.fields.forEach((field) => {
22 console.log(` - ${field.name}: ${field.type}`)
23
24 const fieldAny = field as any
25
26 if (fieldAny.of) {
27 console.log(` of: ${JSON.stringify(fieldAny.of, null, 4)}`)
28 }
29 if (fieldAny.to) {
30 console.log(` to: ${JSON.stringify(fieldAny.to, null, 4)}`)
31 }
32 if (fieldAny.options) {
33 console.log(` options: ${JSON.stringify(fieldAny.options, null, 4)}`)
34 }
35 })
36 }
37})
38
39// Optional: Get document counts
40async function getDocumentCounts() {
41 console.log('\nDocument Counts:')
42 console.log('================')
43
44 for (const schema of schemaTypes) {
45 try {
46 const count = await client.fetch(`count(*[_type == "${schema.name}"])`)
47 console.log(`${schema.name}: ${count} documents`)
48 } catch (error) {
49 const errorMessage = error instanceof Error? error.message : 'Unknown error'
50 console.log(`${schema.name}: Error getting count - ${errorMessage}`)
51 }
52 }
53}
54
55getDocumentCounts()
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
We should have something like this:
Identifying Content Types and Relationships
Next, we'll create a comprehensive relationship analyzer that works with any schema structure - ./analyzeRelationships.ts:
1// ./analyzeRelationships.ts
2import {createClient} from '@sanity/client'
3import {schemaTypes} from './schemaTypes'
4
5const client = createClient({
6 projectId: 'your-project-id',
7 dataset: 'production',
8 useCdn: false,
9 apiVersion: '2023-05-03'
10})
11
12interface RelationshipInfo {
13 fieldName: string
14 fieldType: string
15 targetType?: string
16 isArray: boolean
17 isReference: boolean
18 isAsset: boolean
19}
20
21interface SchemaAnalysis {
22 typeName: string
23 relationships: RelationshipInfo[]
24 documentCount: number
25}
26
27async function analyzeRelationships() {
28 console.log('Analyzing Content Relationships:')
29 console.log('================================\n')
30
31 try {
32 const analysisResults: SchemaAnalysis[] = []
33
34 for (const schema of schemaTypes) {
35 const analysis = await analyzeSchemaType(schema)
36 analysisResults.push(analysis)
37 }
38
39 generateRelationshipReport(analysisResults)
40 await sampleContentAnalysis(analysisResults)
41
42 } catch (error) {
43 console.error('Error analyzing relationships:', error)
44 }
45}
46
47async function analyzeSchemaType(schema: any): Promise<SchemaAnalysis> {
48 const relationships: RelationshipInfo[] = []
49
50 if (schema.type !== 'document') {
51 return {
52 typeName: schema.name,
53 relationships: [],
54 documentCount: 0
55 }
56 }
57
58 const documentCount = await client.fetch(`count(*[_type == "${schema.name}"])`)
59
60 if ('fields' in schema && schema.fields) {
61 schema.fields.forEach((field: any) => {
62 const relationshipInfo = analyzeField(field)
63 if (relationshipInfo) {
64 relationships.push(relationshipInfo)
65 }
66 })
67 }
68
69 return {
70 typeName: schema.name,
71 relationships,
72 documentCount
73 }
74}
75
76function analyzeField(field: any): RelationshipInfo | null {
77 const fieldAny = field as any
78 let relationshipInfo: RelationshipInfo | null = null
79
80 if (field.type === 'reference') {
81 relationshipInfo = {
82 fieldName: field.name,
83 fieldType: 'reference',
84 targetType: fieldAny.to?.[0]?.type || 'unknown',
85 isArray: false,
86 isReference: true,
87 isAsset: false
88 }
89 }
90 else if (field.type === 'array') {
91 const arrayItemType = fieldAny.of?.[0]
92 if (arrayItemType?.type === 'reference') {
93 relationshipInfo = {
94 fieldName: field.name,
95 fieldType: 'array of references',
96 targetType: arrayItemType.to?.[0]?.type || 'unknown',
97 isArray: true,
98 isReference: true,
99 isAsset: false
100 }
101 } else if (arrayItemType?.type === 'image' || arrayItemType?.type === 'file') {
102 relationshipInfo = {
103 fieldName: field.name,
104 fieldType: `array of ${arrayItemType.type}`,
105 isArray: true,
106 isReference: false,
107 isAsset: true
108 }
109 }
110 }
111 else if (field.type === 'image' || field.type === 'file') {
112 relationshipInfo = {
113 fieldName: field.name,
114 fieldType: field.type,
115 isArray: false,
116 isReference: false,
117 isAsset: true
118 }
119 }
120 else if (field.type === 'object') {
121 const nestedFields = fieldAny.fields || []
122 const hasNestedAssets = nestedFields.some((f: any) => f.type === 'image' || f.type === 'file')
123 const hasNestedReferences = nestedFields.some((f: any) => f.type === 'reference')
124
125 if (hasNestedAssets || hasNestedReferences) {
126 relationshipInfo = {
127 fieldName: field.name,
128 fieldType: 'object with nested relationships',
129 isArray: false,
130 isReference: hasNestedReferences,
131 isAsset: hasNestedAssets
132 }
133 }
134 }
135
136 return relationshipInfo
137}
138
139function generateRelationshipReport(analyses: SchemaAnalysis[]) {
140 console.log('RELATIONSHIP MAPPING SUMMARY:')
141 console.log('=============================\n')
142
143 analyses.forEach(analysis => {
144 if (analysis.relationships.length === 0 && analysis.documentCount === 0) return
145
146 console.log(`📋 ${analysis.typeName.toUpperCase()} (${analysis.documentCount} documents)`)
147 console.log('─'.repeat(50))
148
149 if (analysis.relationships.length === 0) {
150 console.log(' No relationships found')
151 } else {
152 analysis.relationships.forEach(rel => {
153 let description = ` ${rel.fieldName}: ${rel.fieldType}`
154 if (rel.targetType) {
155 description += ` → ${rel.targetType}`
156 }
157 if (rel.isArray) {
158 description += ' (multiple)'
159 }
160 console.log(description)
161 })
162 }
163 console.log('')
164 })
165}
166
167async function sampleContentAnalysis(analyses: SchemaAnalysis[]) {
168 console.log('SAMPLE CONTENT ANALYSIS:')
169 console.log('========================\n')
170
171 for (const analysis of analyses) {
172 if (analysis.documentCount === 0 || analysis.relationships.length === 0) continue
173
174 console.log(`Sampling ${analysis.typeName} content...`)
175
176 try {
177 const relationshipFields = analysis.relationships.map(rel => {
178 if (rel.isReference && rel.isArray) {
179 return `${rel.fieldName}[]->{ _id, _type }`
180 } else if (rel.isReference) {
181 return `${rel.fieldName}->{ _id, _type }`
182 } else if (rel.isAsset) {
183 return rel.fieldName
184 } else {
185 return rel.fieldName
186 }
187 }).join(',\n ')
188
189 const query = `*[_type == "${analysis.typeName}"][0...3]{
190 _id,
191 _type,
192 ${relationshipFields}
193 }`
194
195 const sampleDocs = await client.fetch(query)
196
197 sampleDocs.forEach((doc: any, index: number) => {
198 console.log(` Sample ${index + 1}:`)
199
200 analysis.relationships.forEach(rel => {
201 const value = doc[rel.fieldName]
202 let display = 'None'
203
204 if (value) {
205 if (rel.isReference && Array.isArray(value)) {
206 display = `${value.length} references`
207 } else if (rel.isReference && value._type) {
208 display = `1 reference to ${value._type}`
209 } else if (rel.isAsset && Array.isArray(value)) {
210 display = `${value.length} assets`
211 } else if (rel.isAsset) {
212 display = '1 asset'
213 } else {
214 display = 'Has data'
215 }
216 }
217
218 console.log(` ${rel.fieldName}: ${display}`)
219 })
220 console.log('')
221 })
222
223 } catch (error) {
224 console.log(` Error sampling ${analysis.typeName}:`, error)
225 }
226 }
227}
228
229analyzeRelationships()
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:
- Posts → Authors (array of references to person type)
- Posts → Categories (array of references to category type)
- Posts → Images (image assets)
- Pages → SEO Images (nested image assets)
- Products → Gallery Images (array of image assets)
Run the relationship analyzer:
npx sanity exec analyzeRelationships.ts --with-user-token
With that, we should have something like this:
Asset Inventory and Preparation
Finally, let's create a comprehensive asset audit - ./auditAssets.ts:
1// ./auditAssets.ts
2import {createClient} from '@sanity/client'
3
4const client = createClient({
5 projectId: 'your-project-id',
6 dataset: process.env.SANITY_STUDIO_DATASET || 'production',
7 useCdn: false,
8 apiVersion: '2023-05-03',
9 token: process.env.SANITY_API_TOKEN,
10})
11
12interface AssetInfo {
13 _id: string
14 _type: string
15 url: string
16 originalFilename: string
17 size: number
18 mimeType: string
19 extension: string
20 metadata?: {
21 dimensions?: {
22 width: number
23 height: number
24 }
25 }
26}
27
28async function auditAssets() {
29 console.log('Starting asset audit...')
30 console.log('========================\n')
31
32 try {
33 const assets = await client.fetch<AssetInfo[]>(`
34 *[_type in ["sanity.imageAsset", "sanity.fileAsset"]] {
35 _id,
36 _type,
37 url,
38 originalFilename,
39 size,
40 mimeType,
41 extension,
42 metadata
43 }
44 `)
45
46 console.log(`Found ${assets.length} total assets\n`)
47
48 const imageAssets = assets.filter((asset) => asset._type === 'sanity.imageAsset')
49 const fileAssets = assets.filter((asset) => asset._type === 'sanity.fileAsset')
50
51 console.log('ASSET BREAKDOWN:')
52 console.log('================')
53 console.log(`Images: ${imageAssets.length}`)
54 console.log(`Files: ${fileAssets.length}`)
55
56 const totalSize = assets.reduce((sum, asset) => sum + (asset.size || 0), 0)
57 const totalSizeMB = (totalSize / 1024 / 1024).toFixed(2)
58 console.log(`Total size: ${totalSizeMB} MB\n`)
59
60 if (imageAssets.length > 0) {
61 console.log('IMAGE ANALYSIS:')
62 console.log('===============')
63
64 const withDimensions = imageAssets.filter((img) => img.metadata?.dimensions)
65 const avgWidth = withDimensions.reduce((sum, img) => sum + (img.metadata?.dimensions?.width || 0), 0) / withDimensions.length
66 const avgHeight = withDimensions.reduce((sum, img) => sum + (img.metadata?.dimensions?.height || 0), 0) / withDimensions.length
67
68 console.log(`Images with dimensions: ${withDimensions.length}/${imageAssets.length}`)
69 if (withDimensions.length > 0) {
70 console.log(`Average dimensions: ${Math.round(avgWidth)}x${Math.round(avgHeight)}`)
71 }
72
73 const imageTypes = imageAssets.reduce((acc, img) => {
74 const type = img.mimeType || 'unknown'
75 acc[type] = (acc[type] || 0) + 1
76 return acc
77 }, {} as Record<string, number>)
78
79 console.log('Image types:')
80 Object.entries(imageTypes).forEach(([type, count]) => {
81 console.log(` ${type}: ${count}`)
82 })
83 console.log('')
84 }
85
86 await analyzeAssetUsage(assets)
87 await generateAssetInventory(assets)
88
89 console.log('Asset audit complete!')
90 } catch (error) {
91 console.error('Error during asset audit:', error)
92 }
93}
94
95async function analyzeAssetUsage(assets: AssetInfo[]) {
96 console.log('ASSET USAGE ANALYSIS:')
97 console.log('=====================')
98
99 let unusedAssets = 0
100 let usedAssets = 0
101
102 for (const asset of assets) {
103 const referencingDocs = await client.fetch(`
104 *[references("${asset._id}")] {
105 _id,
106 _type
107 }
108 `)
109
110 if (referencingDocs.length > 0) {
111 usedAssets++
112 } else {
113 unusedAssets++
114 }
115 }
116
117 console.log(`Used assets: ${usedAssets}`)
118 console.log(`Unused assets: ${unusedAssets}`)
119 console.log('')
120}
121
122async function generateAssetInventory(assets: AssetInfo[]) {
123 const inventory = {
124 generatedAt: new Date().toISOString(),
125 summary: {
126 totalAssets: assets.length,
127 totalImages: assets.filter((a) => a._type === 'sanity.imageAsset').length,
128 totalFiles: assets.filter((a) => a._type === 'sanity.fileAsset').length,
129 totalSizeBytes: assets.reduce((sum, asset) => sum + (asset.size || 0), 0),
130 },
131 assets: assets.map((asset) => ({
132 id: asset._id,
133 type: asset._type,
134 filename: asset.originalFilename,
135 url: asset.url,
136 size: asset.size,
137 mimeType: asset.mimeType,
138 extension: asset.extension,
139 dimensions: asset.metadata?.dimensions,
140 })),
141 }
142
143 const fs = require('fs')
144 fs.writeFileSync('assets-inventory.json', JSON.stringify(inventory, null, 2))
145 console.log('Asset inventory saved to assets-inventory.json')
146}
147
148auditAssets()
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
With that, we should have something like this:
And we can inspect the newly created ./assets-inventory.json
file generated, here's mine:
1{
2 "generatedAt": "2025-08-28T12:32:29.993Z",
3 "summary": {
4 "totalAssets": 6,
5 "totalImages": 6,
6 "totalFiles": 0,
7 "totalSizeBytes": 9788624
8 },
9 "assets": [
10 {
11 "id": "image-87d44663b620c92e956dbfbd3080a6398589c289-1080x1080-png",
12 "type": "sanity.imageAsset",
13 "filename": "image.png",
14 "url": "<https://cdn.sanity.io/images/lhmeratw/production/87d44663b620c92e956dbfbd3080a6398589c289-1080x1080.png>",
15 "size": 1232943,
16 "mimeType": "image/png",
17 "extension": "png",
18 "dimensions": {
19 "_type": "sanity.imageDimensions",
20 "aspectRatio": 1,
21 "height": 1080,
22 "width": 1080
23 }
24 },
25 ]
26}
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
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
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
With that, we'll install Strapi.
# Start Strapi server
cd strapi-project
npm run develop
And start the Strapi server.
Set up an admin account:
After successful account creation, you should see the admin dashboard:
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
Once you’re here, click on Full Access > View Token > Copy
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
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
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
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
This creates a data.ndjson
file where each line is a JSON document representing your content.
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:
1// migration-scripts/analyze-export.js
2const fs = require('fs')
3const readline = require('readline')
4
5async function analyzeExport() {
6 const fileStream = fs.createReadStream('../sanity-export/data.ndjson')
7 const rl = readline.createInterface({
8 input: fileStream,
9 crlfDelay: Infinity
10 })
11
12 const typeCount = {}
13 const sampleDocs = {}
14
15 for await (const line of rl) {
16 const doc = JSON.parse(line)
17
18 // Count document types
19 typeCount[doc._type] = (typeCount[doc._type] || 0) + 1
20
21 // Store samples for known types
22 if (['post', 'person', 'category', 'page', 'product'].includes(doc._type)) {
23 if (!sampleDocs[doc._type]) {
24 sampleDocs[doc._type] = doc
25 }
26 }
27 }
28
29 console.log('Document type counts:', typeCount)
30
31 // Save analysis results
32 fs.writeFileSync('export-analysis.json', JSON.stringify({
33 typeCount,
34 sampleDocs
35 }, null, 2))
36}
37
38analyzeExport()
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
We should have something like this:
Validation and Quality Checks
Review the generated export-analysis.json
to understand your data structure and ensure all content types are present.
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
Content Type Generation in Strapi
The CLI creates a complete Strapi project structure:
1strapi-project/src/
2├── api/
3│ ├── post/
4│ │ ├── content-types/post/schema.json
5│ │ ├── controllers/post.ts
6│ │ ├── routes/post.ts
7│ │ └── services/post.ts
8│ ├── person/
9│ └── category/
10└── components/
11 ├── blocks/
12 └── media/
Example transformation - A Sanity post schema:
1// sanity/schemaTypes/post.js
2export default {
3 name: 'post',
4 type: 'document',
5 fields: [
6 { name: 'title', type: 'string', validation: Rule => Rule.required() },
7 { name: 'slug', type: 'slug', options: { source: 'title' } },
8 { name: 'author', type: 'reference', to: [{ type: 'person' }] },
9 { name: 'categories', type: 'array', of: [{ type: 'reference', to: [{ type: 'category' }] }] },
10 { name: 'body', type: 'array', of: [{ type: 'block' }] },
11 { name: 'publishedAt', type: 'datetime' }
12 ]
13}
Becomes this Strapi schema:
1{
2 "kind": "collectionType",
3 "collectionName": "posts",
4 "info": {
5 "singularName": "post",
6 "pluralName": "posts",
7 "displayName": "Post"
8 },
9 "attributes": {
10 "title": {
11 "type": "string",
12 "required": true
13 },
14 "slug": {
15 "type": "uid",
16 "targetField": "title"
17 },
18 "author": {
19 "type": "relation",
20 "relation": "manyToOne",
21 "target": "api::person.person",
22 "inversedBy": "posts"
23 },
24 "categories": {
25 "type": "relation",
26 "relation": "manyToMany",
27 "target": "api::category.category",
28 "mappedBy": "posts"
29 },
30 "body": {
31 "type": "blocks"
32 },
33 "publishedAt": {
34 "type": "datetime"
35 }
36 }
37}
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:
- Strapi server running with generated schemas
- 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
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
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
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
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
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)
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
And if we visit our Strapi Admin Dashboard, we should see our content.
Handling Import Errors and Retries
The CLI includes automatic retry logic and error handling. If issues occur:
- Check the migration logs for specific errors
- Verify your Strapi server is running
- Ensure API tokens have proper permissions
- Review schema conflicts in the generated reports
Post-Import Validation
After migration, verify your content in the Strapi admin dashboard:
- Check that all content types are present
- Verify relationships are properly connected
- Ensure assets are uploaded and accessible
- Test API endpoints for data consistency
Phase 6: Testing and Going Live
Content Comparison and Validation
Before going live, perform thorough validation:
- Content freeze: Stop updates in Sanity during final testing
- Data comparison: Spot-check content between systems
- Relationship testing: Verify all references work correctly
- 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:
- Original (Sanity): Main branch
- Migrated (Strapi): migrate-to-strapi branch
Step 1: Replace Sanity Client with Strapi API Client
Before - src/sanity/client.ts
:
1import { createClient } from "next-sanity";
2
3export const client = createClient({
4 projectId: "lhmeratw",
5 dataset: "production",
6 apiVersion: "2024-01-01",
7 useCdn: false,
8});
After - Create src/lib/strapi-client.ts
:
1const STRAPI_URL =
2 process.env.NEXT_PUBLIC_STRAPI_URL || "http://localhost:1337";
3const STRAPI_TOKEN = process.env.STRAPI_API_TOKEN;
4
5export async function strapiRequest(
6 endpoint: string,
7 options: RequestInit = {}
8) {
9 const url = `${STRAPI_URL}/api/${endpoint}`;
10
11 const response = await fetch(url, {
12 headers: {
13 "Content-Type": "application/json",
14 ...(STRAPI_TOKEN && { Authorization: `Bearer ${STRAPI_TOKEN}` }),
15 ...options.headers,
16 },
17 ...options,
18 });
19
20 if (!response.ok) {
21 throw new Error(`Strapi request failed: ${response.statusText}`);
22 }
23
24 return response.json();
25}
Step 2: Create Data Adapters
Create src/utils/strapi-adapter.ts
:
1/* eslint-disable @typescript-eslint/no-explicit-any */
2// ./src/utils/strapi-adapter.ts
3
4const STRAPI_URL =
5 process.env.NEXT_PUBLIC_STRAPI_URL || "http://localhost:1337";
6
7export interface StrapiResponse<T = any> {
8 data: T;
9 meta?: {
10 pagination?: {
11 page: number;
12 pageSize: number;
13 pageCount: number;
14 total: number;
15 };
16 };
17}
18
19export interface StrapiEntity {
20 id?: string | number;
21 documentId?: string | number;
22 [key: string]: any;
23}
24
25/* -------------------------
26 Helpers
27------------------------- */
28
29// Standardized slug adapter
30const adaptSlug = (slug?: string) => ({ current: slug ?? "" });
31
32// Standardized image adapter
33const adaptImage = (img?: StrapiEntity) => img?.data ?? null;
34
35// Standardized authors adapter
36const adaptAuthors = (authors?: { data?: StrapiEntity[] }) =>
37 authors?.data?.map(({ name, profilePicture }) => ({
38 name,
39 profilePicture: adaptImage(profilePicture),
40 })) ?? [];
41
42// Standardized categories adapter
43const adaptCategories = (categories?: { data?: StrapiEntity[] }) =>
44 categories?.data?.map(({ title, slug }) => ({
45 title,
46 slug: adaptSlug(slug),
47 })) ?? [];
48
49// Standardized gallery adapter
50const adaptGallery = (gallery?: { data?: StrapiEntity[] }) =>
51 gallery?.data?.map((img) => ({
52 ...img,
53 asset: { _ref: `image-${img.id}` }, // Sanity-like reference
54 })) ?? [];
55
56/* -------------------------
57 Adapters
58------------------------- */
59
60export function adaptStrapiPost(post: StrapiEntity): any {
61 return {
62 _id: String(post.documentId),
63 slug: adaptSlug(post.slug),
64 image: adaptImage(post.image),
65 authors: adaptAuthors(post.authors),
66 categories: adaptCategories(post.categories),
67 ...post, // spread last so overrides don't break critical fields
68 };
69}
70
71export function adaptStrapiProduct(product: StrapiEntity): any {
72 return {
73 _id: String(product.documentId),
74 specifications: {
75 ...product.specifications, // spread instead of manual copy
76 },
77 gallery: adaptGallery(product.gallery),
78 ...product,
79 };
80}
81
82export function adaptStrapiPage(page: StrapiEntity): any {
83 return {
84 _id: String(page.documentId),
85 slug: adaptSlug(page.slug),
86 seo: {
87 ...page.seo,
88 image: adaptImage(page.seo?.image),
89 },
90 ...page,
91 };
92}
93
94/* -------------------------
95 Image URL builder
96------------------------- */
97export function getStrapiImageUrl(
98 imageAttributes: any,
99 baseUrl = STRAPI_URL
100): string | null {
101 const url = imageAttributes?.url;
102 if (!url) return null;
103 return url.startsWith("http") ? url : `${baseUrl}${url}`;
104}
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
:
1import { client } from "@/sanity/client";
2
3const PAGES_QUERY = `*[_type == "page" && defined(slug.current)]|order(title asc){
4 _id, title, slug
5}`;
6
7export interface NavigationPage {
8 _id: string;
9 title: string;
10 slug: { current: string };
11}
12
13export async function getNavigationPages(): Promise<NavigationPage[]> {
14 const options = { next: { revalidate: 60 } };
15 return client.fetch<NavigationPage[]>(PAGES_QUERY, {}, options);
16}
After - Update src/lib/navigation.ts
:
1import { strapiRequest } from "./strapi-client";
2import {
3 adaptStrapiPage,
4 type StrapiResponse,
5 type StrapiEntity,
6} from "@/utils/strapi-adapter";
7
8export interface NavigationPage {
9 _id: string;
10 title: string;
11 slug: { current: string };
12}
13
14export async function getNavigationPages(): Promise<NavigationPage[]> {
15 try {
16 const response: StrapiResponse<StrapiEntity[]> = await strapiRequest(
17 "pages?fields[0]=title&fields[1]=slug&sort=title:asc",
18 { next: { revalidate: 60 } }
19 );
20
21 return response.data.map(adaptStrapiPage)
22 } catch (error) {
23 console.error("Failed to fetch navigation pages:", error);
24 return [];
25 }
26}
27
28// Keep your existing navigation constants
29export const MAIN_NAV_SLUGS = ["about", "contact"];
30export const FOOTER_QUICK_LINKS_SLUGS = ["about", "contact"];
31export const FOOTER_SUPPORT_SLUGS = ["help", "shipping", "returns", "privacy"];
32export const FOOTER_LEGAL_SLUGS = ["terms", "privacy", "cookies"];
Step 4: Update Homepage
Before - src/app/page.tsx
:
1import { client } from "@/sanity/client";
2
3const POSTS_QUERY = `*[
4 _type == "post"
5 && defined(slug.current)
6]|order(publishedAt desc)[0...3]{_id, title, slug, publishedAt, image}`;
7
8const PRODUCTS_QUERY = `*[
9 _type == "product"
10 && available == true
11]|order(_createdAt desc)[0...4]{_id, name, price, gallery}`;
12
13const options = { next: { revalidate: 30 } };
14
15export default async function HomePage() {
16 const [posts, products] = await Promise.all([
17 client.fetch<SanityDocument[]>(POSTS_QUERY, {}, options),
18 client.fetch<SanityDocument[]>(PRODUCTS_QUERY, {}, options),
19 ]);
After - Update src/app/page.tsx
:
1/* eslint-disable @typescript-eslint/no-explicit-any */
2// ./src/app/page.tsx (improved with design system)
3import { strapiRequest } from "@/lib/strapi-client";
4import {
5 adaptStrapiPost,
6 adaptStrapiProduct,
7 getStrapiImageUrl,
8} from "@/utils/strapi-adapter";
9import Image from "next/image";
10import Link from "next/link";
11
12const options = { next: { revalidate: 30 } };
13
14export default async function HomePage() {
15 const [postsResponse, productsResponse] = await Promise.all([
16 strapiRequest(
17 "posts?populate=*&sort=publishedAt:desc&pagination[limit]=3",
18 options
19 ),
20 strapiRequest(
21 "products?populate=*&filters[available][$eq]=true&sort=createdAt:desc&pagination[limit]=4",
22 options
23 ),
24 ]);
25
26 const posts = postsResponse.data.map(adaptStrapiPost);
27 const products = productsResponse.data.map(adaptStrapiProduct);
Update Image Handling in the same file:
1// Replace urlFor() with getStrapiImageUrl()
2{products.map((product) => {
3 const imageUrl = product.gallery?.[0]
4 ? getStrapiImageUrl(product.gallery[0])
5 : null;
6
7 return (
8 <Link key={product._id} href={`/products/${product._id}`}>
9 {imageUrl && (
10 <Image
11 src={imageUrl}
12 alt={product.name}
13 width={300}
14 height={200}
15 />
16 )}
17 {/* Rest of component */}
18 </Link>
19 );
20})}
Step 5: Update Products Page
Before - src/app/products/page.tsx
:
1const PRODUCTS_QUERY = `*[
2 _type == "product"
3]|order(name asc){_id, name, price, available, tags, gallery}`;
4
5export default async function ProductsPage() {
6 const products = await client.fetch<SanityDocument[]>(
7 PRODUCTS_QUERY,
8 {},
9 options
10 );
After - Update the data fetching:
1export default async function ProductsPage() {
2 const response = await strapiRequest(
3 "products?populate=*&sort=name:asc",
4 options
5 );
6 const products = response.data.map(adaptStrapiProduct);
Step 6: Update Blog Pages
Blog Index - src/app/blog/page.tsx
:
1// Before
2const POSTS_QUERY = `*[
3 _type == "post"
4 && defined(slug.current)
5]|order(publishedAt desc){
6 _id, title, slug, publishedAt, image,
7 authors[]->{ name },
8 categories[]->{ title }
9}`;
10
11// After
12export default async function BlogPage() {
13 const response = await strapiRequest(
14 "posts?populate=*&sort=publishedAt:desc",
15 options
16 );
17
18 const posts = response.data.map(adaptStrapiPost);
19}
Blog Post Detail - src/app/blog/[slug]/page.tsx
:
1// Before
2const POST_QUERY = `*[_type == "post" && slug.current == $slug][0]`;
3
4export default async function PostPage({
5 params,
6}: {
7 params: Promise<{ slug: string }>;
8}) {
9 const post = await client.fetch<SanityDocument>(
10 POST_QUERY,
11 await params,
12 options
13 );
14
15// After
16export default async function PostPage({
17 params,
18}: {
19 params: Promise<{ slug: string }>;
20}) {
21 const { slug } = await params;
22
23 const response = await strapiRequest(
24 `posts?populate=*&filters[slug][$eq]=${slug}`,
25 options
26 );
27
28 const post = response.data[0] ? adaptStrapiPost(response.data[0]) : null;
29
30 if (!post) {
31 notFound();
32 }
Step 7: Update Dynamic Pages
Before - src/app/(pages)/[slug]/page.tsx
:
1const PAGE_QUERY = `*[_type == "page" && slug.current == $slug][0]{
2 _id, title, slug, body, seo
3}`;
4
5export default async function DynamicPage({
6 params,
7}: {
8 params: Promise<{ slug: string }>;
9}) {
10 const { slug } = await params;
11 const page = await client.fetch<SanityDocument>(
12 PAGE_QUERY,
13 { slug },
14 options
15 );
After:
1export default async function DynamicPage({
2 params,
3}: {
4 params: Promise<{ slug: string }>;
5}) {
6 const { slug } = await params;
7
8 const response = await strapiRequest(
9 `pages?populate=*&filters[slug][$eq]=${slug}`,
10 options
11 );
12
13 const page = response.data[0] ? adaptStrapiPage(response.data[0]) : null;
14
15 if (!page) {
16 notFound();
17 }
Step 8: Replace Rich Text Renderer
Install Strapi Blocks Renderer:
npm install @strapi/blocks-react-renderer
Before - Using Sanity's PortableText:
1import { PortableText } from "next-sanity";
2
3// In component
4<div className="prose prose-lg prose-emerald max-w-none">
5 {Array.isArray(post.body) && <PortableText value={post.body} />}
6</div>
After - Using Strapi's BlocksRenderer:
1import { BlocksRenderer, type BlocksContent } from '@strapi/blocks-react-renderer';
2
3// In component
4<div className="prose prose-lg prose-emerald max-w-none">
5 {post.body && <BlocksRenderer content={post.body as BlocksContent} />}
6</div>
Step 9: Update Environment Variables
Before - .env.local
:
NEXT_PUBLIC_SANITY_PROJECT_ID=lhmeratw
NEXT_PUBLIC_SANITY_DATASET=production
SANITY_API_TOKEN=your-token
After - .env.local
:
NEXT_PUBLIC_STRAPI_URL=http://localhost:1337
STRAPI_API_TOKEN=your-full-access-token
Step 10: Error Handling and Fallbacks
Create src/lib/strapi-client.ts
with robust error handling:
1export async function strapiRequest(endpoint: string, options: RequestInit = {}) {
2 try {
3 const url = `${STRAPI_URL}/api/${endpoint}`
4
5 const response = await fetch(url, {
6 headers: {
7 'Content-Type': 'application/json',
8 ...(STRAPI_TOKEN && { Authorization: `Bearer ${STRAPI_TOKEN}` }),
9 ...options.headers,
10 },
11 ...options,
12 })
13
14 if (!response.ok) {
15 console.error(`Strapi API Error: ${response.status} ${response.statusText}`)
16
17 // Return empty data structure for graceful fallback
18 return { data: [], meta: {} }
19 }
20
21 return response.json()
22 } catch (error) {
23 console.error('Strapi request failed:', error)
24 return { data: [], meta: {} }
25 }
26}
With that, we have a fully migrated site, from Sanity to Strapi 🎊
GitHub Source Code
- Strapi Project - branch
generated-schema-structure-from-v4
- Sanity Project
- NextJS Site (Sanity)
- NextJS Site (Strapi) - branch
migrate-to-strapi
- Sanity to Strapi Migration Workspace - Containing the migration scripts and example sanity export
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
- 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
- Compare outputs by temporarily logging both data structures:
1console.log('Sanity data:', sanityPosts)
2console.log('Strapi data:', strapiPosts)
- Validate all pages load without errors
- Check image rendering and links work correctly
- 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:
- Content freeze: Stop all content updates in Sanity
- Final sync: Run one last migration to catch any changes
- DNS preparation: Have CDN/DNS changes ready to deploy
- Rollback plan: Document exactly how to revert if things go wrong
- Team notification: Make sure everyone knows the switch is happening
Going Live Process:
- Deploy your updated frontend with Strapi integration
- Deploy your Strapi project to Strapi Cloud.
- Update environment variables to point to production Strapi
- Test all critical user flows
- Monitor error rates and performance metrics
- Have your rollback plan ready to execute if needed
Key Success Factors:
- Plan extensively - The more you understand your current setup, the smoother the migration
- Validate everything - Don't trust that the migration worked until you've verified it
- Have rollback ready - Things can go wrong, and you need to be able to recover quickly
- 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:
1## Quick Strapi Guide for Content Editors
2
3### Creating a Blog Post
41. Navigate to Content Manager → Posts
52. Click "Create new entry"
63. Fill in title (slug will auto-generate)
74. Set published date
85. Select authors from the dropdown (multiple selection available)
96. Choose categories
107. Upload a featured image
118. Write content in the rich text editor
129. Save & Publish
13
14### Managing Authors (People)
151. Go to Content Manager → People
162. Add name and email
173. Write bio using the rich text editor
184. Upload profile picture
195. Save & Publish
20
21### Creating Products
221. Navigate to Content Manager → Products
232. Enter product name and price
243. Set availability status
254. Add tags as JSON array: ["tag1", "tag2"]
265. Upload multiple images to the gallery
276. Fill in specifications (weight, dimensions, material)
287. Save & Publish
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:
- Deploy your new Strapi project in just few clicks to Strapi cloud.
- Visit the Strapi marketplace to install plugins to power up your Strapi application.
- 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.