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.