Una migración WordPress es un problema de grafo antes que de código. Cada URL que servía el sitio antiguo es un contrato con un crawler, un backlink, un suscriptor de RSS o un email enviado hace cinco años. La migración tiene éxito cuando cada URL del inventario sigue resolviendo al destino correcto tras el cutover. Falla cuando alguien se olvida de las páginas de attachment.
Los cinco archivos de abajo componen una migración que conserva los rankings: el exporter REST, el transformador HTML→MDX, el uploader de medios a R2, el mapa de redirects en el Edge, y el sitemap que anuncia continuidad a Googlebot.
1. El exporter REST API
WordPress expone los contenidos en /wp-json/wp/v2/* con paginación vía los query parameters page y per_page. El exporter camina cada endpoint, escribe un archivo JSON por item y es idempotente al re-run. Exportamos HTML crudo para que lo consuma el transformador y los campos de metadata directamente para el frontmatter.
// scripts/migration/export-wordpress.ts
import { writeFile, mkdir } from 'node:fs/promises'
import { join } from 'node:path'
const WP_API = process.env.WP_API_URL // e.g. https://old.example.com/wp-json/wp/v2
const OUT_DIR = './data/wp-export'
const TYPES = ['posts', 'pages', 'categories', 'tags', 'media'] as const
async function fetchPaginated(type: string): Promise<unknown[]> {
const items: unknown[] = []
let page = 1
// La REST API limita per_page a 100; la cabecera X-WP-TotalPages guía el loop.
while (true) {
const res = await fetch(
`${WP_API}/${type}?per_page=100&page=${page}&_embed=1`,
{ headers: { 'User-Agent': 'adamarant-migration/1.0' } },
)
if (!res.ok) throw new Error(`${type} page ${page}: ${res.status}`)
const batch = (await res.json()) as unknown[]
items.push(...batch)
const totalPages = Number(res.headers.get('X-WP-TotalPages') ?? '1')
if (page >= totalPages) break
page += 1
}
return items
}
async function main(): Promise<void> {
await mkdir(OUT_DIR, { recursive: true })
for (const type of TYPES) {
console.log(`exporting ${type}…`)
const items = await fetchPaginated(type)
await writeFile(
join(OUT_DIR, `${type}.json`),
JSON.stringify(items, null, 2),
'utf8',
)
console.log(` ${items.length} ${type} written`)
}
}
void main()
2. El transformador HTML→MDX
Cada post del export pasa a ser un archivo MDX con frontmatter tipado y un cuerpo que es HTML despojado del ruido de WordPress. Los estilos inline desaparecen, los wrappers de bloque Gutenberg se desenvuelven, los shortcodes pasan a ser componentes React, las URLs de las imágenes saltan al path R2. El transformador es una función pura; ejecutarlo dos veces produce el mismo resultado.
// scripts/migration/transform.ts
import { readFile, writeFile, mkdir } from 'node:fs/promises'
import { join } from 'node:path'
import { JSDOM } from 'jsdom'
import { MEDIA_MAP } from './media-map.json' assert { type: 'json' }
interface WPPost {
id: number
slug: string
date_gmt: string
modified_gmt: string
title: { rendered: string }
excerpt: { rendered: string }
content: { rendered: string }
categories: number[]
tags: number[]
featured_media: number
yoast_head_json?: { canonical?: string; og_description?: string }
}
function rewriteImages(html: string): string {
const dom = new JSDOM(`<body>${html}</body>`)
const doc = dom.window.document
doc.querySelectorAll('img').forEach((img) => {
const src = img.getAttribute('src') ?? ''
const newSrc = (MEDIA_MAP as Record<string, string>)[src]
if (newSrc) img.setAttribute('src', newSrc)
img.removeAttribute('srcset') // R2 + next/image se encarga
img.removeAttribute('sizes')
})
doc.querySelectorAll('[style]').forEach((el) => el.removeAttribute('style'))
doc.querySelectorAll('.wp-block-image, .wp-block-paragraph').forEach((el) => {
while (el.firstChild) el.parentNode?.insertBefore(el.firstChild, el)
el.remove()
})
return doc.body.innerHTML
}
function toFrontmatter(post: WPPost, categorySlug: string): string {
return [
'---',
`title: ${JSON.stringify(post.title.rendered)}`,
`slug: ${post.slug}`,
`published_at: ${post.date_gmt}Z`,
`updated_at: ${post.modified_gmt}Z`,
`description: ${JSON.stringify(post.excerpt.rendered.replace(/<[^>]+>/g, '').trim())}`,
`category: ${categorySlug}`,
post.yoast_head_json?.canonical
? `canonical: ${post.yoast_head_json.canonical}`
: null,
'---',
'',
]
.filter(Boolean)
.join('\n')
}
export async function transformPost(
post: WPPost,
categorySlug: string,
outDir: string,
): Promise<void> {
const body = rewriteImages(post.content.rendered)
const mdx = toFrontmatter(post, categorySlug) + body
await mkdir(outDir, { recursive: true })
await writeFile(join(outDir, `${post.slug}.mdx`), mdx, 'utf8')
}
3. El uploader de medios a R2
Cada imagen del directorio de uploads de WordPress se mueve a un único bucket R2 bajo un path content-addressed. El hash hace la URL estable para siempre; la invalidación de caché deja de ser un problema porque la URL cambia cuando cambian los bytes. El uploader emite un mapa de URL vieja a URL nueva que el transformador lee.
// scripts/migration/upload-media.ts
import { createHash } from 'node:crypto'
import { readFile, writeFile } from 'node:fs/promises'
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3'
const r2 = new S3Client({
region: 'auto',
endpoint: `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: process.env.R2_ACCESS_KEY_ID!,
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
},
})
interface MediaItem {
source_url: string
mime_type: string
}
async function uploadOne(item: MediaItem): Promise<[string, string]> {
const res = await fetch(item.source_url)
if (!res.ok) throw new Error(`fetch ${item.source_url}: ${res.status}`)
const buf = Buffer.from(await res.arrayBuffer())
const hash = createHash('sha256').update(buf).digest('hex').slice(0, 16)
const ext = item.source_url.split('.').pop()!.toLowerCase()
const key = `media/${hash}.${ext}`
await r2.send(
new PutObjectCommand({
Bucket: process.env.R2_BUCKET!,
Key: key,
Body: buf,
ContentType: item.mime_type,
CacheControl: 'public, max-age=31536000, immutable',
}),
)
return [item.source_url, `https://cdn.adamarant.com/${key}`]
}
export async function uploadAll(items: MediaItem[]): Promise<void> {
const map: Record<string, string> = {}
// Concurrencia de 8 cabe dentro de los límites de R2 y satura la red local.
const queue = [...items]
const workers = Array.from({ length: 8 }, async () => {
while (queue.length) {
const item = queue.shift()!
const [oldUrl, newUrl] = await uploadOne(item)
map[oldUrl] = newUrl
}
})
await Promise.all(workers)
await writeFile(
'./scripts/migration/media-map.json',
JSON.stringify(map, null, 2),
'utf8',
)
}
4. El mapa de redirects en el Edge
Cada URL del CSV de inventario pasa a ser una entrada en redirects.json. Next.js la consume en build y Vercel sirve los redirects desde el Edge antes incluso de que arranque el runtime Node. La latencia del 301 está en un solo dígito de milisegundos; los rankings se trasladan porque Googlebot respeta los redirects permanentes dentro del mismo dominio de forma fiable.
// next.config.ts
import type { NextConfig } from 'next'
import redirectsFromInventory from './data/redirects.json' assert { type: 'json' }
interface RedirectEntry {
source: string
destination: string
permanent: true
}
const config: NextConfig = {
async redirects(): Promise<RedirectEntry[]> {
// El CSV de inventario se procesa offline a redirects.json; la CI falla
// si el recuento cae respecto a la build anterior (los rankings dependen
// de este contrato).
return (redirectsFromInventory as RedirectEntry[]).map((r) => ({
source: r.source,
destination: r.destination,
permanent: true,
}))
},
}
export default config
// data/redirects.json (extracto)
[
{ "source": "/2023/04/how-we-think-about-design-systems", "destination": "/blog/how-we-think-about-design-systems", "permanent": true },
{ "source": "/category/engineering", "destination": "/blog/category/engineering", "permanent": true },
{ "source": "/?p=1247", "destination": "/blog/multi-tenant-saas-architecture", "permanent": true },
{ "source": "/wp-content/uploads/:path*", "destination": "https://cdn.adamarant.com/media/:path*", "permanent": true },
{ "source": "/author/ricardo", "destination": "/authors/ricardo", "permanent": true },
{ "source": "/feed/", "destination": "/feed.xml", "permanent": true }
]
5. El sitemap, el feed RSS y el monitor de cutover
El sitemap es dinámico; lee el directorio MDX en build y emite una entrada por post con lastmod derivado del frontmatter. El feed RSS preserva el GUID por post para que los suscriptores no reciban un muro de entradas "nuevas" en el cutover. Un monitor Playwright golpea las 200 URLs principales cada cinco minutos durante 24 horas después del cambio de DNS.
// app/sitemap.ts
import type { MetadataRoute } from 'next'
import { getAllPosts } from '@/lib/blog'
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const posts = await getAllPosts()
const site = 'https://adamarant.com'
return [
{ url: site, lastModified: new Date(), changeFrequency: 'weekly', priority: 1 },
{ url: `${site}/blog`, lastModified: new Date(), changeFrequency: 'weekly', priority: 0.9 },
...posts.map((p) => ({
url: `${site}/blog/${p.slug}`,
lastModified: new Date(p.updated_at),
changeFrequency: 'monthly' as const,
priority: 0.7,
})),
]
}
// app/feed.xml/route.ts
import { getAllPosts } from '@/lib/blog'
export async function GET(): Promise<Response> {
const posts = await getAllPosts()
const items = posts
.map(
(p) => `
<item>
<title><![CDATA[${p.title}]]></title>
<link>https://adamarant.com/blog/${p.slug}</link>
<!-- GUID preservado del export WordPress para que los suscriptores no vean duplicados -->
<guid isPermaLink="false">${p.wp_guid}</guid>
<pubDate>${new Date(p.published_at).toUTCString()}</pubDate>
<description><![CDATA[${p.description}]]></description>
</item>`,
)
.join('\n')
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
<channel>
<title>Adamarant</title>
<link>https://adamarant.com</link>
<description>Design and engineering studio</description>
<language>en</language>
${items}
</channel>
</rss>`
return new Response(xml, {
headers: { 'Content-Type': 'application/xml; charset=utf-8' },
})
}
// scripts/monitor/cutover.ts
import { chromium } from 'playwright'
import topUrls from './top-200.json' assert { type: 'json' }
async function check(url: string): Promise<{ url: string; status: number; title: string }> {
const browser = await chromium.launch()
const page = await browser.newPage()
const res = await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 15000 })
const title = await page.title()
await browser.close()
return { url, status: res?.status() ?? 0, title }
}
async function main(): Promise<void> {
const results = await Promise.all((topUrls as string[]).map(check))
const bad = results.filter((r) => r.status >= 400 || !r.title)
if (bad.length > 0) {
console.error(`FAIL: ${bad.length} bad URLs`, bad)
process.exit(1)
}
console.log(`OK: ${results.length} URLs healthy`)
}
void main()
6. Cómo encaja todo esto
El exporter tira los contenidos de WordPress. El transformador los convierte a MDX tipado. El uploader re-aloja los medios en R2 bajo URLs estables. El mapa de redirects sirve cada URL antigua desde el Edge con un 301. El sitemap y el RSS anuncian continuidad a los crawlers y a los suscriptores. El monitor de cutover atrapa los bugs que nadie vio en staging.
WordPress deja de ser el sistema de producción. Next.js renderiza el sitio marketing por debajo de los 200 ms porque la base de datos sale del request path. La pipeline de build reemplaza al admin de WordPress: el equipo editorial trabaja en MDX (o en una capa CMS sobre el MDX); los desarrolladores ven diffs reales en las pull requests. Search Console se mantiene plano durante todo el cutover porque el grafo de URLs sobrevivió intacto. La migración fue primero un problema de grafo; la velocidad de render fue un efecto colateral de haberla hecho bien.