Alessandro Aili - WordPress Developer
Alessandro Aili - WordPress DeveloperPlay Alessandro Aili - WordPress DeveloperDrag Alessandro Aili - WordPress DeveloperView
Alessandro Aili - WordPress Developer
  • Homepage
  • Chi sono
  • Portfolio
  • Aree di Attività
    • Sviluppo di Siti Web Personalizzati
    • Ottimizzazione delle Prestazioni
    • Sicurezza del Sito Web
    • Aggiornamenti e Manutenzione
    • Sviluppo di Temi e Plugin Personalizzati
    • Migrazione e Trasferimento del Sito
  • Blog
  • Contatti
Alessandro Aili - WordPress Developer
Alessandro Aili - WordPress Developer Alessandro Aili - WordPress Developer
  • Homepage
  • Chi sono
  • Portfolio
  • Aree di Attività
    • Sviluppo di Siti Web Personalizzati
    • Ottimizzazione delle Prestazioni
    • Sicurezza del Sito Web
    • Aggiornamenti e Manutenzione
    • Sviluppo di Temi e Plugin Personalizzati
    • Migrazione e Trasferimento del Sito
  • Blog
  • Contatti
MENU Alessandro Aili - WordPress Developer
  • Home
  • Blog
  • Temi e Plugin
  • Headless WordPress con Next.js: quando conviene davvero e come si fa

Headless WordPress con Next.js: quando conviene davvero e come si fa

Alessandro Aili - WordPress Developer
Alessandro Aili WordPress Developer

Data di pubblicazione

23 Maggio 2025

Tempo di lettura

28 minuti

Categoria

Temi e Plugin

Commenti

0

Schema di un'architettura headless: WordPress come backend espone i contenuti via API a un frontend Next.js

Headless significa spegnere il frontend di WordPress e lasciargli fare solo quello che sa fare meglio: raccogliere e organizzare contenuti. Le pagine le disegna un’applicazione separata — qui Next.js — che legge quei contenuti via API e li pubblica su un altro server. È una scelta di architettura, non un’ottimizzazione: cambia come si scrive il sito, come si aggiorna, come si mette in sicurezza e quanto costa mantenerlo.

Questa guida fa due cose. Prima ti aiuta a decidere se ti serve davvero, perché nella maggior parte dei progetti la risposta onesta è no e le rigidità che si attribuiscono al «monolite» vengono quasi sempre dal tema in uso. Poi, se la risposta è sì, ti porta dall’installazione di WordPress al frontend in produzione: quale API scegliere, cosa installare, come si scrive il progetto Next.js con l’App Router, come si gestiscono metadati e sitemap senza Rank Math, come si aggiorna il sito quando pubblichi un post e come funziona l’anteprima delle bozze — che è il punto in cui si arenano quasi tutti i progetti headless.

Il codice è scritto per Next.js 16 con l’App Router e per WPGraphQL 2.21. Nomi dei plugin, versioni e comandi sono stati verificati sulle fonti ufficiali il 2 settembre 2026.

Una versione precedente di questo articolo conteneva indicazioni non più corrette, tutte sistemate qui sotto. Fra i plugin «essenziali» era indicato «WP Headless CMS», che nel repository di WordPress.org non esiste con quel nome; l’estensione che porta i campi ACF dentro GraphQL era chiamata «ACF to GraphQL» e si chiama in realtà WPGraphQL for ACF; era consigliato Node.js 18, che è arrivato a fine vita il 30 aprile 2025 e oggi non soddisfa nemmeno il minimo richiesto da Next.js; «React Query» dal 2022 si chiama TanStack Query. Gli esempi di codice usavano inoltre il Pages Router (getStaticProps, next/head) mentre create-next-app genera da tempo un progetto App Router, la configurazione di Apollo Client non è più valida dalla versione 4 e l’istruzione su next-sitemap indicava il file sbagliato.

Che cosa cambia davvero quando stacchi il frontend

WordPress installato in modo classico fa tutto in un unico processo: riceve la richiesta, interroga il database, sceglie il file del tema da usare, costruisce l’HTML e lo restituisce. Tema e contenuti vivono nella stessa applicazione, e questo è il motivo per cui basta un plugin per cambiare l’aspetto del sito — ma anche il motivo per cui l’HTML che esce è quello che il tema ha deciso di produrre.

In un’architettura headless quel percorso si spezza in due. WordPress resta la redazione: utenti, ruoli, editor a blocchi, campi personalizzati, revisioni, media. Ma non serve più pagine: espone i contenuti in JSON, tramite la REST API già inclusa nel core o tramite un endpoint GraphQL aggiunto con un plugin. Dall’altra parte un’applicazione Next.js legge quei dati e genera le pagine, quasi sempre a build time o con rigenerazione incrementale, e le serve da un’infrastruttura completamente diversa.

Le due parti stanno su domini o sottodomini diversi e si aggiornano separatamente. È qui che nasce sia il vantaggio sia il costo: hai il controllo totale sull’HTML e sul comportamento del frontend, e in cambio hai due applicazioni da mantenere, due catene di deploy, e una serie di funzioni che davi per scontate — l’anteprima, i moduli di contatto, la ricerca, i commenti, i redirect — che devi ricostruire una per una.

Prima domanda: il problema è l’architettura o è il tema?

È la domanda che salta quasi sempre, e la sbaglia chi arriva a headless partendo da un sito lento. Un sito WordPress lento lo è quasi sempre per ragioni che si misurano e si correggono senza cambiare architettura: troppi plugin che si caricano ovunque, un tema che carica quindici richieste per la homepage, immagini non ottimizzate, hosting sottodimensionato, nessuna cache. Sono le cause reali di un sito lento, e vanno escluse prima di considerare una riscrittura.

Se invece il problema è che il tema non fa quello che ti serve, le strade prima di headless sono tre e costano tutte molto meno: personalizzare quello che hai con un child theme, sceglierne uno migliore applicando i sei criteri per valutare un tema, oppure scriverne uno da zero. Quest’ultima strada risolve gran parte dei casi in cui si invoca headless, con una frazione della complessità operativa.

