Tutorials

How to Migrate from Sanity to Decoupled.io

Jay Callicott··8 min read

How to Migrate from Sanity to Decoupled.io

Sanity is a developer-friendly CMS with real-time collaboration and a powerful query language. But those strengths come with trade-offs that get harder to ignore over time: a proprietary query language your team can only use with Sanity, per-seat pricing that punishes growth, and no option to self-host the backend.

If you're evaluating alternatives, here's how to move from Sanity to Decoupled.io while preserving your content and improving your frontend developer experience.

Why Migrate

GROQ is a dead-end skill. Sanity's query language is genuinely powerful, but it only works with Sanity. Every GROQ query your team writes is knowledge that doesn't transfer to any other platform. Decoupled.io's typed client generates queries from your schema — you write TypeScript, not a proprietary query language.

Per-seat pricing adds up. Sanity's Growth plan charges $15/seat/month. A team of 10 is $150/month before you've stored a single document. Worse, the free tier has hard caps — when you hit limits, API calls are blocked, not throttled. Decoupled.io doesn't charge per seat.

No visual page builder. Sanity Studio is flexible and customizable, but building landing pages requires developers to create custom components for every layout variation. Decoupled.io includes a visual page builder with paragraphs (hero sections, card groups, CTAs) that content editors can assemble without developer involvement.

What You'll Need

  • A Decoupled.io account
  • Access to your Sanity project (to export content)
  • Your frontend codebase
  • About 2-4 hours for a typical migration

Step-by-Step Migration Plan

Step 1: Export Content from Sanity's Content Lake

Use the Sanity CLI to export your dataset:

sanity dataset export production ./sanity-export.tar.gz

This exports all documents and assets from your Sanity project. The export is in NDJSON format (newline-delimited JSON), which is straightforward to parse and transform.

Step 2: Map Sanity Document Types to Drupal Content Types

Review your Sanity schema definitions and create corresponding content types in Decoupled.io:

Sanity Decoupled.io (Drupal)
Document type Content Type
Object type Paragraph / Field Group
Reference Entity Reference
Image asset Media (Image)
Block content Rich Text / Paragraphs
Array of blocks Paragraphs field
Slug URL alias (automatic)

Sanity's "block content" (Portable Text) maps to either a formatted text field or Drupal's Paragraphs system. If you're using Portable Text for structured layouts, Paragraphs is the better fit — it gives content editors a visual builder for assembling sections.

Step 3: Transform and Import Content

Write a migration script that reads the NDJSON export and maps each document to the corresponding Drupal content type. Drupal's Migrate API handles the import, including resolving references between documents and downloading assets.

For each document type, define the field mapping — which Sanity field populates which Drupal field. Most fields map directly: strings to text fields, references to entity references, images to media entities.

Step 4: Generate the Typed Client

With your content in Decoupled.io, generate the typed client:

npx decoupled-cli@latest schema sync

This creates TypeScript interfaces from your Drupal schema — full type safety and IDE autocomplete, without learning a new query language.

Step 5: Replace GROQ Queries with Typed Client Calls

Go through your frontend and replace GROQ queries with typed client calls. This is where the migration pays off — you're replacing a proprietary query language with standard TypeScript.

What Changes in Your Frontend Code

The biggest change is dropping GROQ entirely. Here's a typical before and after:

Before (Sanity + GROQ):

import { createClient } from '@sanity/client'

const client = createClient({
  projectId: 'your-project-id',
  dataset: 'production',
  useCdn: true,
})

const posts = await client.fetch(
  `*[_type == "post"] | order(publishedAt desc) [0...10] {
    title,
    slug,
    publishedAt,
    "author": author->name,
    "image": mainImage.asset->url
  }`
)

After (decoupled-client):

import { createClient } from 'decoupled-client'
import { createTypedClient } from './schema/client'

const base = createClient({
  baseUrl: process.env.NEXT_PUBLIC_DRUPAL_BASE_URL!,
  clientId: process.env.DRUPAL_CLIENT_ID!,
  clientSecret: process.env.DRUPAL_CLIENT_SECRET!,
})

const client = createTypedClient(base)

const posts = await client.getEntries('NodePost', {
  first: 10,
  sortKey: 'CREATED_AT',
  reverse: true,
})

No query language to learn. No string-based queries that can silently break. The typed client knows your schema, so posts[0].title is typed as string, and trying to access posts[0].nonExistentField is a compile-time error.

What Stays the Same

  • Content modeling — You still define document types with fields. Drupal's content modeling is more mature (entity references, revisions, content moderation are built in), but the concepts are familiar.
  • Structured content — Your content stays structured and presentation-agnostic. Drupal's API delivers clean data, just like Sanity's Content Lake.
  • Editorial experience — Content editors still use a web-based admin interface. Drupal's admin UI is different from Sanity Studio, but the workflow — create, edit, preview, publish — is the same.
  • Frontend freedom — You keep your React/Next.js/Astro frontend. Only the data-fetching layer changes.

Next Steps

  1. Get started with Decoupled.io — Create your account and set up your first space.
  2. See how Decoupled.io compares to Sanity — Detailed comparison of features, pricing, and architecture.
  3. Explore pricing — No per-seat charges, no hard API caps.
  4. Learn the typed client — Full documentation for queries, filtering, sorting, and pagination.