Caso de uso · WordPress a Next.js

Sal de WordPress sin dejar caer una sola URL posicionada

Contenidos exportados vía REST API a MDX tipado, medios re-alojados en Cloudflare R2 detrás de URLs estables, cada slug heredado preservado con un mapa 301 en el Vercel Edge, sitemap y RSS verificados antes del cutover. La nueva build se renderiza en 80 ms en lugar de 1,4 s y la curva de tráfico orgánico se mantiene plana en el cambio.

El problema

Las migraciones de WordPress se rompen porque nadie planifica el grafo de URLs

El patrón que pierde tráfico orgánico es este. Un equipo rehace el sitio marketing en Next.js, lo despliega un viernes, apunta el DNS a Vercel y el lunes ve Search Console encenderse en rojo. Cada URL antigua devuelve 404. El equipo intenta poner parches con redirects, pero el historial de slugs (años de permalinks, URLs de attachments, archivos paginados, páginas de category y tag) nunca se inventarió. Posicionamientos que costaron tres años de trabajo desaparecen en una semana. Tratamos la migración como un problema de grafo antes que de código: cada URL que servía el sitio antiguo recibe una entrada en un mapa 301, cada imagen embebida encuentra una casa nueva estable, cada suscriptor de RSS sigue recibiendo los posts. Los contenidos se mueven a MDX tipado para que el equipo editorial escriba en su tooling y el desarrollador vea diffs reales en pull requests. La nueva build se renderiza por debajo de los 200 ms porque no hay query a base de datos en el request path, y la curva de SEO se mantiene plana a través del cutover.

Cómo lo abordamos

Los siete pasos de una migración WordPress que conserva el ranking

El orden importa. Inventario antes que export, export antes que transformación, transformación antes que redirects, redirects antes que cutover. Cada paso cierra una clase de bug que muerde a los equipos que intentan comprimir dos pasos en uno.

  1. 01

    Inventariar el grafo de URLs

    Pasamos un crawler de verdad sobre el sitio WordPress en producción (Screaming Frog, Sitebulb o un script Playwright) y producimos un CSV con cada URL indexada. Páginas, posts, category, tag, páginas de attachment, archivos paginados, páginas de autor, custom post types. El CSV es el contrato: nada de lo que aparece en él puede dar 404 tras el cutover. Solo este paso atrapa las URLs que nadie recordaba: una nota de prensa de 2017, una página de category que rankea para una query de buyer, una URL de attachment que alguien linkó desde Twitter.

  2. 02

    Exportar contenidos vía la REST API de WordPress

    Tiramos posts, páginas y custom post types desde `/wp-json/wp/v2/*` con paginación, incluyendo HTML crudo, JSON de bloques Gutenberg cuando está presente, campos SEO de Yoast o RankMath, IDs de featured image y taxonomías. El script de export escribe un archivo JSON por cada item de contenido en disco; el JSON es la fuente desde la que transformamos. WP-CLI export es el fallback cuando la REST API está bloqueada.

  3. 03

    Transformar HTML a MDX con frontmatter tipado

    Cada post pasa a ser un archivo MDX con frontmatter que espeja el schema de producción (`title`, `slug`, `published_at`, `updated_at`, `description`, `category`, `tags`, `cover`, `canonical`, `noindex`). El cuerpo es HTML limpiado y convertido: shortcodes resueltos a componentes React, divs `wp-block-*` desenvueltos, estilos inline borrados, etiquetas img reescritas para apuntar a R2. La transformación es determinista; ejecutarla dos veces produce el mismo byte de salida.

  4. 04

    Migrar los medios a Cloudflare R2 con URLs estables

    Cada imagen, PDF y embed alcanzable desde el directorio de uploads de WordPress se vuelve a subir a un bucket R2 único bajo un path content-addressed. La vieja `/wp-content/uploads/2023/04/cover.jpg` resuelve a la nueva `https://cdn.adamarant.com/media/<hash>/cover.jpg` mediante un mapa permanente. R2 tiene egress cero; el sitio marketing paga storage, no tráfico.

  5. 05

    Construir el mapa 301 en el Vercel Edge

    El CSV de inventario de URLs pasa a ser un archivo `redirects.json` que la app Next.js lee en build. Vercel sirve los redirects 301 desde el Edge antes de que arranque el runtime Node; el coste de latencia es de un solo dígito de milisegundos. Cada slug antiguo, página de attachment, archivo paginado y alias de category recibe un destino en el sitio nuevo o un fallback elegante al padre.

  6. 06

    Replicar sitemap, RSS y datos estructurados

    El sitio nuevo emite un `sitemap.xml` que incluye cada URL migrada con `lastmod` derivado del `updated_at` original. El feed RSS en `/feed/` mantiene el mismo GUID por post para que los suscriptores no vean entradas duplicadas. El schema JSON-LD `Article` con `author`, `datePublished` y `dateModified` aterriza en cada post; la canonical URL en el dominio nuevo se fija de forma explícita para evitar deuda por contenido duplicado.

  7. 07

    Cutover con ventana de verificación de 24 horas

    El día antes del cambio de DNS hacemos un crawl final contra la build de staging y lo comparamos con el inventario de producción. Cualquier URL que no resuelva tumba el deploy. Tras el corte del DNS, un monitor sintético golpea las 200 URLs principales por ranking cada cinco minutos durante 24 horas; las alertas saltan ante cualquier 4xx o 5xx. El equipo se queda de guardia hasta que se cierra la ventana.