Quando headless è la risposta giusta

Ci sono situazioni in cui il frontend separato non è un vezzo ma la soluzione più semplice disponibile. Le riconosci perché il vincolo non è mai «voglio un sito più veloce»:

  • Gli stessi contenuti servono a più destinazioni. Un sito web, un’app mobile, un totem in negozio, un partner che consuma i tuoi dati. Il momento in cui il secondo consumatore diventa reale è il momento in cui headless smette di essere teoria.
  • Il frontend è un’applicazione, non un sito. Configuratori di prodotto, aree riservate con logica complessa, interfacce con molto stato: sono cose che si scrivono in React, e forzarle dentro un tema PHP produce codice peggiore.
  • Il team frontend non lavora in PHP. Se chi disegna e scrive l’interfaccia usa React tutto il giorno, un tema WordPress lo rallenta e produce risultati mediocri.
  • WordPress deve stare dietro un muro. Ci sono contesti — intranet, requisiti di compliance — in cui il CMS non può essere raggiungibile dall’esterno. Headless permette di tenerlo su una rete privata e pubblicare solo il frontend.

Fuori da questi casi, il conto raramente torna. Vale la pena metterlo per iscritto prima di iniziare, perché sono tre voci che nessun preventivo include e che si presentano tutte insieme al terzo mese.

Le tre voci di costo che nessuno conta

VoceCosa succede in pratica
Due applicazioni da aggiornareWordPress ha il suo ciclo di aggiornamenti, il progetto Next.js ha il suo albero di dipendenze npm. Sono due manutenzioni distinte, con due tipi di rotture distinte.
Le funzioni che il tema regalavaModuli di contatto, ricerca interna, commenti, breadcrumb, paginazione, redirect, pagine 404: nel monolite arrivano con un plugin, qui si scrivono.
La redazione perde il «vedi come viene»L’anteprima va costruita. Fino a che non funziona, chi scrive pubblica alla cieca — ed è la ragione numero uno per cui i progetti headless vengono abbandonati.

Nessuna di queste è un motivo per non farlo. Sono il prezzo, e serve conoscerlo prima di firmare.

REST API o WPGraphQL: la scelta che decide tutto il resto

È la prima decisione tecnica e condiziona ogni riga che scriverai dopo, perché cambia la forma dei dati, gli strumenti di sviluppo e il modo in cui si aggiungono i campi personalizzati. Le opzioni sono due e non c’è una vincitrice assoluta.

La REST API c’è già e non chiede permesso

WordPress espone i propri contenuti in JSON senza che tu installi niente, sotto /wp-json/wp/v2/. Puoi provarlo adesso su qualunque sito: /wp-json/wp/v2/posts restituisce gli ultimi post, e il parametro _embed aggiunge nella stessa risposta autore, immagine in evidenza, categorie e tag, evitandoti una richiesta per ognuno.

# Gli ultimi 10 post con autore, immagine e tassonomie incluse
curl 'https://cms.esempio.it/wp-json/wp/v2/posts?_embed&per_page=10'

# Un solo post, cercato per slug
curl 'https://cms.esempio.it/wp-json/wp/v2/posts?slug=il-mio-post&_embed'

Il vantaggio è che non dipendi da nessun plugin: la REST API è parte del core, la mantiene chi mantiene WordPress e non può essere abbandonata. Il limite è la forma delle risposte. Sono verbose — un post arriva con decine di campi che non userai — e per costruire una pagina con contenuto, autore, articoli correlati e menù finisci spesso a fare tre o quattro chiamate. Il campo content.rendered arriva come HTML già montato da WordPress, con dentro le classi dei blocchi: va inserito nella pagina con dangerouslySetInnerHTML, ed è una cosa da sapere prima, non dopo.

La documentazione di riferimento è il REST API Handbook, che elenca tutti gli endpoint e i parametri disponibili.

WPGraphQL, e cosa significa che è un canonical plugin

WPGraphQL aggiunge a WordPress un endpoint GraphQL, di norma su /graphql. La differenza pratica è che chiedi esattamente i campi che ti servono, per tutte le entità coinvolte, in una sola richiesta: post, autore, immagine e categorie tornano insieme, nella forma che hai definito tu nella query.

query PostBySlug($slug: ID!) {
  post(id: $slug, idType: SLUG) {
    title
    slug
    date
    content
    author { node { name } }
    featuredImage { node { sourceUrl altText } }
    categories { nodes { name slug } }
  }
}

Fino al 2024 la principale obiezione a WPGraphQL era la sua dipendenza da una persona sola. Nell’ottobre 2024 il plugin è diventato un canonical plugin di WordPress.org e il suo autore, Jason Bahl, è passato in Automattic per continuare a mantenerlo: lo status di canonical mette WPGraphQL nella stessa categoria di WP-CLI e della REST API, cioè fra i progetti che il progetto WordPress considera propri. Non è una garanzia eterna, ma cambia la valutazione del rischio, e chi aveva scartato GraphQL per quel motivo può rifarla.

Il plugin include anche una IDE GraphiQL nella bacheca di WordPress: ci scrivi le query con l’autocompletamento sullo schema del tuo sito e le provi prima di portarle nel codice. È il modo più veloce per capire che cosa il tuo WordPress espone davvero.

Il confronto, voce per voce

 REST API (core)WPGraphQL (plugin)
InstallazioneNiente da fare, è già attivaUn plugin, più uno per i campi ACF
Dati per paginaSpesso più chiamateUna query sola
Peso della rispostaTutti i campi, anche inutiliSolo i campi richiesti
Campi personalizzatiVanno esposti a mano con register_rest_field()Automatici con WPGraphQL for ACF
StrumentiBrowser, cURL, PostmanGraphiQL nella bacheca, con lo schema del tuo sito
Curva di apprendimentoBassa: sono URL e JSONMedia: va imparato GraphQL
Rischio a lungo termineNullo, è nel coreBasso: canonical plugin dal 2024

