Esci da WordPress senza perdere una sola URL posizionata
Contenuti esportati via REST API in MDX tipizzato, media riospitati su Cloudflare R2 dietro URL stabili, ogni vecchio slug preservato con una mappa 301 sul Vercel Edge, sitemap e RSS verificati prima del cutover. La nuova build rende in 80 ms invece che in 1,4 s e la curva del traffico organico resta piatta attraverso lo switch.
Il problema
Le migrazioni WordPress si rompono perché nessuno pianifica il grafo delle URL
Il pattern che fa perdere traffico organico è questo. Un team rifà il sito marketing in Next.js, lo spedisce un venerdì, punta il DNS a Vercel e il lunedì vede Search Console accendersi di rosso. Ogni vecchia URL risponde 404. Il team mette pezze a forza di redirect, ma la storia degli slug (anni di permalink, URL degli attachment, archivi paginati, pagine di category e tag) non era stata mai inventariata. Posizionamenti che hanno richiesto tre anni di lavoro spariscono in una settimana. Trattiamo la migrazione prima come un problema di grafo: ogni URL servito dal vecchio sito riceve una voce in una mappa 301, ogni immagine incorporata trova una casa nuova stabile, ogni abbonato RSS continua a ricevere i post. I contenuti si spostano in MDX tipizzato così l'editorial scrive nel proprio tooling e lo sviluppatore vede diff veri in pull request. La nuova build rende sotto i 200 ms perché il database sparisce dal request path, e la curva SEO resta piatta attraverso il cutover.
Come lo affrontiamo
I sette passi di una migrazione WordPress che mantiene il ranking
L'ordine conta. Inventario prima dell'export, export prima della trasformazione, trasformazione prima dei redirect, redirect prima del cutover. Ogni passo chiude una categoria di bug che morde i team che cercano di comprimere due passi in uno.
- 01
Inventariare il grafo delle URL
Si fa il crawl del sito WordPress live con un crawler vero (Screaming Frog, Sitebulb o uno script Playwright) e si produce un CSV di ogni URL indicizzata. Pagine, post, category, tag, pagine di attachment, archivi paginati, pagine autore, custom post type. Il CSV è il contratto: nulla di quello che c'è dentro può rispondere 404 dopo il cutover. Questo passo da solo intercetta le URL che nessuno ricordava: un comunicato stampa del 2017, una pagina category che ranka per una query di buyer, una URL di attachment che qualcuno ha linkato su Twitter.
- 02
Esportare i contenuti via REST API di WordPress
Tiriamo giù post, pagine e custom post type da `/wp-json/wp/v2/*` con paginazione, includendo HTML grezzo, JSON dei blocchi Gutenberg quando presente, campi SEO Yoast o RankMath, ID delle featured image e tassonomie. Lo script di export scrive un file JSON per ogni elemento di contenuto su disco; il JSON è la fonte da cui si trasforma. WP-CLI export è il fallback quando la REST API è bloccata.
- 03
Trasformare HTML in MDX con frontmatter tipizzato
Ogni post diventa un file MDX con frontmatter che rispecchia lo schema di produzione (`title`, `slug`, `published_at`, `updated_at`, `description`, `category`, `tags`, `cover`, `canonical`, `noindex`). Il body è HTML ripulito e convertito: shortcode risolti in componenti React, div `wp-block-*` srotolati, stili inline rimossi, tag immagine riscritti per puntare a R2. La trasformazione è deterministica; rieseguirla produce un output byte-identico.
- 04
Migrare i media su Cloudflare R2 con URL stabili
Ogni immagine, PDF e embed raggiungibile dalla upload directory di WordPress viene ricaricata in un singolo bucket R2 sotto un path content-addressed. La vecchia `/wp-content/uploads/2023/04/cover.jpg` si risolve nella nuova `https://cdn.adamarant.com/media/<hash>/cover.jpg` tramite una mappa permanente. R2 ha zero egress; il sito marketing paga per lo storage, non per il traffico.
- 05
Costruire la mappa 301 sul Vercel Edge
Il CSV di inventario URL diventa un file `redirects.json` che l'app Next.js legge in fase di build. Vercel serve i redirect 301 sull'Edge prima che il runtime Node parta; il costo di latenza è in singola cifra di millisecondi. Ogni vecchio slug, pagina attachment, archivio paginato e alias di category riceve una destinazione sul nuovo sito o un fallback elegante al genitore.
- 06
Replicare sitemap, RSS e dati strutturati
Il nuovo sito emette una `sitemap.xml` che include ogni URL migrata con `lastmod` derivato dall'`updated_at` originale. Il feed RSS a `/feed/` mantiene lo stesso GUID per post così gli abbonati esistenti non vedono voci duplicate. Lo schema JSON-LD `Article` con `author`, `datePublished` e `dateModified` atterra su ogni post; la canonical URL sul nuovo dominio è impostata esplicitamente per evitare debito da contenuto duplicato.
- 07
Cutover con finestra di verifica di 24 ore
Il giorno prima dello switch DNS facciamo un crawl finale contro la build di staging e lo confrontiamo con l'inventario di produzione. Qualsiasi URL che non si risolve fa fallire il deploy. Dopo il cut del DNS, un monitor sintetico colpisce le prime 200 URL per ranking ogni cinque minuti per 24 ore; gli alert scattano su qualsiasi 4xx o 5xx. Il team resta in reperibilità fino a chiusura finestra.
Cosa consegniamo
CSV di inventario URL
Ogni URL che il sito WordPress serviva, con i dati di traffico da GA4 o Plausible, URL di destinazione sul nuovo sito e tipo di redirect (`301` permanente, `410` gone o `200` render diretto). Il manufatto rispetto a cui la migrazione si misura.
Pipeline di export REST API
Uno script Node che pagina su `/wp-json/wp/v2/*`, gestisce l'autenticazione e scrive un file JSON per elemento di contenuto. Ri-eseguibile; idempotente al riesporto.
Trasformatore HTML→MDX
Un trasformatore tipizzato che converte l'HTML dei post in MDX, con handler per blocchi Gutenberg, shortcode, oembed e stili inline. Ogni handler è una funzione con unit test; i nuovi handler sono PR.
Bucket media R2 e CDN
Un bucket Cloudflare R2 con path content-addressed, un Worker che serve immagini con `Cache-Control: immutable` e una mappa vecchio→nuovo committata nel repo. Costo egress zero sul traffico.
Mappa 301 di redirect
Un `redirects.json` consumato da `next.config.ts`, servito sul Vercel Edge con sotto i 10 ms di overhead. Ogni URL dell'inventario è coperta; la CI fallisce se una nuova build perde una voce.
Continuità di sitemap e RSS
Una `sitemap.xml` dinamica con URL stabili e `lastmod`, un feed RSS che preserva i GUID per post, e un `robots.txt` che lascia passare i crawler LLM e search su cui il team si è accordato.
Dati strutturati JSON-LD
Schema `Article` sui post, `BreadcrumbList` su ogni pagina, `Organization` e `WebSite` sitewide. Schema `Person` sulle pagine autore con `knowsAbout` e `sameAs`.
Runbook di cutover
Switch DNS passo per passo con piano di rollback, setup di monitoring e finestra di verifica di 24 ore. Cofirmato dai lead engineering e marketing prima dell'esecuzione.
Monitoring sintetico
Un monitor Playwright che colpisce le prime 200 URL ogni cinque minuti e asserisce su status, title e meta description. Gli alert vanno su PagerDuty o Slack.
Continuità GA4 / Plausible
L'analytics mantiene la sua property; gli eventi partono dagli stessi punti logici; il tracking cross-domain gestisce la settimana del cutover. Le sessioni per landing page si riconciliano entro il 5%.
Workflow editoriale in MDX
I post diventano PR; l'editorial scrive in Markdown con uno strato Decap o Sanity quando preferito. Le bozze fanno preview nelle branch deployment; pubblicare è un merge su `main`.
Review Search Console post-cutover
Diff settimanale su Search Console per il primo mese. Le URL che perdono impression ricevono una causa-radice; la mappa di redirect viene emendata; il team impara cosa ha morso la migrazione.
Cinque file concreti che compongono una migrazione WordPress→Next.js senza ranking persi
Una migrazione WordPress è cinque file incollati insieme. L'exporter REST che tira giù i contenuti, il trasformatore HTML→MDX che li converte, l'uploader R2 che riospita i media, la mappa di redirect che protegge i ranking, e la sitemap che annuncia continuità ai crawler. Ognuno è piccolo, deterministico, ri-eseguibile.
Una migrazione WordPress è un problema di grafo prima che un problema di codice. Ogni URL che il vecchio sito serviva è un contratto con un crawler, un backlink, un abbonato RSS o una email mandata cinque anni fa. La migrazione riesce quando ogni URL dell'inventario continua a risolversi sulla destinazione giusta dopo il cutover. Fallisce quando qualcuno si dimentica le pagine di attachment.
I cinque file qui sotto compongono una migrazione che mantiene i ranking: l'exporter REST, il trasformatore HTML→MDX, l'uploader media R2, la mappa di redirect sull'Edge, e la sitemap che annuncia continuità a Googlebot.
1. L'exporter REST API
WordPress espone i contenuti su /wp-json/wp/v2/* con paginazione tramite i query parameter page e per_page. L'exporter cammina ogni endpoint, scrive un file JSON per item, ed è idempotente al ri-run. Esportiamo l'HTML grezzo perché lo consumi il trasformatore e i campi di metadata direttamente per il 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; l'header X-WP-TotalPages guida il 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. Il trasformatore HTML→MDX
Ogni post dell'export diventa un file MDX con frontmatter tipizzato e un body che è HTML ripulito dal rumore WordPress. Gli stili inline spariscono, i wrapper di blocco Gutenberg si srotolano, gli shortcode diventano componenti React, le URL delle immagini scattano al path R2. Il trasformatore è una funzione pura; girarlo due volte produce lo stesso output.
// 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 gestisce questo
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. L'uploader media su R2
Ogni immagine nella upload directory di WordPress si sposta in un singolo bucket R2 sotto un path content-addressed. L'hash rende la URL stabile per sempre; l'invalidazione cache smette di essere un problema perché la URL cambia quando cambiano i byte. L'uploader emette una mappa dalla vecchia alla nuova URL che il trasformatore legge.
// 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> = {}
// Concorrenza a 8 sta dentro i limiti di R2 e satura la rete locale.
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. La mappa di redirect sull'Edge
Ogni URL del CSV di inventario diventa una voce in redirects.json. Next.js la consuma in fase di build e Vercel serve i redirect sull'Edge prima ancora che il runtime Node si svegli. La latenza del 301 è in singola cifra di millisecondi; i ranking si trasferiscono perché Googlebot rispetta i redirect permanenti dentro lo stesso dominio in modo affidabile.
// 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[]> {
// Il CSV di inventario viene processato offline in redirects.json; la CI
// fallisce se il conteggio scende rispetto alla build precedente (i ranking
// dipendono da questo contratto).
return (redirectsFromInventory as RedirectEntry[]).map((r) => ({
source: r.source,
destination: r.destination,
permanent: true,
}))
},
}
export default config
// data/redirects.json (estratto)
[
{ "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. La sitemap, il feed RSS e il monitor di cutover
La sitemap è dinamica; legge la directory MDX in fase di build ed emette una voce per post con lastmod derivato dal frontmatter. Il feed RSS preserva il GUID per post così gli abbonati esistenti non ricevono un muro di voci "nuove" al cutover. Un monitor Playwright colpisce le prime 200 URL ogni cinque minuti per 24 ore dopo lo switch 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 preservato dall'export WordPress così gli abbonati non vedono duplicati -->
<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. Come si tiene insieme tutto questo
L'exporter tira giù i contenuti WordPress. Il trasformatore li converte in MDX tipizzato. L'uploader riospita i media su R2 dietro URL stabili. La mappa di redirect serve ogni vecchia URL sull'Edge con un 301. La sitemap e l'RSS annunciano continuità ai crawler e agli abbonati. Il monitor di cutover intercetta i bug che nessuno aveva visto in staging.
WordPress smette di essere il sistema di produzione. Next.js rende il sito marketing sotto i 200 ms perché il database è uscito dal request path. La pipeline di build rimpiazza l'admin WordPress: l'editorial lavora in MDX (o in uno strato CMS sopra l'MDX); gli sviluppatori vedono diff veri nelle pull request. Search Console resta piatto durante tutto il cutover perché il grafo delle URL è sopravvissuto intatto. La migrazione era prima un problema di grafo; la velocità di render è stata un effetto collaterale di averla fatta bene.
Stack correlati
Domande frequenti
Quanto dura tipicamente una migrazione WordPress→Next.js?
Per un sito marketing con 100-500 post e tassonomie standard, tre-quattro settimane da kickoff a cutover DNS. La prima settimana è inventario ed export; la seconda è trasformazione e mappatura redirect; la terza è build di staging e verifica col crawler; la quarta è cutover e finestra di monitoring di 24 ore. Siti con plugin custom, WooCommerce o setup multilingua aggiungono una-due settimane per asse di complessità.
Cosa succede alle funzionalità su cui contavamo via plugin (form, search, commenti)?
Ogni plugin viene rimpiazzato o tolto in base all'uso reale. I form passano a Resend con una Server Action tipizzata; la ricerca del sito passa ad Algolia, Typesense o Postgres full-text a seconda dei volumi; i commenti o si ritirano (la maggior parte dei siti marketing) o si spostano a un thread moderato su Linear o Discord. La decisione avviene durante l'inventario, non a posteriori.
Perderemo ranking SEO durante la migrazione?
Non se l'inventario URL è completo e la mappa 301 è corretta. Il pattern che fa perdere ranking è dimenticare URL (una pagina attachment, un archivio paginato a pagina 7, un vecchio alias di category). Il pattern che mantiene ranking è trattare ogni URL dell'inventario come un contratto. Abbiamo eseguito migrazioni con varianza di traffico organico sotto il 5% durante la settimana di cutover.
L'editorial può ancora scrivere post dopo la migrazione?
Sì, in due modi. O come pull request MDX direttamente (meglio per team tecnici già abituati a GitHub), o attraverso uno strato CMS headless come Decap, Sanity o Sveltia montato sugli stessi file MDX (meglio per editor non tecnici). L'MDX nel repo è la fonte di verità; il CMS è una UI sopra.
Come si gestiscono WooCommerce o i plugin di membership?
Fuori scope per una migrazione marketing→Next.js; in scope per un rebuild SaaS completo. Se il sito ha e-commerce o membership, o teniamo WooCommerce su un sottodominio (woocommerce.example.com) e migriamo solo i contenuti marketing, o ricostruiamo lo strato commerce su Stripe e Supabase. L'una o l'altra strada si decide nella prima call di scoping.
Come si migrano blocchi Gutenberg che non hanno equivalente Next.js?
Cataloghiamo ogni blocco usato sul sito durante l'inventario. Quelli ad alto volume (paragrafo, heading, immagine, lista, citazione, embed) si mappano in MDX direttamente. Quelli a volume più basso (gallerie, accordion, colonne) diventano componenti React con lo stesso nome. La long tail (15+ blocchi rari) viene appiattita in HTML all'export e rivista manualmente per i post che la usano.
E per i siti WordPress multilingua (WPML, Polylang)?
Estendiamo inventario e trasformatore per locale. Ogni lingua ha la sua directory MDX; gli header `hreflang` partono da `generateMetadata`; la struttura URL sul nuovo sito rispecchia la convenzione WordPress o passa a un prefisso `/<lang>/` più pulito con un redirect per ogni vecchia URL. I siti multilingua aggiungono una-due settimane di lavoro rispetto al monolingua.
Possiamo mantenere lo stesso dominio o serve cambiarlo?
Stesso dominio. Il DNS si stacca dall'hosting WordPress e si attacca a Vercel; il dominio resta. Il certificato HTTPS transita; coordiniamo il taglio con una finestra di downtime breve e pianificata (di solito sotto i 60 secondi) o senza downtime tramite una strategia di warm-up DNS. Il sottodominio di staging (`new.example.com`) è l'ambiente di verifica; il dominio di produzione vede il nuovo sito solo dopo che l'inventario URL passa.
Pianifica la migrazione WordPress
Una call di scoping, un inventario URL nella prima risposta, un numero che non cambiamo dopo l'accordo sullo scope. Tre-quattro settimane da kickoff a un sito più veloce che ha mantenuto i suoi ranking.