Content Migration During Website Redesign
Redesign is almost always accompanied by content structure changes: URL schemes, page hierarchies, field formats. Content migration during redesign differs from CMS migration in that the platform may stay the same, but data semantics change.
Content Audit Before Redesign
# Scan all site URLs
npx screaming-frog-seo-spider --crawl https://mysite.com --headless \
--export-tabs "Crawl Overview,Internal,Response Codes" \
--output-folder ./crawl-results
# Or via sitemap
curl https://mysite.com/sitemap.xml | grep '<loc>' | sed 's/<[^>]*>//g' > urls.txt
wc -l urls.txt
For each page, record:
- URL (old and new)
- Content type
- Unique elements (videos, galleries, forms)
- SEO importance (traffic from Google Analytics)
URL Mapping
// scripts/generate-redirects.ts
// Generate redirects file based on mapping
const urlMapping: Record<string, string> = {
'/blog/category/web-development': '/web-development',
'/services/web-design': '/services/design',
'/about-us/team': '/team',
'/portfolio': '/work',
};
// For Next.js
const redirects = Object.entries(urlMapping).map(([source, destination]) => ({
source,
destination,
permanent: true,
}));
// For Nginx
const nginxRules = Object.entries(urlMapping)
.map(([from, to]) => `rewrite ^${from}$ ${to} permanent;`)
.join('\n');
Transforming Content Structure
Example: restructure blog with new fields:
// Was: simple post with body
// Became: post with intro + body (StreamField) + callout + related_posts
async function transformPost(oldPost: OldPost): Promise<NewPost> {
return {
title: oldPost.title,
slug: oldPost.slug,
intro: extractIntro(oldPost.body), // first paragraph
body: convertToStreamField(oldPost.body),
publishedAt: oldPost.date,
author: await findOrCreateAuthor(oldPost.authorName),
tags: oldPost.tags,
seoTitle: oldPost.seoTitle || oldPost.title,
seoDescription: oldPost.seoDescription || extractIntro(oldPost.body, 160),
};
}
function extractIntro(html: string, maxChars = 250): string {
const firstParagraph = html.match(/<p[^>]*>(.*?)<\/p>/s)?.[1] ?? '';
const text = firstParagraph.replace(/<[^>]*>/g, '');
return text.slice(0, maxChars).trim();
}
Processing Media Files
When moving to different storage, update all URLs in content:
async function updateMediaUrls(content: string, urlMap: Map<string, string>): string {
return content.replace(
/https:\/\/old-domain\.com\/wp-content\/uploads\/([^\s"']+)/g,
(match, path) => urlMap.get(path) || `https://cdn.newdomain.com/${path}`
);
}
// Upload media files and create mapping
async function migrateMedia(oldUrls: string[]) {
const urlMap = new Map<string, string>();
for (const url of oldUrls) {
const buffer = await downloadFile(url);
const key = url.split('/uploads/')[1];
const newUrl = await uploadToS3(buffer, key);
urlMap.set(key, newUrl);
}
return urlMap;
}
Parallel Launch During Redesign
- Content freeze — 2 weeks before final migration start
- Migrate-and-verify — migrate data, verify key pages
- Parallel run — staging with new design and real content
- DNS cutover — switch during low traffic
- Post-launch crawl — check redirects and 404s
Result Validation
# Check all old URLs return either 301 or 200
while IFS= read -r url; do
status=$(curl -s -o /dev/null -w "%{http_code}" "$url")
echo "$status $url"
done < old-urls.txt | grep -v "^301\|^200" > broken.txt
Content migration during medium site redesign (100–500 pages) — 2–4 weeks.