La regola che uso: se il sito è un blog o una vetrina con pagine semplici, la REST API basta e ti risparmia due dipendenze. Se le pagine compongono contenuti da fonti diverse, o se il progetto vive di campi personalizzati, WPGraphQL ripaga la curva di apprendimento nel giro di due settimane.

Cosa si installa sul lato WordPress

Meno di quanto sembri. Un’installazione headless è un’installazione normale — vale tutto quello che c’è nella guida su come installare e configurare WordPress — con tre o quattro plugin in più e nessun tema da scegliere.

I plugin che servono davvero

  • WPGraphQL — solo se hai scelto la strada GraphQL. Alla verifica del 2 settembre 2026 è alla versione 2.21.1, con oltre 30.000 installazioni attive.
  • WPGraphQL for ACF — porta i campi di Advanced Custom Fields dentro lo schema GraphQL. È questo il nome corretto: l’estensione chiamata «ACF to GraphQL» in molte guide non esiste con quel nome nel repository.
  • Advanced Custom Fields — per i campi personalizzati. Qui serve una precisazione, perché su WordPress.org oggi convivono due plugin distinti: Advanced Custom Fields (ACF®) di WP Engine, oltre 2 milioni di installazioni, e Secure Custom Fields, il fork mantenuto da WordPress.org. Il ponte verso GraphQL è scritto per ACF: se scegli il fork, verifica prima che i tuoi campi arrivino nello schema.
  • Un plugin SEO, comunque — non per il frontend, che non esiste più, ma perché continua a essere il posto dove la redazione scrive title e description. Al frontend li passerai via API.

Quello che non serve più è un plugin di cache. Il confronto fra W3 Total Cache e WP Rocket riguarda la generazione delle pagine, e in headless quel lavoro non lo fa più WordPress: la cache si sposta nel frontend e sulla CDN che lo serve.

Spegnere il frontend: attenzione al plugin che non esiste

Con WordPress usato solo come backend, le pagine pubbliche del CMS restano raggiungibili e mostrano il tema predefinito. Non è un disastro — sono pagine che nessuno linka — ma sono contenuto duplicato rispetto al frontend vero, e vale la pena reindirizzarle.

Qui va fatta una correzione. Molte guide, questa compresa nella sua versione precedente, indicano un plugin chiamato «WP Headless CMS» come soluzione. Nel repository di WordPress.org quel plugin non esiste. Esistono altre cose, e vanno valutate per quello che sono: Headless Mode di Ben Meredith fa esattamente questo lavoro ma è fermo al luglio 2024, dichiara compatibilità fino a WordPress 6.6 e porta in cima alla sua scheda l’avviso di WordPress.org sulle tre major non testate; Faust.js di WP Engine è un framework completo, non un interruttore, e ha senso solo se adotti anche il resto.

Il criterio per giudicare un plugin di questa categoria è sempre lo stesso, e sta in tre righe della sua scheda: ultimo aggiornamento, testato fino a, e l’eventuale avviso in cima alla pagina. Se non ti convince nessuno dei due, il reindirizzamento si scrive in poche righe in un mu-plugin, senza aggiungere una dipendenza da mantenere:

<?php
/**
 * Plugin Name: Frontend spento
 * Description: Manda al frontend Next.js chi arriva sulle pagine pubbliche di WordPress.
 * Da salvare in wp-content/mu-plugins/frontend-spento.php
 */

add_action( 'template_redirect', function () {
	// La bacheca, il login, la REST API e l'endpoint GraphQL non passano di qui.
	// L'anteprima si esclude a mano: altrimenti il redirect la rompe.
	if ( is_feed() || is_preview() ) {
		return;
	}

	$frontend = 'https://www.esempio.it';
	$percorso = isset( $_SERVER['REQUEST_URI'] ) ? $_SERVER['REQUEST_URI'] : '/';

	wp_redirect( $frontend . $percorso, 301 );
	exit;
} );

Due avvertenze prima di incollarlo. template_redirect non si attiva sulla bacheca, sul login né sulle richieste REST e GraphQL, quindi la redazione continua a lavorare normalmente. E se il frontend non replica tutti gli URL del CMS, quel 301 manda alla pagina 404 del frontend: conviene farlo dopo aver definito la struttura degli indirizzi, non prima.

Il progetto Next.js: si parte dall’App Router

Qui c’è il punto in cui quasi tutte le guide in circolazione fanno perdere un pomeriggio, questa inclusa fino a oggi. Next.js ha due sistemi di routing: il Pages Router, con la cartella pages/, getStaticProps e next/head, e l’App Router, con la cartella app/, i Server Component e l’oggetto metadata. L’App Router è il sistema raccomandato dalla 13.4, ed è quello che create-next-app genera per impostazione predefinita: la documentazione ufficiale di Next.js è divisa nelle due sezioni, e vale la pena controllare sempre in quale ci si trova.

Il risultato è che se segui una guida scritta per il Pages Router dentro un progetto appena creato, il codice non funziona e gli errori non dicono perché. Se leggi una guida su headless che usa getStaticProps o next/head, stai leggendo istruzioni per l’altro sistema. Tutto quello che segue qui è App Router.

Creare il progetto

# Node.js 22 o 24: sono le due versioni LTS supportate.
# Next.js 16 richiede come minimo la 20.9, ma Node 20 è fuori supporto dal 30 aprile 2026.
node -v

npx create-next-app@latest sito-headless --yes
cd sito-headless
npm run dev