Qué entregamos

CSV de inventario de URLs

Cada URL que servía el sitio WordPress, con los datos de tráfico de GA4 o Plausible, URL de destino en el sitio nuevo y tipo de redirect (`301` permanente, `410` gone o `200` render directo). El artefacto contra el que se mide la migración.

Pipeline de export REST API

Un script Node que pagina sobre `/wp-json/wp/v2/*`, gestiona la autenticación y escribe un archivo JSON por item de contenido. Re-ejecutable; idempotente al re-exportar.

Transformador HTML→MDX

Un transformador tipado que convierte el HTML de los posts en MDX, con handlers para bloques Gutenberg, shortcodes, oembeds y estilos inline. Cada handler es una función con tests unitarios; los nuevos handlers son PRs.

Bucket de medios R2 y CDN

Un bucket de Cloudflare R2 con paths content-addressed, un Worker que sirve imágenes con `Cache-Control: immutable` y un mapa de viejo→nuevo committeado al repo. Coste de egress cero sobre el tráfico.

Mapa de redirects 301

Un `redirects.json` consumido por `next.config.ts`, servido en el Vercel Edge con menos de 10 ms de overhead. Cada URL del inventario está cubierta; la CI falla si una build nueva pierde una entrada.

Continuidad de sitemap y RSS

Un `sitemap.xml` dinámico con URLs estables y `lastmod`, un feed RSS que preserva los GUIDs por post y un `robots.txt` que deja pasar los crawlers de LLM y de búsqueda que el equipo haya acordado.

Datos estructurados JSON-LD

Schema `Article` en los posts, `BreadcrumbList` en cada página, `Organization` y `WebSite` sitewide. Schema `Person` en las páginas de autor con `knowsAbout` y `sameAs`.

Runbook de cutover

Cambio de DNS paso a paso con plan de rollback, setup de monitoring y ventana de verificación de 24 horas. Cofirmado por los responsables de engineering y marketing antes de ejecutarlo.

Monitoring sintético

Un monitor Playwright que golpea las 200 URLs principales cada cinco minutos y asegura status, title y meta description. Las alertas van a PagerDuty o Slack.

Continuidad de GA4 / Plausible

Analytics mantiene su property; los eventos se disparan desde los mismos puntos lógicos; el tracking cross-domain gestiona la semana del cutover. Las sesiones por landing page reconcilian dentro del 5%.

Workflow editorial en MDX

Los posts pasan a ser PRs; el equipo editorial escribe en Markdown con una capa Decap o Sanity cuando se prefiere. Los borradores hacen preview en branch deployments; publicar es un merge a `main`.

Revisión de Search Console post-cutover

Diff semanal en Search Console durante el primer mes. Las URLs que pierden impresiones reciben una causa raíz; el mapa de redirects se enmienda; el equipo aprende qué mordió la migración.

Cinco archivos concretos que componen una migración WordPress→Next.js que no se rompe

