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.
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.
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.
Sanity and Strapi are both headless CMSs, but that's where the similarities end. Here are the fundamental differences:
Sanity's Approach:
Strapi's Approach:
Based on recent migration requests, the reasons usually fall into these categories:
Benefits:
Challenges:
Before starting this migration, ensure you have:
Technical Requirements:
Required Tools:
# Install global CLI tools
npm install -g @sanity/cli @strapi/strapiRecommended Knowledge:
This migration process consists of six main phases:
We'll use a custom CLI tool that automates much of the heavy lifting while providing detailed reports and error handling throughout the process.
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.
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-tokenWe should have something like this:
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.
From our example schemas, here's what we're working with:
Run the relationship analyzer:
npx sanity exec analyzeRelationships.ts --with-user-tokenWith that, we should have something like this:
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-tokenWith 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}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 reportsInstall 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 csvtojsonCreate and configure your new Strapi project:
# Create new Strapi project
npx create-strapi-app@latest strapi-project --quickstartWith that, we'll install Strapi.
# Start Strapi server
cd strapi-project
npm run developAnd start the Strapi server.
Set up an admin account:
After successful account creation, you should see the admin dashboard:
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.
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.gzFor 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).sqlSanity 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-exportThe 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-exportThis creates a data.ndjson file where each line is a JSON document representing your content.
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.jsWe should have something like this:
Review the generated export-analysis.json to understand your data structure and ensure all content types are present.
Now we'll use the automated CLI tool to handle the complex schema transformation process:
# Generate schemas only
npx @untools/sanity-strapi-cli@latest schemas \
--sanity-project ../studio-first-project \
--sanity-export ./sanity-export \
--strapi-project ./strapi-projectThe 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}The CLI automatically handles:
Once schemas are ready, migrate your actual content and media assets.
Prerequisites:
# 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:1337Choose 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 strapiOption 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 cloudinaryFor 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:1337The migration generates detailed reports:
For a guided setup experience that will handle both schema generation and content migration:
# Guided setup with prompts
npx @untools/sanity-strapi-cli@latest --interactiveThis will prompt you for:
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.jsonAnd if we visit our Strapi Admin Dashboard, we should see our content.
The CLI includes automatic retry logic and error handling. If issues occur:
After migration, verify your content in the Strapi admin dashboard:
Before going live, perform thorough validation:
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.
We'll migrate an example Next.js site with:
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}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.
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"];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})}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);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 }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 }Install Strapi Blocks Renderer:
npm install @strapi/blocks-react-rendererBefore - 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>Before - .env.local:
NEXT_PUBLIC_SANITY_PROJECT_ID=lhmeratw
NEXT_PUBLIC_SANITY_DATASET=production
SANITY_API_TOKEN=your-tokenAfter - .env.local:
NEXT_PUBLIC_STRAPI_URL=http://localhost:1337
STRAPI_API_TOKEN=your-full-access-tokenCreate 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 🎊
generated-schema-structure-from-v4migrate-to-strapi| 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 |
# Terminal 1: Start Strapi
cd strapi-project && npm run develop
# Terminal 2: Start Next.js
cd frontend && npm run dev1console.log('Sanity data:', sanityPosts)
2console.log('Strapi data:', strapiPosts)This systematic approach ensures your frontend continues working seamlessly after the CMS migration while maintaining the same user experience.
Monitor these key metrics during the transition:
Pre-Launch Checklist:
Going Live Process:
Key Success Factors:
What You've Accomplished:
By following this guide, you've successfully:
Benefits Achieved:
Ongoing Maintenance:
Your new Strapi setup requires different maintenance considerations:
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 & PublishMigrating 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:
Remember: this migration is a journey, not a destination. Your new Strapi setup should be the foundation for even better content management experiences ahead.