Il flag --yes accetta i valori predefiniti: TypeScript, ESLint, Tailwind CSS, App Router e Turbopack, con alias di importazione @/*. Senza il flag, la prima domanda del CLI ti propone gli stessi valori come «recommended defaults». In entrambi i casi ti ritrovi una cartella app/, non una pages/.

Sulla versione di Node vale la pena fermarsi un secondo, perché è l’indicazione che invecchia più in fretta di tutte. Il calendario ufficiale delle release è la fonte da controllare: alla data di questa revisione Node 18 è fuori supporto dal 30 aprile 2025 e Node 20 dal 30 aprile 2026, quindi le versioni sensate sono la 22 e la 24. Non fidarti del numero che trovi scritto in una guida, guarda il calendario.

Leggere i post: il fetch sta dentro il componente

Nell’App Router i componenti sono Server Component: girano sul server e possono essere async. Non esiste più una funzione separata che prepara i dati e li passa alla pagina — il fetch lo fa la pagina stessa. Ecco l’elenco dei post letto dalla REST API:

// app/blog/page.tsx
import Link from 'next/link'

type Post = {
  id: number
  slug: string
  title: { rendered: string }
}

// Rigenera la pagina al massimo una volta all'ora.
export const revalidate = 3600

export default async function BlogIndex() {
  const res = await fetch(
    'https://cms.esempio.it/wp-json/wp/v2/posts?per_page=12&_fields=id,slug,title'
  )

  if (!res.ok) {
    throw new Error(`WordPress ha risposto ${res.status}`)
  }

  const posts: Post[] = await res.json()

  return (
    <ul>
      {posts.map((post) => (
        <li key={post.id}>
          <Link href={`/blog/${post.slug}`}>
            <span dangerouslySetInnerHTML={{ __html: post.title.rendered }} />
          </Link>
        </li>
      ))}
    </ul>
  )
}

Tre cose da notare, perché sono quelle che nelle guide vengono saltate. Il tipo Post serve davvero: create-next-app genera un tsconfig.json con strict attivo, e un componente con parametri non tipizzati non compila. Il controllo su res.ok evita che una risposta 500 di WordPress diventi un errore incomprensibile in fase di build. E i titoli di WordPress arrivano con le entità HTML già codificate — l’apostrofo tipografico diventa &#8217; — quindi vanno inseriti con dangerouslySetInnerHTML o decodificati, altrimenti il lettore vede il codice.

Apollo Client solo se serve, e con la sintassi giusta

Se hai scelto GraphQL, la prima domanda è se ti serve un client. fetch parla benissimo con un endpoint GraphQL: è una POST con una stringa dentro. Un client come Apollo aggiunge cache normalizzata e gestione dello stato, cose utili in un’applicazione con molta interattività lato browser e superflue in un sito che genera pagine statiche.

// lib/wp.ts — GraphQL senza librerie
export async function wpQuery<T>(
  query: string,
  variables: Record<string, unknown> = {}
): Promise<T> {
  const res = await fetch('https://cms.esempio.it/graphql', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ query, variables }),
    cache: 'force-cache', // senza questo una POST non viene messa in cache
    next: { tags: ['wp'] },
  })

  const json = await res.json()

  if (json.errors) {
    throw new Error(json.errors.map((e: { message: string }) => e.message).join('; '))
  }

  return json.data as T
}

Le ultime due righe delle opzioni sono quelle che fanno la differenza, ed è un dettaglio che costa un pomeriggio a chi non lo sa: in Next.js la cache è opt-in, e una richiesta POST non viene messa in cache a meno che tu non lo chieda con cache: 'force-cache'. Siccome GraphQL viaggia in POST, senza quella riga l’etichetta tags non serve a niente — non c’è niente da etichettare — e l’invalidazione mirata che vedremo fra poco non ha effetto.

Se invece Apollo ti serve, attenzione alla versione. Apollo Client 4, uscito a settembre 2025, ha reso obbligatoria l’opzione link e ha rimosso uri, headers e credentials dal costruttore. La configurazione che gira su tutte le guide scritte prima non funziona più:

// lib/apollo.ts — sintassi valida da Apollo Client 4 in poi
import { ApolloClient, InMemoryCache, HttpLink } from '@apollo/client'

export const client = new ApolloClient({
  link: new HttpLink({ uri: 'https://cms.esempio.it/graphql' }),
  cache: new InMemoryCache(),
})

Nella versione 4 gli hook React vivono inoltre in @apollo/client/react, non più nel pacchetto principale. Se stai aggiornando un progetto esistente, sono i due errori che vedrai per primi.

La pagina del singolo post: rotta dinamica e immagini

La rotta e la generazione statica

Ogni post di WordPress diventa una pagina del frontend. Nell’App Router la rotta si crea con una cartella fra parentesi quadre, e l’elenco degli indirizzi da generare in fase di build lo restituisce generateStaticParams.

// app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation'

type Props = { params: Promise<{ slug: string }> }

export const revalidate = 3600

export async function generateStaticParams() {
  const res = await fetch(
    'https://cms.esempio.it/wp-json/wp/v2/posts?per_page=100&_fields=slug'
  )
  const posts: { slug: string }[] = await res.json()

  return posts.map((post) => ({ slug: post.slug }))
}

export default async function PostPage({ params }: Props) {
  const { slug } = await params

  const res = await fetch(
    `https://cms.esempio.it/wp-json/wp/v2/posts?slug=${slug}&_embed`
  )
  const posts = await res.json()
  const post = posts[0]

  if (!post) {
    notFound()
  }

  return (
    <article>
      <h1 dangerouslySetInnerHTML={{ __html: post.title.rendered }} />
      <div dangerouslySetInnerHTML={{ __html: post.content.rendered }} />
    </article>
  )
}

Due dettagli che cambiano dal Pages Router e che generano errori di tipo se ignorati: params è una Promise e va atteso con await, e un post non trovato si gestisce con notFound(), che rende la pagina 404 del frontend invece di far esplodere la build. Il parametro _fields=slug nella query di generateStaticParams chiede a WordPress solo il campo che serve: su un archivio di qualche centinaio di post la differenza in tempo di build si vede.

Le immagini, e il passo che manca in quasi tutte le guide

Il componente Image di Next.js ridimensiona, converte in formati moderni e imposta il lazy loading da solo. Ma le immagini di un sito headless stanno sul dominio di WordPress, e Next.js si rifiuta di ottimizzare immagini da host che non hai autorizzato: senza configurazione la richiesta risponde 400 Bad Request. È il passo mancante che fa concludere «l’esempio non funziona».

// next.config.ts
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  images: {
    remotePatterns: [
      {
        protocol: 'https',
        hostname: 'cms.esempio.it',
        port: '',
        pathname: '/wp-content/uploads/**',
      },
    ],
  },
}

export default nextConfig

Vale la pena restringere anche il pathname, come qui: autorizzare l’intero host significa lasciare che chiunque usi il tuo servizio di ottimizzazione per qualunque file presente su quel dominio. La vecchia opzione images.domains fa una cosa simile ma è deprecata dalla 14 proprio perché non permette di restringere protocollo, porta e percorso.

Con l’host autorizzato, l’immagine in evidenza si usa così — e l’attributo alt arriva da WordPress, che è il posto giusto per scriverlo:

// dentro il componente della pagina
import Image from 'next/image'

const media = post._embedded?.['wp:featuredmedia']?.[0]

{media && (
  <Image
    src={media.source_url}
    alt={media.alt_text ?? ''}
    width={media.media_details.width}
    height={media.media_details.height}
    sizes="(max-width: 768px) 100vw, 800px"
    priority
  />
)}

Larghezza e altezza reali arrivano già dentro media_details: usarle invece di numeri fissi evita che il browser debba riservare uno spazio sbagliato, ed è la differenza fra un layout stabile e un salto di contenuto durante il caricamento.

I metadati SEO quando Rank Math non serve più le pagine

Il plugin SEO continua a girare su WordPress, ma nessuno vede più le pagine che produce. Title, description, canonical, Open Graph, sitemap e dati strutturati diventano responsabilità del frontend. La buona notizia è che Next.js ha un’API dedicata per ognuna di queste cose e non serve installare niente.

generateMetadata: title, description e canonical

next/head qui non esiste: è un componente del Pages Router. Nell’App Router si esporta un oggetto metadata per i valori fissi e una funzione generateMetadata per quelli che dipendono dal contenuto.

// app/blog/[slug]/page.tsx — accanto al componente della pagina
import type { Metadata } from 'next'

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { slug } = await params

  const res = await fetch(
    `https://cms.esempio.it/wp-json/wp/v2/posts?slug=${slug}&_embed`
  )
  const post = (await res.json())[0]

  if (!post) {
    return { title: 'Contenuto non trovato' }
  }

  return {
    title: post.title.rendered,
    description: post.excerpt.rendered.replace(/<[^>]+>/g, '').trim(),
    alternates: {
      canonical: `https://www.esempio.it/blog/${slug}`,
    },
    openGraph: {
      title: post.title.rendered,
      type: 'article',
      publishedTime: post.date_gmt + 'Z',
    },
  }
}

Il fetch ripetuto fra generateMetadata e il componente non è uno spreco: Next.js memoizza le richieste identiche all’interno dello stesso rendering, quindi WordPress la riceve una volta sola.

Se sul CMS usi Rank Math o Yoast e vuoi che title e description restino di competenza della redazione, il modo corretto è leggerli dall’API — entrambi i plugin li espongono — e passarli qui invece del titolo grezzo del post. È anche l’occasione per verificare che il frontend rispetti la checklist SEO on-page che applicheresti a un sito normale: gli errori non cambiano natura solo perché l’HTML lo genera React.

Sitemap e robots.txt sono file del framework

Qui va corretta un’altra indicazione della versione precedente di questo articolo, che consigliava il pacchetto next-sitemap e diceva di richiamarlo da next.config.js. Il pacchetto va in realtà agganciato a uno script postbuild in package.json — e soprattutto non serve più: dalla 13.3 l’App Router genera sitemap e robots da due file, con i dati che vuoi tu.

// app/sitemap.ts
import type { MetadataRoute } from 'next'

export default async function sitemap(): Promise&lt;MetadataRoute.Sitemap&gt; {
  const res = await fetch(
    'https://cms.esempio.it/wp-json/wp/v2/posts?per_page=100&amp;_fields=slug,modified_gmt'
  )
  const posts: { slug: string; modified_gmt: string }[] = await res.json()

  return [
    {
      url: 'https://www.esempio.it',
      lastModified: new Date(),
      changeFrequency: 'daily',
      priority: 1,
    },
    ...posts.map((post) =&gt; ({
      url: `https://www.esempio.it/blog/${post.slug}`,
      lastModified: new Date(post.modified_gmt + 'Z'),
      changeFrequency: 'monthly' as const,
      priority: 0.7,
    })),
  ]
}
// app/robots.ts
import type { MetadataRoute } from 'next'

export default function robots(): MetadataRoute.Robots {
  return {
    rules: {
      userAgent: '*',
      allow: '/',
      disallow: '/api/',
    },
    sitemap: 'https://www.esempio.it/sitemap.xml',
  }
}

La Z aggiunta a modified_gmt non è un dettaglio estetico: WordPress restituisce quella data senza indicatore di fuso orario, e senza Z viene interpretata come ora locale del server che esegue la build. Su un servizio di deploy che gira in un altro continente, le date della sitemap risultano sbagliate di ore.

Cosa si perde davvero, e come si recupera

La parte di un plugin SEO che sparisce è quella che scrive nell’HTML: i dati strutturati, i breadcrumb, i tag Open Graph. I dati strutturati schema.org vanno riscritti nel frontend — si inserisce un <script type="application/ld+json"> nel componente della pagina — e i redirect gestiti dal plugin vanno spostati nel file next.config.ts o nella configurazione della CDN. È un elenco corto ma va fatto prima di andare online, perché sono esattamente le cose che nessuno nota mancare finché non calano le impressioni.

Tenere aggiornato il frontend quando cambia un contenuto

È il problema strutturale dell’architettura: le pagine sono state generate una volta, WordPress non lo sa, e chi pubblica si aspetta di vedere il post online in trenta secondi. Le soluzioni sono tre e vanno usate insieme, non in alternativa.

La rete di sicurezza: rigenerazione a tempo

È la riga export const revalidate = 3600 che hai già visto. Dopo un’ora, la prima richiesta alla pagina riceve comunque subito la versione in cache, e nel frattempo Next.js ne rigenera una nuova in background: chi visita non aspetta mai. La documentazione consiglia valori alti — un’ora, non un secondo — perché questo meccanismo è la rete di sicurezza, non lo strumento principale.

Il modo giusto: revalidation on-demand

Quello che serve davvero è che WordPress avvisi il frontend nel momento in cui un contenuto cambia. Nel frontend si scrive un Route Handler che riceve la notifica e invalida solo la pagina interessata:

// app/api/revalidate/route.ts
import { revalidatePath, revalidateTag } from 'next/cache'
import type { NextRequest } from 'next/server'

export async function POST(request: NextRequest) {
  const secret = request.nextUrl.searchParams.get('secret')

  if (secret !== process.env.WP_REVALIDATE_SECRET) {
    return Response.json({ message: 'Non autorizzato' }, { status: 401 })
  }

  const { slug } = await request.json()

  if (slug) {
    revalidatePath(`/blog/${slug}`)
  }

  revalidatePath('/blog')
  revalidateTag('wp')

  return Response.json({ revalidated: true, slug, now: Date.now() })
}

Il segreto nella query string non è formalità: senza, quell’indirizzo è un pulsante pubblico per far rigenerare il sito a chiunque lo trovi. Va tenuto in una variabile d’ambiente, non nel codice.

Da sapere, perché non è ovvio: revalidatePath invalida la cache, ma la pagina viene effettivamente rigenerata alla richiesta successiva. Se stai guardando il frontend con l’anteprima aperta in un’altra scheda, la prima ricarica può ancora mostrarti la versione vecchia. E se il frontend gira su più istanze, l’invalidazione raggiunge solo quella che ha ricevuto la chiamata: serve una cache condivisa.

Il webhook lato WordPress

Dall’altra parte serve qualcosa che chiami quell’indirizzo alla pubblicazione o alla modifica di un post. Si può fare con un plugin di automazione come WP Webhooks, oggi mantenuto da Cozmoslabs, ma sono quindici righe in un mu-plugin e non vale la dipendenza:

<?php
/**
 * Plugin Name: Avvisa il frontend
 * Da salvare in wp-content/mu-plugins/avvisa-frontend.php
 */