Una migración WordPress son cinco archivos pegados entre sí. El exporter REST que tira los contenidos, el transformador HTML→MDX que los convierte, el uploader R2 que re-aloja los medios, el mapa de redirects que protege los rankings, y el sitemap que anuncia continuidad a los crawlers. Cada uno es pequeño, determinista y re-ejecutable.

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.

Stacks relacionados

Preguntas frecuentes

¿Cuánto dura una migración típica de WordPress a Next.js?

Para un sitio marketing con 100 a 500 posts y taxonomías estándar, de tres a cuatro semanas desde el kickoff hasta el cutover de DNS. La primera semana es inventario y export; la segunda es transformación y mapeo de redirects; la tercera es build de staging y verificación con el crawler; la cuarta es cutover y la ventana de monitoring de 24 horas. Sitios con plugins custom, WooCommerce o setups multilingua añaden una a dos semanas por eje de complejidad.

¿Qué pasa con la funcionalidad que dependía de plugins (formularios, búsqueda, comentarios)?

Cada plugin se reemplaza o se elimina según el uso real. Los formularios se mueven a Resend con una Server Action tipada; la búsqueda del sitio se mueve a Algolia, Typesense o Postgres full-text según el volumen; los comentarios se retiran (la mayoría de los sitios marketing) o se mueven a un hilo moderado en Linear o Discord. La decisión se toma durante el inventario, no después.

¿Vamos a perder rankings SEO durante la migración?

No si el inventario de URLs está completo y el mapa 301 es correcto. El patrón que hace perder rankings es olvidarse de URLs (una página de attachment, un archivo paginado en la página 7, un viejo alias de category). El patrón que mantiene rankings es tratar cada URL del inventario como un contrato. Hemos hecho migraciones con menos del 5% de varianza en tráfico orgánico durante la semana del cutover.

¿El equipo editorial puede seguir escribiendo después de la migración?

Sí, de dos maneras. O como pull requests MDX directamente (mejor para equipos técnicos ya acostumbrados a GitHub), o a través de una capa CMS headless como Decap, Sanity o Sveltia montada sobre los mismos archivos MDX (mejor para editores no técnicos). El MDX en el repo es la fuente de verdad; el CMS es una UI por encima.

¿Cómo gestionáis WooCommerce o plugins de membresía?

Fuera del scope para una migración marketing→Next.js; dentro del scope para un rebuild SaaS completo. Si el sitio tiene e-commerce o membresías, o mantenemos WooCommerce en un subdominio (woocommerce.example.com) y migramos solo los contenidos marketing, o reconstruimos la capa commerce sobre Stripe y Supabase. Cualquiera de los dos caminos se decide en la primera llamada de scoping.

¿Cómo se migran bloques Gutenberg que no tienen equivalente en Next.js?

Catalogamos cada bloque usado en el sitio durante el inventario. Los de alto volumen (párrafo, heading, imagen, lista, cita, embed) mapean a MDX directamente. Los de volumen más bajo (galerías, accordions, columnas) pasan a ser componentes React con el mismo nombre. La cola larga (15+ bloques poco usados) se aplana a HTML en el export y se revisa manualmente para los posts que la usan.

¿Y los sitios WordPress multilingua (WPML, Polylang)?

Extendemos el inventario y el transformador por locale. Cada idioma tiene su propio directorio MDX; las cabeceras `hreflang` se emiten desde `generateMetadata`; la estructura URL en el sitio nuevo espeja la convención WordPress o pasa a un prefijo `/<lang>/` más limpio con un redirect por cada URL antigua. Los sitios multilingua añaden una a dos semanas de trabajo respecto a los monolingua.

¿Podemos mantener el mismo dominio o hay que cambiar?

Mismo dominio. El DNS pasa del hosting de WordPress a Vercel; el dominio se queda. El certificado HTTPS transita; coordinamos el corte con una ventana de downtime breve y planificada (normalmente por debajo de los 60 segundos) o sin downtime usando una estrategia de warm-up de DNS. El subdominio de staging (`new.example.com`) es el entorno de verificación; el dominio de producción solo ve el sitio nuevo después de que el inventario URL pase.

Planifica tu migración WordPress

Una llamada de scoping, un inventario URL en la primera respuesta, un número que no cambiamos una vez acordado el scope. Tres a cuatro semanas desde el kickoff hasta un sitio más rápido que conservó sus rankings.