Cosa Significa WordPress Headless (e Perché nel 2026 Ha Senso)
WordPress headless separa il backend dal frontend. Il backend resta WordPress: gestione contenuti, media, utenti, plugin per estendere il data model. Il frontend diventa un’applicazione JavaScript separata, in questo caso Next.js 15, che consuma i dati via API e renderizza pagine statiche o server-side.
Il risultato? Pagine che caricano in sotto 200ms, Core Web Vitals vicino al punteggio massimo, e zero PHP sul percorso critico del visitatore. Nella nostra esperienza con siti client ad alto traffico, l’architettura headless riduce il TTFB del 60-80% rispetto a un setup WordPress tradizionale con Nginx e PHP-FPM ben configurato.
Ma non è tutto oro. Perdi le anteprime native di WordPress, i page builder visivi, e buona parte dei plugin che iniettano HTML nel frontend. Guadagni controllo totale sullo stack frontend, performance costanti, e la capacità di servire lo stesso contenuto a web app, mobile app, e sito pubblico da un’unica API.
Per le agenzie che gestiscono più siti WordPress, headless introduce una variabile interessante: puoi standardizzare il frontend su un unico template Next.js e variare solo il backend WordPress per ogni client. Un setup, dieci clienti, dieci branding diversi.
TL;DR
- Architettura: WordPress come CMS headless + Next.js 15 App Router come frontend
- API: REST API nativa o WPGraphQL per query tipizzate
- Rendering: ISR (Incremental Static Regeneration) per contenuti che cambiano spesso
- Deploy: Vercel, Railway, o self-hosted con Docker
- Casi d’uso ideali: siti editoriali, portfolio, marketing site con budget performance strict
- Casi da evitare: WooCommerce con checkout complesso, siti pieni di shortcode plugin
Quando Headless Ha Senso (e Quando No)
Headless vale la pena quando:
- Il sito ha bisogno di PageSpeed costantemente sopra 95
- Il team editoriale è a suo agio in WordPress ma quello tecnico vuole React
- Devi servire lo stesso contenuto a web app, mobile app, e sito pubblico
- Vuoi deploy su edge con zero PHP sul percorso utente
Headless diventa doloroso quando:
- Il sito dipende da WooCommerce (lo stato del carrello è difficile da replicare fuori da PHP)
- Usi molti plugin che renderizzano shortcode o iniettano HTML
- Il team non ha esperienza JavaScript
- Serve anteprima live dei blocchi Gutenberg istantanea
Onestamente, per la maggior parte dei siti aziendali italiani standard, un WordPress tradizionale ben ottimizzato con un tema leggero e cache configurata correttamente raggiunge performance accettabili. Headless è l’arma pesante: la usi quando il traditional stack non basta più.
Preparare WordPress come CMS Headless
Step 1: Verificare la REST API
La REST API è attiva di default in ogni installazione WordPress moderna. Per verificarla, apri nel browser:
https://tuodominio.com/wp-json/wp/v2/posts
Se vedi un array JSON di post, sei a posto. Next.js chiamerà questo endpoint server-side, quindi CORS non è un problema per le Server Components. Lo diventa solo se usi Client Components che fanno fetch dal browser.
Step 2: Configurare CORS (solo per Client Components)
Se hai componenti client che chiamano direttamente l’API WordPress dal browser, devi configurare CORS. Aggiungi questo al functions.php del tuo tema o in un mu-plugin:
add_action( 'rest_api_init', function() {
remove_filter( 'rest_pre_serve_request', 'rest_send_cors_headers' );
add_filter( 'rest_pre_serve_request', function( $value ) {
$origin = get_http_origin();
$allowed = [
'https://tuosito.vercel.app',
'http://localhost:3000',
];
if ( in_array( $origin, $allowed, true ) ) {
header( 'Access-Control-Allow-Origin: ' . esc_url_raw( $origin ) );
header( 'Access-Control-Allow-Methods: GET, POST, OPTIONS' );
header( 'Access-Control-Allow-Credentials: true' );
}
return $value;
} );
} );
Step 3: Installare WPGraphQL (opzionale ma consigliato)
La REST API funziona per query standard su post, page, e tassonomie. WPGraphQL ti dà un endpoint tipizzato dove richiedi solo i campi che ti servono, riducendo il payload e semplificando query annidate complesse. Installa il plugin WPGraphQL dalla directory ufficiale. Una volta attivo, l’endpoint GraphQL è disponibile su:
https://tuodominio.com/graphql
Una query base per recuperare post con titolo, slug, e immagine in evidenza:
query GetPosts {
posts(first: 10) {
nodes {
id
title
slug
date
excerpt
featuredImage {
node {
sourceUrl
altText
mediaDetails {
width
height
}
}
}
}
}
}
Step 4: Creare una Application Password per le anteprime
Le anteprime bozza richiedono richieste autenticate. Vai su Utenti > Profilo, scorri fino a Password Applicazione, inserisci un nome tipo “Next.js Preview” e clicca Aggiungi. Copia la password subito perché viene mostrata una sola volta.
La userai come credenziale Basic Auth: codifica in Base64 username:password_applicazione e inviala nell’header Authorization per le richieste di anteprima.
Setup del Progetto Next.js 15 App Router
Step 5: Creare il progetto
npx create-next-app@latest mio-headless-wp --typescript --tailwind --app
cd mio-headless-wp
Il flag --app attiva l’App Router. Questa guida usa Next.js 15 con React 19 Server Components come default. Tutte le chiamate fetch avvengono nelle Server Components a meno che non aggiungi esplicitamente "use client" in cima al file.
Step 6: Variabili d’ambiente
Crea un file .env.local nella root del progetto:
NEXT_PUBLIC_WP_URL=https://tuodominio.com
WP_AUTH_USER=tuo-wp-username
WP_AUTH_PASS=tua-application-password
PREVIEW_SECRET=una-stringa-random
REVALIDATE_SECRET=altra-stringa-per-webhook
Mai committare .env.local su Git. Aggiungilo a .gitignore prima del primo push.
Step 7: Client API WordPress
Crea lib/wordpress.ts:
const WP_URL = process.env.NEXT_PUBLIC_WP_URL!;
export async function getAllPosts() {
const res = await fetch(
`${WP_URL}/wp-json/wp/v2/posts?_embed&per_page=100`,
{ next: { tags: ['posts'] } }
);
if (!res.ok) throw new Error('Failed to fetch posts');
return res.json();
}
export async function getPostBySlug(slug: string) {
const res = await fetch(
`${WP_URL}/wp-json/wp/v2/posts?slug=${slug}&_embed`,
{ next: { tags: [`post-${slug}`] } }
);
if (!res.ok) throw new Error('Failed to fetch post');
const data = await res.json();
return data[0];
}
export async function getAllCategories() {
const res = await fetch(
`${WP_URL}/wp-json/wp/v2/categories?per_page=100`,
{ next: { tags: ['categories'] } }
);
if (!res.ok) throw new Error('Failed to fetch categories');
return res.json();
}
La parte importante qui è next: { tags: [...] }. Questi tag permettono a Next.js di invalidare la cache selettivamente quando un contenuto cambia su WordPress, tramite on-demand revalidation.
Routing e Pagine in Next.js 15 App Router
Homepage con lista post
Crea app/page.tsx:
import Link from 'next/link';
import { getAllPosts } from '@/lib/wordpress';
export default async function HomePage() {
const posts = await getAllPosts();
return (
<main className="max-w-4xl mx-auto px-4 py-8">
<h1>Blog</h1>
<section>
{posts.map((post: any) => (
<article key={post.id}>
<Link href={`/blog/${post.slug}`}>
<h2>{post.title.rendered}</h2>
</Link>
<div dangerouslySetInnerHTML={{ __html: post.excerpt.rendered }} />
</article>
))}
</section>
</main>
);
}
Questa è una Server Component. Il fetch avviene sul server, zero JavaScript inviato al client per il rendering della lista.
Pagina singolo articolo
Crea app/blog/[slug]/page.tsx:
import { getAllPosts, getPostBySlug } from '@/lib/wordpress';
import { notFound } from 'next/navigation';
export async function generateStaticParams() {
const posts = await getAllPosts();
return posts.map((post: any) => ({ slug: post.slug }));
}
export default async function BlogPost({ params }: { params: { slug: string } }) {
const post = await getPostBySlug(params.slug);
if (!post) return notFound();
return (
<article className="max-w-3xl mx-auto px-4 py-8">
<h1>{post.title.rendered}</h1>
<div
dangerouslySetInnerHTML={{ __html: post.content.rendered }}
/>
</article>
);
}
generateStaticParams pre-genera tutte le pagine dei post a build time. Con ISR, queste pagine vengono rigenerate su intervalli o su webhook, non ad ogni richiesta.
ISR: Incremental Static Regeneration
ISR è il motivo principale per cui Next.js 15 + WordPress headless batte un setup tradizionale. Invece di rigenerare ogni pagina ad ogni richiesta (SSR costoso) o di servire sempre HTML statico (stale), ISR rigenera le pagine in background quando i dati cambiano.
Revalidation basata su tempo
export const revalidate = 3600; // rigenera ogni ora
Aggiungi questa riga in cima a qualsiasi pagina e Next.js la rigenererà in background ogni ora, servendo nel frattempo la versione cache.
On-demand revalidation via webhook
Questo è il metodo migliore per agenzie. Quando un contenuto viene aggiornato su WordPress, un webhook notifica Next.js di rigenerare solo quella pagina. Zero attesa, zero risorse sprecate.
Crea app/api/revalidate/route.ts:
import { revalidateTag } from 'next/cache';
import { NextRequest, NextResponse } from 'next/server';
export async function POST(req: NextRequest) {
const body = await req.json();
const secret = req.headers.get('x-revalidate-secret');
if (secret !== process.env.REVALIDATE_SECRET) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
if (body.slug) {
revalidateTag(`post-${body.slug}`);
}
revalidateTag('posts');
return NextResponse.json({ revalidated: true });
}
Su WordPress, aggiungi un webhook nel functions.php o via plugin quando un post viene salvato:
add_action( 'save_post_post', function( $post_id ) {
if ( wp_is_post_revision( $post_id ) ) return;
$post = get_post( $post_id );
wp_remote_post( 'https://tuosito.vercel.app/api/revalidate', [
'headers' => [
'Content-Type' => 'application/json',
'x-revalidate-secret' => 'tua-secret-qui',
],
'body' => json_encode([
'slug' => $post->post_name,
]),
'timeout' => 10,
] );
} );
Così, ogni volta che un editor pubblica o aggiorna un post, WordPress chiama Next.js che rigenera solo la pagina interessata. Il visitatore vede sempre contenuto fresco, ma con performance da sito statico.
Gestione Immagini con next/image
Le immagini sono il problema più comune nei setup headless. WordPress serve immagini dal suo dominio, Next.js vuole ottimizzarle tramite next/image. Devi configurare i domini remoti.
In next.config.ts:
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'tuodominio.com',
},
],
},
};
export default nextConfig;
Poi usa next/image normalmente:
import Image from 'next/image';
// Dentro la componente post
{post.featured_image_url && (
<Image
src={post.featured_image_url}
alt={post.title.rendered}
width={1200}
height={630}
priority
/>
)}
Next.js ottimizza automaticamente: WebP, resize responsive, lazy loading. Zero plugin WordPress necessari.
Anteprima Bozza (Draft Preview)
Una delle funzionalità che perdi andando headless è l’anteprima nativa di WordPress. La ricostruisci con un endpoint dedicato.
Crea app/api/preview/route.ts:
import { NextRequest, NextResponse } from 'next/server';
export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url);
const secret = searchParams.get('secret');
const slug = searchParams.get('slug');
if (secret !== process.env.PREVIEW_SECRET || !slug) {
return NextResponse.json({ error: 'Invalid token' }, { status: 401 });
}
const res = NextResponse.redirect(
`${process.env.NEXT_PUBLIC_WP_URL.replace(/\/$/, '')}/blog/${slug}?preview=true`
);
res.cookies.set('preview', 'true', {
httpOnly: true,
sameSite: 'strict',
});
return res;
}
Nella pagina del post, leggi il cookie e fai una chiamata autenticata se l’anteprima è attiva:
import { cookies } from 'next/headers';
export default async function BlogPost({ params }: { params: { slug: string } }) {
const isPreview = cookies().get('preview')?.value === 'true';
const headers: HeadersInit = {};
if (isPreview) {
const auth = Buffer.from(
`${process.env.WP_AUTH_USER}:${process.env.WP_AUTH_PASS}`
).toString('base64');
headers.Authorization = `Basic ${auth}`;
}
const res = await fetch(
`${process.env.NEXT_PUBLIC_WP_URL}/wp-json/wp/v2/posts?slug=${params.slug}&_embed&status=draft,publish`,
{ headers, cache: isPreview ? 'no-store' : 'force-cache' }
);
// ... rendering
}
Su WordPress, configura il link di anteprima nel functions.php:
add_filter( 'preview_post_link', function( $link, $post ) {
return sprintf(
'https://tuosito.vercel.app/api/preview?secret=%s&slug=%s',
esc_html( 'tua-preview-secret' ),
$post->post_name
);
}, 10, 2 );
REST API vs WPGraphQL: Quale Scegliere
| Criterio | REST API | WPGraphQL |
|---|---|---|
| Setup | Zero, nativa in WordPress | Plugin da installare |
| Payload size | Più grande (campi fissi) | Minimizzato (solo campi richiesti) |
| Query complesse | Multiple chiamate necessarie | Single query annidata |
| Caching | HTTP cache standard | POST, cache più complessa |
| Tipizzazione | Schema introspezionabile, codegen nativo | |
| Ackenze | Documentazione ampia, esempi ovunque | Documentazione buona, community minore |
Nella nostra esperienza, REST API è sufficiente per il 90% dei siti. WPGraphQL brilla quando hai data model complessi con relazioni profonde (Custom Post Types con campi ACF correlati). Per un blog standard o un sito aziendale, REST con _embed è più semplice da mantenere.
Autenticazione: JWT, OAuth e Application Passwords
Per le chiamate pubbliche (lettura contenuti published), non serve autenticazione. Per le anteprime e le chiamate autenticate, hai tre opzioni:
Application Passwords (consigliato per agenzie)
Native in WordPress dalla 5.6. Crei una password per applicazione nel profilo utente e la usi come Basic Auth. Semplice, funziona, zero configurazione extra. Per un’agenzia che gestisce 20 siti, questo è il metodo più rapido.
const auth = Buffer.from(
`${process.env.WP_AUTH_USER}:${process.env.WP_AUTH_PASS}`
).toString('base64');
fetch(`${WP_URL}/wp-json/wp/v2/posts?status=draft`, {
headers: { Authorization: `Basic ${auth}` }
});
JWT Auth
Richiede il plugin JWT Authentication for WP REST API. Utile quando devi autenticare utenti front-end (login area clienti). Il token JWT scade e va rinnovato, il che aggiunge complessità.
OAuth 2.0
Eccessivo per la maggior parte dei casi d’uso agency. Lo consideriamo solo per integrazioni enterprise con flussi OAuth multi-tenant.
Per approfondire l’autenticazione REST API, abbiamo scritto una guida dedicata sugli endpoint personalizzati WordPress.
Deploy in Produzione
Opzione 1: Vercel (più semplice)
Vercel è il creatore di Next.js. Il deploy è diretto dal repo Git, preview deployment per ogni branch, edge network globale. Il piano gratuito basta per progetti piccoli, quello Pro ($20/mese) per siti client reali.
npm install -g vercel
vercel --prod
Opzione 2: Self-hosted con Docker
Per i client che richiedono data residency in Italia o non vogliono lock-in Vercel, Docker è la strada. Un docker-compose.yml base:
version: '3.8'
services:
nextjs:
build: .
ports:
- '3000:3000'
environment:
- NEXT_PUBLIC_WP_URL=https://wp.cliente.it
- WP_AUTH_USER=${WP_AUTH_USER}
- WP_AUTH_PASS=${WP_AUTH_PASS}
- PREVIEW_SECRET=${PREVIEW_SECRET}
- REVALIDATE_SECRET=${REVALIDATE_SECRET}
restart: unless-stopped
nginx:
image: nginx:alpine
ports:
- '80:80'
- '443:443'
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf
- ./certs:/etc/nginx/certs
depends_on:
- nextjs
restart: unless-stopped
Il Dockerfile per Next.js 15 standalone:
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
EXPOSE 3000
CMD ["node", "server.js"]
Abilita l’output standalone in next.config.ts:
const nextConfig: NextConfig = {
output: 'standalone',
// ... resto config
};
Questo setup gira su qualsiasi VPS con Docker. Costo: 5-10€/mese per un VPS Hetzner o Contabo, contro i $20+ di Vercel Pro. Per un’agenzia che gestisce molti siti, la differenza si somma in fretta.
Opzione 3: Railway
Railway è un mezzo termine. Deploy dal repo, ma più economico di Vercel e con più controllo sulla region di deploy. Utile per siti client che devono girare su server EU.
SEO: Cosa Cambia con Headless
La SEO in headless richiede attenzione specifica. Next.js 15 risolve molti problemi che nei framework precedenti erano manuali:
Metadata dinamico
Next.js 15 ha il Metadata API. In ogni pagina, esporta un oggetto metadata o una funzione generateMetadata:
import { Metadata } from 'next';
import { getPostBySlug } from '@/lib/wordpress';
export async function generateMetadata({
params,
}: {
params: { slug: string };
}): Promise<Metadata> {
const post = await getPostBySlug(params.slug);
return {
title: post.title.rendered,
description: post.yoast_head_json?.description || post.excerpt.rendered.replace(/<[^>]*>/g, ''),
openGraph: {
title: post.title.rendered,
type: 'article',
images: post.featured_image_url ? [{ url: post.featured_image_url }] : [],
},
};
}
Sitemap
Next.js genera sitemap XML nativamente con app/sitemap.ts:
import { MetadataRoute } from 'next';
import { getAllPosts } from '@/lib/wordpress';
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const posts = await getAllPosts();
const postUrls = posts.map((post: any) => ({
url: `https://tuosito.com/blog/${post.slug}`,
lastModified: new Date(post.modified),
changeFrequency: 'weekly' as const,
priority: 0.8,
}));
return [
{ url: 'https://tuosito.com', lastModified: new Date(), changeFrequency: 'daily', priority: 1 },
{ url: 'https://tuosito.com/blog', lastModified: new Date(), changeFrequency: 'daily', priority: 0.9 },
...postUrls,
];
}
robots.txt
// app/robots.ts
import { MetadataRoute } from 'next';
export default function robots(): MetadataRoute.Robots {
return {
rules: { userAgent: '*', allow: '/', disallow: '/api/' },
sitemap: 'https://tuosito.com/sitemap.xml',
};
}
Per la gestione dei robots.txt e AI bot, headless ti dà controllo totale su quale bot può accedere a quale route.
GEO: Ottimizzazione per Motori AI
L’architettura headless ha un vantaggio non ovvio per la Generative Engine Optimization. I motori AI (ChatGPT, Perplexity, Gemini) prediligono contenuti strutturati, semanticamente chiari, e velocemente accessibili. Next.js 15 server-rendera HTML completo, che i crawler AI possono leggere senza eseguire JavaScript.
Punti chiave per GEO in headless:
- Contenuto sempre nel HTML server-renderato (zero client-side rendering per contenuti pubblici)
- Schema JSON-LD iniettato lato server, non via JavaScript client-side
- Tabelle e liste strutturate che i motori AI possono estrarre direttamente
- TTFB basso: i crawler AI hanno timeout aggressivi, server-side rendering su edge vince
llms.txtservito dalla root, con indicizzazione dei contenuti disponibili via API
Costi Reali per un’Agenzia
| Voce | Setup Traditional | Setup Headless |
|---|---|---|
| Hosting WordPress | 20-50€/mese | 10-20€/mese (solo backend) |
| Hosting Frontend | — | 0-20€/mese (Vercel free/Pro) |
| Sviluppo tema | 40-80 ore | 80-160 ore (Next.js + API) |
| Manutenzione mensile | 4-8 ore | 2-6 ore (meno plugin, meno conflitti) |
| Aggiornamenti core/plugin | Rischio alto (plugin tema) | Rischio basso (backend isolato) |
Il costo di sviluppo iniziale è circa il doppio. Ma la manutenzione mensile è più bassa perché il backend WordPress ha molti meno plugin (niente cache, niente SEO plugin, niente page builder, niente form plugin nel backend). I backup sono più semplici perché riguarda solo il database e gli upload, non il tema.
FAQ: WordPress Headless con Next.js
Quanto costa passare a headless per un sito client esistente?
Dipende dalla complessità del sito. Per un sito aziendale con 10-15 pagine e un blog, il rebuild richiede 80-120 ore di sviluppo. Per un e-commerce WooCommerce, raddoppia o triplica. Considera che il backend WordPress va pulito: rimozione plugin inutili, disattivazione del tema frontend, configurazione CORS e API.
Si possono usare i plugin WordPress nel frontend headless?
No. I plugin che renderizzano HTML nel frontend (form plugin, page builder, slider) non funzionano in headless. I plugin che estendono il data model (ACF, Custom Post Type UI, WPGraphQL) continuano a funzionare perché agiscono sul backend e sui dati esposti via API.
Headless è più sicuro di WordPress tradizionale?
Sì, in parte. La superficie di attacco del frontend è zero (zero PHP esposto al web). Il backend WordPress va protetto con le stesse pratiche di hardening, ma puoi limitare l’accesso pubblico alla cartella /wp-admin via IP allowlist o basic auth Nginx, dato che i visitatori non toccano mai il backend.
Come si gestiscono i form di contatto in headless?
I form vanno gestiti lato Next.js. Puoi usare un endpoint API Next.js che invia i dati al backend WordPress via REST API (creando un Custom Post Type “lead” o usando un plugin come Contact Form 7 con REST API), oppure usare servizi esterni come Formspree, Resend, o un’API custom. In AgencyPilot abbiamo integrato la gestione lead direttamente nel dashboard, bypassando WordPress per i form.
WooCommerce funziona in headless?
Parzialmente. WooCommerce espone una REST API per prodotti, ordini, e clienti. Ma il checkout (gestione carrello, calcolo spedizione, gateway di pagamento) è complesso da replicare fuori da PHP. Esistono soluzioni come WooCommerce Blocks e il Store API, ma per store con logiche di pricing complesse, headless è ancora doloroso. Per e-commerce semplici (catalogo + checkout PayPal/Stripe), si fa. Per shop complessi, resta tradizionale.
Next.js 15 richiede React 19?
Next.js 15 supporta React 19 come default. Le Server Components, il Metadata API, e il sistema di cache basato su tag funzionano meglio con React 19. Puoi usare React 18 con il flag --react-18, ma per nuovi progetti conviene partire con React 19.