add_action( 'transition_post_status', function ( $new_status, $old_status, $post ) {
	if ( ! defined( 'WP_REVALIDATE_SECRET' ) ) {
		return;
	}
	// Interessano solo i post: pubblicati ora, modificati, o rimossi dal pubblico.
	if ( 'post' !== $post->post_type ) {
		return;
	}
	if ( 'publish' !== $new_status && 'publish' !== $old_status ) {
		return;
	}

	// Quando un post finisce nel cestino il core ha gia' aggiunto __trashed
	// allo slug, prima che questo hook parta: va tolto o si rigenera l'URL sbagliato.
	$slug = str_replace( '__trashed', '', $post->post_name );

	$url = add_query_arg(
		'secret',
		WP_REVALIDATE_SECRET, // definita in wp-config.php
		'https://www.esempio.it/api/revalidate'
	);

	wp_remote_post(
		$url,
		array(
			'timeout'  => 5,
			'blocking' => false, // non far aspettare la redazione
			'headers'  => array( 'Content-Type' => 'application/json' ),
			'body'     => wp_json_encode( array( 'slug' => $slug ) ),
		)
	);
}, 10, 3 );

Il controllo su $new_status e $old_status copre i tre casi che contano: un post che viene pubblicato, uno già pubblicato che viene modificato, e uno che torna in bozza o nel cestino — in quest’ultimo caso la pagina va rigenerata proprio per farla sparire. Ed è proprio il cestino a nascondere la trappola: quando un post ci finisce, WordPress aggiunge __trashed al suo slug prima di far scattare questo hook, non dopo. Senza la sostituzione, il frontend riceve mio-post__trashed, rigenera un indirizzo che non esiste e lascia online la pagina che volevi togliere — senza nessun errore da nessuna parte. 'blocking' => false fa partire la richiesta senza attendere la risposta, così il salvataggio nell’editor resta immediato; il rovescio della medaglia è che se il frontend non risponde non te ne accorgi, e conviene tenere un occhio sui log del deploy.

