Skip to main content

Understanding the mapping process

Design target schema

Map source content to ButterCMS content types:

Choosing the right ButterCMS content type

Source ContentBest ButterCMS MatchReason
Blog postsBlog EngineBuilt-in author, categories, tags, SEO
Landing pagesPage TypeCustom fields, components, SEO
Product pagesPage TypeCustom schema per product type
Team membersCollectionReference data used across pages
CategoriesCollectionReusable taxonomy
TestimonialsCollectionReferenced by multiple pages
FAQsCollectionStructured Q&A data
SettingsCollectionSite-wide configuration
NavigationCollectionMenu structure data

Mapping field types

Source Field TypeButterCMS Field TypeTransformation Notes
Plain textShort TextTruncate if exceeds limits
Long textLong Text or WYSIWYGChoose based on formatting needs
Rich text/HTMLWYSIWYGPreserve HTML, clean up if needed
IntegerNumberDirect mapping
DecimalNumberDirect mapping
BooleanCheckboxDirect mapping
DateDateConvert to ISO format
DateTimeDateMay lose time component
Single selectDropdownDefine options in schema
Multi-selectRepeater or ReferenceDepends on use case
Single imageMediaUpload to CDN
GalleryRepeater with MediaOne media field per item
FileMediaUpload to CDN
Single referenceReference (One-to-One)Map to collection/page
Multiple referencesReference (One-to-Many)Map to collection/page
JSON/ObjectLong Text or RepeaterStore as JSON or flatten
GeolocationShort TextStore as “lat,lng” string

Mapping documentation template

Document your mappings for team reference:
# Content Mapping Documentation

## Blog Posts

### Source: WordPress Post
### Target: ButterCMS Page Type "blog_post"

| Source Field | Target Field | Transformation |
|-------------|--------------|----------------|
| post_title | title | Direct mapping |
| post_content | body | Clean shortcodes |
| post_excerpt | summary | Generate if empty |
| post_date | publish_date | ISO format |
| post_status | status | Map to published/draft |
| post_name | slug | Direct mapping |
| featured_media | featured_image | Resolve to URL |
| author | author | Map ID to slug |
| categories | categories | Map IDs to slugs |
| tags | tags | Map IDs to slugs |

### Notes
- Author must exist in blog_authors collection before post migration
- Categories must exist in blog_categories collection before post migration
- Images are automatically served via ButterCMS CDN

Create transformation rules

Field transformations

// transformation-rules.js

const transformations = {
  // Text transformations
  text: {
    // Truncate to max length
    truncate: (value, maxLength) => {
      if (!value || value.length <= maxLength) return value;
      return value.substring(0, maxLength - 3) + '...';
    },

    // Clean HTML tags
    stripHtml: (value) => {
      return value?.replace(/<[^>]*>/g, '') || '';
    },

    // Generate slug from title
    slugify: (value) => {
      return value
        ?.toLowerCase()
        .replace(/[^a-z0-9]+/g, '-')
        .replace(/^-|-$/g, '') || '';
    }
  },

  // HTML transformations
  html: {
    // Fix relative URLs to absolute
    fixUrls: (html, baseUrl) => {
      return html?.replace(
        /(?:src|href)="(\/[^"]+)"/g,
        (match, path) => match.replace(path, baseUrl + path)
      ) || '';
    },

    // Remove WordPress shortcodes
    removeShortcodes: (html) => {
      return html?.replace(/\[[^\]]+\]/g, '') || '';
    },

    // Clean empty paragraphs
    cleanEmpty: (html) => {
      return html?.replace(/<p>\s*<\/p>/g, '') || '';
    }
  },

  // Date transformations
  date: {
    // Convert to ISO format
    toISO: (value) => {
      const date = new Date(value);
      return isNaN(date) ? null : date.toISOString();
    },

    // Parse various formats
    parse: (value, format) => {
      // Add custom parsing logic for specific formats
      return new Date(value).toISOString();
    }
  },

  // Reference transformations
  reference: {
    // Convert IDs to slugs
    idToSlug: (id, lookupTable) => {
      return lookupTable[id] || null;
    },

    // Ensure array for one-to-many
    ensureArray: (value) => {
      if (!value) return [];
      return Array.isArray(value) ? value : [value];
    }
  },

  // Media transformations
  media: {
    // Ensure HTTPS
    secureUrl: (url) => {
      return url?.replace(/^http:/, 'https:') || '';
    },

    // Extract CDN URL
    extractCdn: (mediaObject) => {
      return mediaObject?.url || mediaObject?.src || mediaObject || '';
    }
  }
};

Content type transformers