Il Deploy Hook di Vercel, e quando ha ancora senso

Su Vercel esiste una terza via: un URL che, se chiamato, fa ripartire l’intera build. Si crea nelle impostazioni del progetto, sotto Settings → Git → Deploy Hooks, scegliendo un nome e il ramo da distribuire, e si attiva con una richiesta GET o POST senza autenticazione — quindi anche quell’URL va trattato come una password.

È lo strumento sbagliato per «ho corretto un refuso»: rigenerare tutto il sito per una pagina è lento e, su un piano a consumo, costa. Resta però quello giusto per i cambiamenti che toccano tutte le pagine — un menù, un footer, un dato di contatto — e come pulsante di emergenza quando qualcosa nella cache si è incastrato. Vale la pena sapere che i piani Hobby e Pro consentono fino a 5 deploy hook per progetto e 60 attivazioni all’ora.

L’anteprima delle bozze, e cosa è cambiato nel 2026

È il problema che affossa i progetti headless più di ogni difficoltà tecnica, perché non è tecnico: è che chi scrive perde il pulsante «Anteprima». In WordPress classico quel pulsante mostra la bozza renderizzata dal tema; senza tema, non mostra niente di utile.

La buona notizia è che Next.js ha una funzione dedicata proprio a questo, e quasi nessuna guida su headless la nomina: si chiama Draft Mode. Non è un trucco, è il meccanismo previsto dal framework per i CMS headless: quando è attiva, per quel visitatore le richieste fetch saltano ogni livello di cache e la pagina viene servita con Cache-Control: private, no-cache, mentre tutti gli altri continuano a vedere la versione statica.

Si attiva da un Route Handler che WordPress apre al posto della propria anteprima:

// app/api/draft/route.ts
import { draftMode } from 'next/headers'
import { redirect } from 'next/navigation'

export async function GET(request: Request) {
  const { searchParams } = new URL(request.url)
  const secret = searchParams.get('secret')
  const id = searchParams.get('id')

  if (secret !== process.env.WP_PREVIEW_SECRET || !id || !/^\d+$/.test(id)) {
    return new Response('Richiesta non valida', { status: 401 })
  }

  const draft = await draftMode()
  draft.enable()

  // La destinazione si costruisce qui, da un id già validato. Non si reindirizza
  // mai a un percorso arrivato dalla query string: sarebbe un redirect aperto.
  redirect(`/anteprima/${id}`)
}

Dall’altra parte, il pulsante «Anteprima» di WordPress si dirotta con un filtro del core, preview_post_link, senza toccare l’editor:

<?php
// Manda il pulsante Anteprima di WordPress alla rotta di bozza del frontend.

add_filter( 'preview_post_link', function ( $link, $post ) {
	if ( ! defined( 'WP_PREVIEW_SECRET' ) || 'post' !== $post->post_type ) {
		return $link;
	}

	// Si passa l'ID e non lo slug: un post mai pubblicato ha post_name vuoto.
	// Il core lo dice esplicitamente: «Drafts and pending posts are allowed
	// to have an empty post name».
	return add_query_arg(
		array(
			'secret' => WP_PREVIEW_SECRET,
			'id'     => $post->ID,
		),
		'https://www.esempio.it/api/draft'
	);
}, 10, 2 );

La rotta manda a app/anteprima/[id]/page.tsx, una pagina che legge il post per ID invece che per slug. Non è un capriccio: un post mai pubblicato non ha ancora uno slug — il core di WordPress lo dice in un commento, «le bozze e i post in attesa possono avere un post name vuoto» — quindi un’anteprima costruita sullo slug funziona sui post già online e fallisce in silenzio proprio sul caso più comune, il pezzo nuovo che non è mai uscito.

Resta un pezzo da risolvere sul lato WordPress: le bozze non sono pubbliche, quindi la richiesta con cui il frontend le legge dev’essere autenticata — con una password applicazione o con un token — altrimenti l’API risponde che quel contenuto non esiste. È il punto in cui il lavoro si complica, e va previsto nel preventivo.

E proprio su quel pezzo qualcosa si è mosso da poco. WPGraphQL 2.21.0, rilasciato alla fine di agosto 2026, ha introdotto un contesto di anteprima a livello di richiesta: un’intestazione X-GraphQL-Preview (con equivalente nel campo extensions del corpo della richiesta) con cui un utente autenticato e autorizzato riceve, sui campi previsti, i valori dell’ultimo salvataggio automatico invece di quelli pubblicati, mentre l’identità del post resta quella pubblica. Rispetto a prima cambia il modo di chiedere l’anteprima: non serve più una query diversa per le bozze, basta la stessa query con un’intestazione in più.

È una novità di poche settimane, quindi il consiglio operativo è di verificarne lo stato sulle note di rilascio prima di costruirci sopra. Il punto che non cambia è un altro, ed è di metodo: l’anteprima va fatta funzionare prima di consegnare il sito alla redazione, non dopo. Un progetto headless in cui gli autori pubblicano alla cieca viene abbandonato nel giro di due mesi, e il motivo non comparirà mai in un rapporto tecnico.

La sicurezza cambia di segno

Si legge spesso che headless «rende il sito più sicuro». È vero a metà, e la metà che manca è quella che conta. Vero: il frontend pubblico è HTML statico, non esegue PHP, non ha un database dietro e non ha una pagina di login — tutta la superficie di attacco classica di WordPress semplicemente non è raggiungibile da lì.

Falso, però, che il CMS sparisca. Continua a esistere, con la sua bacheca e il suo wp-login.php, e in più ha un requisito nuovo: l’endpoint da cui il frontend legge i contenuti deve essere raggiungibile. È il punto in cui headless si scontra con un consiglio che do sempre e che resta valido negli altri casi — nella checklist di sicurezza WordPress uno dei controlli è impedire alla REST API di esporre l’elenco degli utenti, e in generale limitare chi la interroga. Qui non puoi chiuderla: è il tuo sito.

Quello che si può fare è restringere invece di chiudere:

  • Lasciare aperti solo gli endpoint che il frontend usa davvero. Il resto della REST API può richiedere autenticazione: /wp-json/wp/v2/users non serve a un blog pubblico.
  • Mettere un limite di frequenza davanti all’endpoint. Una singola query GraphQL può costare molto al server, e chi vuole metterti in difficoltà non ha bisogno di un attacco DDoS in piena regola se un endpoint costoso è aperto e senza limiti.
  • Limitare la profondità delle query GraphQL. WPGraphQL ha impostazioni apposite, e vanno guardate prima di andare in produzione, non dopo il primo incidente.
  • Portare la bacheca fuori dalla vista. Se il CMS sta su un sottodominio dedicato, quel sottodominio può stare dietro autenticazione a livello di server o su una lista di indirizzi consentiti, mentre l’endpoint dei contenuti resta pubblico.
  • Tenere il CMS aggiornato lo stesso. Un’installazione «tanto non la vede nessuno» è quella che nessuno aggiorna, e resta il bersaglio più facile che ci sia.

Il riassunto onesto è che headless sposta il rischio: toglie la superficie di attacco del frontend e concentra tutto su un endpoint API, che va protetto con criteri che nel mondo dei temi non servivano.

Mettere in produzione senza sorprese

Il frontend si distribuisce su una piattaforma che sa eseguire Next.js — Vercel è la scelta naturale perché è di chi sviluppa il framework, ma un container su un VPS fa lo stesso lavoro. WordPress resta dove sta, o si sposta su un sottodominio dedicato.

Se il passaggio a headless avviene su un sito esistente, la parte delicata non è nessuna di quelle viste finora: è il cambio di indirizzi. Ogni URL che cambia forma ha bisogno del suo redirect 301, e il momento del passaggio va gestito come qualunque altro cutover — vale integralmente la procedura descritta nella guida alla migrazione di un sito WordPress senza downtime. La differenza è che i redirect non li scrive più un plugin: stanno in next.config.ts o nella configurazione della CDN.