// transformers.js

class BlogPostTransformer {
  constructor(lookups) {
    this.authorLookup = lookups.authors || {};
    this.categoryLookup = lookups.categories || {};
    this.tagLookup = lookups.tags || {};
  }

  transform(sourcePost) {
    return {
      "page-type": "blog_post",
      status: this.mapStatus(sourcePost.status),
      title: sourcePost.title,
      slug: this.generateSlug(sourcePost),
      fields: {
        // Core fields
        title: sourcePost.title,
        body: this.transformBody(sourcePost.content),
        summary: this.generateSummary(sourcePost),

        // Date fields
        publish_date: transformations.date.toISO(sourcePost.published_at),

        // References
        author: this.transformAuthor(sourcePost.author),
        categories: this.transformCategories(sourcePost.categories),
        tags: this.transformTags(sourcePost.tags),

        // SEO fields
        seo: {
          title: sourcePost.meta_title || sourcePost.title,
          description: sourcePost.meta_description || this.generateSummary(sourcePost),
          og_image: transformations.media.secureUrl(sourcePost.featured_image)
        }
      }
    };
  }

  mapStatus(sourceStatus) {
    const statusMap = {
      'publish': 'published',
      'published': 'published',
      'draft': 'draft',
      'pending': 'draft',
      'private': 'draft'
    };
    return statusMap[sourceStatus] || 'draft';
  }

  generateSlug(post) {
    return post.slug || transformations.text.slugify(post.title);
  }

  transformBody(content) {
    let html = content || '';

    // Clean up common issues
    html = transformations.html.removeShortcodes(html);
    html = transformations.html.cleanEmpty(html);

    return html;
  }

  generateSummary(post) {
    if (post.excerpt) return post.excerpt;

    // Extract from body
    const stripped = transformations.text.stripHtml(post.content);
    return transformations.text.truncate(stripped, 160);
  }

  transformAuthor(author) {
    if (!author) return null;

    // Convert ID to slug
    if (typeof author === 'number' || typeof author === 'string') {
      return this.authorLookup[author] || null;
    }

    // Already an object
    return author.slug || transformations.text.slugify(author.name);
  }

  transformCategories(categories) {
    if (!categories) return [];

    return categories
      .map(cat => {
        if (typeof cat === 'number' || typeof cat === 'string') {
          return this.categoryLookup[cat];
        }
        return cat.slug || transformations.text.slugify(cat.name);
      })
      .filter(Boolean);
  }

  transformTags(tags) {
    if (!tags) return [];

    return tags
      .map(tag => {
        if (typeof tag === 'number' || typeof tag === 'string') {
          return this.tagLookup[tag];
        }
        return tag.slug || transformations.text.slugify(tag.name);
      })
      .filter(Boolean);
  }
}

Handle special cases

Relationship mapping

References require special handling during migration: Migration order matters:
  1. First, migrate Collections (authors, categories, tags)
  2. Note the slugs or IDs of created items
  3. Then migrate Pages/Posts with references to those items
async function migrateWithReferences(data) {
  const lookups = {};

  // Step 1: Migrate authors collection
  console.log('Migrating authors...');
  lookups.authors = {};
  for (const author of data.authors) {
    const result = await createCollectionItem('blog_authors', {
      en: {
        name: author.name,
        slug: author.slug || slugify(author.name),
        bio: author.bio
      }
    });
    lookups.authors[author.id] = result.data.fields.slug;
  }

  // Step 2: Migrate categories
  console.log('Migrating categories...');
  lookups.categories = {};
  for (const category of data.categories) {
    const result = await createCollectionItem('blog_categories', {
      en: {
        name: category.name,
        slug: category.slug || slugify(category.name)
      }
    });
    lookups.categories[category.id] = result.data.fields.slug;
  }

  // Step 3: Migrate posts with references
  console.log('Migrating posts...');
  const transformer = new BlogPostTransformer(lookups);

  for (const post of data.posts) {
    const butterPost = transformer.transform(post);
    await createPage(butterPost);
  }
}

Handling rich text content

Rich text often needs cleanup during migration:
const cheerio = require('cheerio');

function cleanRichText(html) {
  const $ = cheerio.load(html);

  // Remove inline styles
  $('[style]').removeAttr('style');

  // Remove empty elements
  $('p:empty, span:empty, div:empty').remove();

  // Fix image sources
  $('img').each((i, el) => {
    const src = $(el).attr('src');
    if (src && !src.startsWith('http')) {
      $(el).attr('src', 'https://example.com' + src);
    }
  });

  // Remove WordPress-specific classes
  $('[class*="wp-"]').removeClass(function(i, className) {
    return className.split(' ').filter(c => c.startsWith('wp-')).join(' ');
  });

  // Convert shortcodes to proper HTML
  // [button url="..." text="..."] -> <a href="..." class="button">...</a>
  let result = $.html();
  result = result.replace(
    /\[button url="([^"]+)" text="([^"]+)"\]/g,
    '<a href="$1" class="button">$2</a>'
  );

  return result;
}

Handling media assets

async function migrateMediaAssets(sourceMedia) {
  const mediaMapping = {};

  for (const media of sourceMedia) {
    // Option 1: Use URL directly (ButterCMS will serve via CDN)
    mediaMapping[media.id] = media.url;

    // Option 2: Download and upload to ButterCMS Media Library
    // (If you need to ensure all assets are in ButterCMS)

    // Media field URLs uploaded via Write API are saved to the media library.
  }

  return mediaMapping;
}

// Use in content transformation
function transformImageField(imageId, mediaMapping) {
  const url = mediaMapping[imageId];
  if (!url) return null;

  // Ensure HTTPS
  return url.replace(/^http:/, 'https:');
}

Handling localized content

function transformLocalizedContent(sourceContent, locales) {
  const result = {};

  for (const locale of locales) {
    // Map source locale code to ButterCMS locale
    const butterLocale = mapLocaleCode(locale.code);

    // Get localized fields
    result[butterLocale] = {
      title: sourceContent.title[locale.code] || sourceContent.title.default,
      body: sourceContent.body[locale.code] || sourceContent.body.default,
      // ... other fields
    };
  }

  return result;
}

function mapLocaleCode(sourceCode) {
  // Map common locale formats
  const mapping = {
    'en_US': 'en',
    'en-us': 'en',
    'de_DE': 'de',
    'de-de': 'de',
    'fr_FR': 'fr',
    'fr-fr': 'fr'
  };

  return mapping[sourceCode] || sourceCode.split(/[-_]/)[0];
}

Validate transformations

Always validate transformed data before uploading:
class TransformationValidator {
  constructor(schema) {
    this.schema = schema;
    this.errors = [];
  }

  validate(transformedData) {
    this.errors = [];

    // Check required fields
    for (const [field, config] of Object.entries(this.schema)) {
      if (config.required && !transformedData.fields?.[field]) {
        this.errors.push(`Missing required field: ${field}`);
      }
    }

    // Check field types
    for (const [field, value] of Object.entries(transformedData.fields || {})) {
      const expectedType = this.schema[field]?.type;
      if (expectedType && !this.checkType(value, expectedType)) {
        this.errors.push(`Invalid type for ${field}: expected ${expectedType}`);
      }
    }

    // Check slug format
    if (transformedData.slug && !/^[a-z0-9-]+$/.test(transformedData.slug)) {
      this.errors.push(`Invalid slug format: ${transformedData.slug}`);
    }

    // Check reference validity
    for (const [field, config] of Object.entries(this.schema)) {
      if (config.type === 'reference') {
        const value = transformedData.fields?.[field];
        if (value && !this.validateReference(value, config)) {
          this.errors.push(`Invalid reference for ${field}`);
        }
      }
    }

    return this.errors.length === 0;
  }

  checkType(value, expectedType) {
    switch (expectedType) {
      case 'string':
        return typeof value === 'string';
      case 'number':
        return typeof value === 'number';
      case 'boolean':
        return typeof value === 'boolean';
      case 'array':
        return Array.isArray(value);
      case 'date':
        return !isNaN(new Date(value));
      default:
        return true;
    }
  }

  validateReference(value, config) {
    if (config.oneToMany) {
      return Array.isArray(value);
    }
    return typeof value === 'string';
  }

  getErrors() {
    return this.errors;
  }
}

Data transformation tools

jq (JSON processing)

# Extract and transform JSON
cat export.json | jq '.posts | map({
  "page-type": "blog_post",
  title: .title,
  slug: .slug,
  fields: {
    title: .title,
    body: .content
  }
})' > buttercms_import.json

Python pandas (CSV processing)

import pandas as pd
import json

# Read CSV
df = pd.read_csv('content.csv')

# Transform to ButterCMS format
pages = []
for _, row in df.iterrows():
    pages.append({
        'page-type': 'blog_post',
        'title': row['title'],
        'slug': row['slug'],
        'status': 'draft',
        'fields': {
            'title': row['title'],
            'body': row['content'],
            'summary': row['excerpt']
        }
    })

# Save for import
with open('import.json', 'w') as f:
    json.dump({'pages': pages}, f, indent=2)