Le tre verifiche che faccio sempre il giorno del passaggio, in quest’ordine: che ogni vecchio URL risponda con un solo salto verso il nuovo; che la sitemap generata dal frontend contenga tutti i contenuti e nessuna bozza; e che una pubblicazione di prova dalla bacheca compaia online entro un minuto. Se la terza fallisce, il problema è quasi sempre il segreto del webhook scritto in due posti diversi.

Domande frequenti

Headless WordPress conviene per un blog o un sito vetrina?

Quasi mai. Un blog o una vetrina hanno un solo consumatore dei contenuti — il sito stesso — e non hanno bisogno di un frontend applicativo. In quei casi un tema ben fatto, o un child theme su un tema solido, dà lo stesso risultato con una manutenzione sola invece di due. Headless inizia ad avere senso quando gli stessi contenuti devono alimentare più destinazioni, o quando il frontend è di fatto un’applicazione.

Meglio la REST API o WPGraphQL?

La REST API se il sito è semplice: è già nel core, non aggiunge dipendenze e non richiede di imparare un linguaggio di query. WPGraphQL se le pagine compongono dati da fonti diverse o se il progetto vive di campi personalizzati, perché una sola query sostituisce tre o quattro chiamate REST e l’integrazione con ACF è automatica. Dall’ottobre 2024 WPGraphQL è un canonical plugin di WordPress.org, quindi il rischio di abbandono che lo penalizzava è molto ridotto.

Perché il codice che trovo online non funziona nel progetto appena creato?

Perché quasi certamente è scritto per il Pages Router, mentre create-next-app genera un progetto App Router. Il segnale è immediato: se l’esempio usa getStaticProps, getStaticPaths, la cartella pages/ o next/head, appartiene all’altro sistema. Nell’App Router il fetch sta dentro il componente, l’elenco delle rotte statiche lo dà generateStaticParams e i metadati si esportano con generateMetadata.

Come faccio a vedere l’anteprima di una bozza su un sito headless?

Si usa la Draft Mode di Next.js, che è la funzione prevista dal framework proprio per i CMS headless: un Route Handler protetto da un token la attiva, e per quel visitatore le richieste saltano ogni livello di cache. Il pulsante «Anteprima» di WordPress si dirotta su quella rotta con il filtro preview_post_link. Resta da autenticare la lettura delle bozze, che non sono pubbliche; dalla versione 2.21.0, di fine agosto 2026, WPGraphQL offre su questo un contesto di anteprima a livello di richiesta tramite l’intestazione X-GraphQL-Preview. È la funzione da far funzionare per prima: senza anteprima, la redazione smette di usare il sistema.

Il sito headless si aggiorna da solo quando pubblico un post?

No, va collegato. Il modo corretto è un webhook: WordPress, alla pubblicazione o alla modifica, chiama una rotta del frontend che invalida la cache di quella pagina con revalidatePath. In aggiunta si imposta una rigenerazione a tempo come rete di sicurezza. Il deploy hook che rigenera tutto il sito va tenuto per i cambiamenti globali, come un menù o un footer, perché su una singola pagina è lento e costoso.

Un sito headless è più sicuro di un WordPress normale?

Sposta il rischio più che ridurlo. Il frontend pubblico è statico e non ha login né database, quindi la superficie di attacco classica sparisce da lì. In compenso il CMS continua a esistere e l’endpoint API deve restare raggiungibile per definizione: non puoi chiuderlo come faresti su un sito normale. Va quindi limitato nella frequenza delle richieste, ristretto agli endpoint che servono davvero e tenuto aggiornato come qualunque installazione esposta.

In sintesi: quando lo consiglio e quando no

Headless WordPress con Next.js è una buona architettura per i progetti che hanno un motivo per esserlo: più destinazioni per gli stessi contenuti, un frontend che è un’applicazione, un team che lavora in React. In quei casi il controllo che ottieni sull’HTML e sul comportamento del sito non si ottiene in nessun altro modo, e il costo di manutenzione doppio è giustificato.

Per tutto il resto — che è la maggioranza dei siti — la strada corta funziona meglio: un tema scelto con criterio, personalizzato dove serve, su un hosting adeguato. Il tempo che risparmi in infrastruttura lo metti nei contenuti, ed è quasi sempre l’investimento con il ritorno più alto.

Se stai valutando il passaggio e vuoi una risposta motivata prima di impegnare un budget, o se il progetto ha già i requisiti giusti e ti serve chi lo costruisca, posso aiutarti in entrambe le fasi: analisi di fattibilità con i costi reali sul tavolo, oppure sviluppo completo del frontend con il CMS collegato.

Parliamo del tuo progetto »

Indice dei contenuti

  • Che cosa cambia davvero quando stacchi il frontend
  • REST API o WPGraphQL: la scelta che decide tutto il resto
  • Cosa si installa sul lato WordPress
  • Il progetto Next.js: si parte dall’App Router
  • La pagina del singolo post: rotta dinamica e immagini
  • I metadati SEO quando Rank Math non serve più le pagine
  • Tenere aggiornato il frontend quando cambia un contenuto
  • L’anteprima delle bozze, e cosa è cambiato nel 2026
  • La sicurezza cambia di segno
  • Mettere in produzione senza sorprese
  • Domande frequenti
  • In sintesi: quando lo consiglio e quando no

Tag in Evidenza

HeadlessNext.jsPerformancePlugin WordPressSEO tecnicaSitemapTemi WordPressWordPress

Condividi


Lascia un commento Annulla risposta

Il tuo indirizzo email non sarà pubblicato. I campi obbligatori sono contrassegnati *

Alessandro Aili - WordPress Developer Alessandro Aili - WordPress Developer
Sono un WordPress developer che affronta tutte le tue sfide online.
Skills
  • Sviluppo di Siti Web Personalizzati
  • Ottimizzazione delle Prestazioni
  • Sicurezza del Sito Web
  • Aggiornamenti e Manutenzione
  • Sviluppo di Temi e Plugin Personalizzati
  • Migrazione e Trasferimento del Sito
© Copyright 2026 Alessandro Aili
  • Mappa del sito
  • Privacy Policy
  • Cookie Policy
  • Preferenze cookie
Alessandro Aili - WordPress Developer