Cómo crear bloques personalizados en Gutenberg (con código)

Aprende a crear bloques personalizados en Gutenberg desde cero con create-block, block.json y React. Bloque estático vs dinámico, con código real.

Para crear bloques personalizados en Gutenberg, genera el scaffold con @wordpress/create-block, describe el bloque en block.json (apiVersion 3) y escribe edit y save en React/JSX. El resultado es HTML nativo y portable guardado en la base de datos — sin lock-in de plugin.

Un bloque personalizado de Gutenberg es un componente propio, registrado vía block.json, que añade un tipo de contenido al editor de WordPress y graba HTML estándar en la base de datos — sin depender de plugins de terceros para renderizar.

Todo tutorial de “bloques personalizados” que empieza con “instala este plugin de bloques” está resolviendo el problema equivocado. La forma en que lo hacen los profesionales — y lo que documenta el propio WordPress — es construir un bloque nativo: un paquete con block.json, edit.js y save.js, compilado por el toolchain oficial. Es más simple de lo que parece, no exige ser mago de React, y entrega algo que ningún page builder entrega: HTML tuyo, limpio, que sobrevive al cambio de tema, plugin o agencia. Esta guía construye uno desde cero, con código real que funciona.

Qué es un bloque nativo (y por qué no es un plugin de bloques)

Cuando creas un bloque nativo, WordPress guarda en la base de datos un HTML común, delimitado por un comentario de bloque:

<!-- wp:pixelize/card -->
<div class="wp-block-pixelize-card"><p>Tu contenido</p></div>
<!-- /wp:pixelize/card -->

Ese comentario es la clave. Es solo un marcador — el contenido entre las etiquetas es HTML estándar. Si mañana el bloque deja de existir, WordPress aún renderiza el HTML de dentro. Nada se rompe. Es lo opuesto a lo que ocurre con las alternativas populares:

  • ACF Blocks y Lazy Blocks te dejan armar bloques por interfaz, pero el HTML final lo genera una plantilla PHP del plugin en cada request. Desactiva el plugin y el contenido se evapora.
  • Elementor y otros page builders graban la estructura como un blob serializado o una montaña de <div> anidados con clases propias. El contenido queda rehén del producto: sin el plugin activo, ves shortcodes huérfanos o una sopa de divs sin estilo.

La diferencia técnica es el lock-in. Un bloque nativo graba HTML que es de WordPress, no del plugin. Por eso la arquitectura importa: un bloque nativo es DOM ligero, compatible con Full Site Editing (FSE) y theme.json, y portable entre proyectos. Esa es la prueba técnica de por qué recomendamos Gutenberg en vez de page builders — el argumento completo está en Elementor vs Gutenberg.

Vale la distinción: ACF Blocks resuelve casos legítimos (prototipos rápidos, equipos sin front-end). Pero cuando el objetivo es rendimiento, portabilidad y mantenimiento a largo plazo, el bloque nativo es el estándar de oro — y es lo que enseña esta guía.

”¿Necesito ser experto en React?”

No. Ese es el mayor mito que frena a los desarrolladores en la puerta de Gutenberg.

No necesitas saber Redux, hooks avanzados, Suspense, Server Components ni nada del ecosistema React moderno. Lo que necesitas es mucho menos:

  1. Entender JSX. JSX es HTML con superpoderes — <div className="card">{ texto }</div>. Si lees HTML, lees JSX. Las dos trampas son className en lugar de class y las llaves { } para insertar valores JavaScript.
  2. Pasar props. Tus componentes reciben attributes y setAttributes. Lees de uno, escribes con el otro. Eso es todo.
  3. Usar un puñado de componentes de WordPress. useBlockProps, RichText, InspectorControls, TextControl. Todos documentados, todos con la misma cara. Copias el patrón y lo adaptas.

El toolchain hace el trabajo aburrido: @wordpress/create-block genera el proyecto entero y @wordpress/scripts se encarga del build (Webpack, Babel, JSX → JavaScript). Nunca configuras nada de eso. Un desarrollador que domina HTML, CSS y JavaScript básico registra su primer bloque funcional en una tarde. Vamos a probarlo.

Requisitos previos y entorno

Necesitas Node.js y npm instalados. El toolchain de WordPress pide Node 20 o superior. Comprueba las versiones:

node -v
# v20.11.0  (20.x o superior)

npm -v
# 10.2.4

Si los comandos devuelven versiones válidas, estás listo. Si no, instala Node desde el sitio oficial o mediante un gestor de versiones como nvm. También necesitarás una instalación de WordPress en marcha (local, vía wp-env, Local, Docker o staging) para instalar y probar el plugin generado.

Paso a paso con @wordpress/create-block

De aquí en adelante es manos a la obra. Vamos a crear un bloque llamado pixelize/card — una tarjeta simple con título editable — y evolucionarlo hasta un bloque con controles y versión dinámica.

1. Scaffold del proyecto

@wordpress/create-block genera todo el proyecto con un comando. Ejecútalo dentro de la carpeta wp-content/plugins de tu instalación (o en cualquier lugar y luego mueve el build):

npx @wordpress/create-block pixelize-card --namespace pixelize
cd pixelize-card

El --namespace pixelize define el prefijo del bloque (pixelize/...), evitando colisiones con bloques de otros autores. El comando descarga las dependencias, crea la estructura y deja un plugin funcional listo para activar.

2. Estructura de archivos generada

Al terminar, tienes algo así:

pixelize-card/
├── build/                 # salida compilada (generada por el build; no editar)
├── src/
   ├── block.json         # manifiesto del bloque
   ├── index.js           # registra el bloque (registerBlockType)
   ├── edit.js            # componente del editor
   ├── save.js            # lo que se graba en la base de datos
   ├── style.scss         # estilos del front-end + editor
   ├── editor.scss        # estilos solo del editor
   └── view.js            # JS que corre en el front-end
├── pixelize-card.php      # plugin: register_block_type en init
├── package.json
└── readme.txt

Casi siempre editarás tres archivos: block.json, edit.js y save.js. El resto lo administra el toolchain.

3. block.json — el manifiesto del bloque

El block.json es el corazón del bloque en apiVersion 3: la fuente única de metadatos. Es lo que WordPress lee para registrar el bloque, encolar scripts y estilos bajo demanda y mantener compatibilidad con FSE. Abre src/block.json y ajusta:

{
	"$schema": "https://schemas.wp.org/trunk/block.json",
	"apiVersion": 3,
	"name": "pixelize/card",
	"version": "0.1.0",
	"title": "Pixelize Card",
	"category": "design",
	"icon": "id-alt",
	"description": "Una tarjeta personalizada de Pixelize.",
	"textdomain": "pixelize-card",
	"supports": {
		"html": false
	},
	"attributes": {
		"content": {
			"type": "string",
			"source": "html",
			"selector": "p"
		}
	},
	"editorScript": "file:./index.js",
	"editorStyle": "file:./index.css",
	"style": "file:./style-index.css",
	"viewScript": "file:./view.js",
	"render": "file:./render.php"
}

Los campos que importan:

  • apiVersion: 3 — la versión actual de la API de bloques, con mejor aislamiento del iframe del editor.
  • name — el identificador único namespace/slug. Es lo que aparece en el comentario <!-- wp:pixelize/card -->.
  • attributes — los datos que guarda el bloque. Aquí, content se lee del HTML de la etiqueta <p> grabada por save.
  • editorScript / style / viewScript — WordPress carga cada asset solo cuando el bloque está en uso. editorScript corre en el editor, style aplica a editor y front-end, viewScript corre solo en el front-end.
  • render — el PHP que genera el HTML en el front-end (usado en la versión dinámica; ver más abajo).

4. Registrar el bloque

En JavaScript, el src/index.js importa los metadatos del block.json y conecta edit y save:

import { registerBlockType } from '@wordpress/blocks';

import './style.scss';
import Edit from './edit';
import save from './save';
import metadata from './block.json';

registerBlockType( metadata.name, {
	edit: Edit,
	save,
} );

En PHP, el pixelize-card.php registra el bloque en el hook init, apuntando a la carpeta build (donde queda el block.json compilado):

<?php
/**
 * Plugin Name: Pixelize Card
 * Description: Bloque personalizado nativo de Pixelize.
 * Version:     0.1.0
 */

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

function pixelize_card_block_init() {
	register_block_type( __DIR__ . '/build/pixelize-card' );
}
add_action( 'init', 'pixelize_card_block_init' );

El register_block_type recibe la ruta de la carpeta, no un array de configuración — lee el block.json de allí y hace todo el trabajo de registro y encolado.

5. edit.js — la experiencia en el editor

El edit.js define cómo aparece y se comporta el bloque dentro del editor. Dos protagonistas: useBlockProps, que inyecta las clases y atributos que WordPress espera en el elemento raíz, y RichText, un campo de texto editable con formato:

import { __ } from '@wordpress/i18n';
import { useBlockProps, RichText } from '@wordpress/block-editor';

export default function Edit( { attributes, setAttributes } ) {
	const blockProps = useBlockProps();

	return (
		<RichText
			{ ...blockProps }
			tagName="p"
			value={ attributes.content }
			onChange={ ( content ) => setAttributes( { content } ) }
			placeholder={ __( 'Escribe el título de la tarjeta…', 'pixelize-card' ) }
		/>
	);
}

El value lee el atributo content; el onChange lo escribe de vuelta con setAttributes en cada tecla. Ese es el ciclo de datos de Gutenberg entero en cuatro líneas.

6. save.js — lo que se graba en la base de datos

El save.js define el HTML que va a la base de datos. Aquí usamos useBlockProps.save() (la versión para el HTML guardado) y RichText.Content, que serializa el texto con formato:

import { useBlockProps, RichText } from '@wordpress/block-editor';

export default function save( { attributes } ) {
	const blockProps = useBlockProps.save();

	return (
		<RichText.Content
			{ ...blockProps }
			tagName="p"
			value={ attributes.content }
		/>
	);
}

Es este HTML — <p class="wp-block-pixelize-card">…</p> — el que queda guardado entre los comentarios de bloque. Limpio, semántico, portable. Un detalle de la apiVersion 3: como el block.json declara render, WordPress usa el render.php para montar el front-end y pasa este HTML del save como $content. Para un bloque 100% estático, quita la línea render del block.json y el HTML de arriba se sirve directo, sin PHP. Volveremos a esto en la sección de bloques dinámicos.

7. Build y prueba

Con todo en su lugar, @wordpress/scripts compila. En desarrollo, usa el modo watch, que recompila en cada cambio:

npm start

Activa el plugin Pixelize Card en wp-admin, abre el editor de entradas e inserta el bloque “Pixelize Card”. Para generar la versión final optimizada (minificada) para producción:

npm run build

Listo — tu primer bloque nativo está funcionando. Ahora vamos a dejarlo profesional.

Añadiendo controles con InspectorControls

Todo bloque serio tiene opciones en la barra lateral: color, enlace, alineación. Eso es el InspectorControls, el panel de la derecha del editor. Lo rellenas con PanelBody (secciones colapsables) y controles como TextControl.

Primero, declara el nuevo atributo en el block.json, dentro de attributes:

"attributes": {
	"content": {
		"type": "string",
		"source": "html",
		"selector": "p"
	},
	"buttonUrl": {
		"type": "string",
		"default": ""
	}
}

Después, actualiza el edit.js para renderizar el panel y escribir en el atributo:

import { __ } from '@wordpress/i18n';
import {
	useBlockProps,
	RichText,
	InspectorControls,
} from '@wordpress/block-editor';
import { PanelBody, TextControl } from '@wordpress/components';

export default function Edit( { attributes, setAttributes } ) {
	const blockProps = useBlockProps();

	return (
		<>
			<InspectorControls>
				<PanelBody title={ __( 'Ajustes de la tarjeta', 'pixelize-card' ) }>
					<TextControl
						label={ __( 'Enlace del botón', 'pixelize-card' ) }
						value={ attributes.buttonUrl }
						onChange={ ( buttonUrl ) => setAttributes( { buttonUrl } ) }
					/>
				</PanelBody>
			</InspectorControls>

			<div { ...blockProps }>
				<RichText
					tagName="p"
					value={ attributes.content }
					onChange={ ( content ) => setAttributes( { content } ) }
					placeholder={ __( 'Escribe el título de la tarjeta…', 'pixelize-card' ) }
				/>
			</div>
		</>
	);
}

El patrón se repite para cualquier control: value lee el atributo, onChange llama a setAttributes. Cambia TextControl por ColorPalette, ToggleControl o SelectControl y tienes cualquier opción que imagines. El fragmento <>…</> envuelve el panel y el contenido porque el InspectorControls se “teletransporta” a la barra lateral — no aparece dentro del bloque.

Bloque estático vs dinámico

Esta es la decisión de arquitectura más importante al crear un bloque. La regla:

Usa estático cuando el HTML es fijo en el momento de escribirlo (tarjeta, destacado, CTA, cita): el save.js graba el HTML final en la base de datos y se sirve tal cual, sin PHP. Usa dinámico cuando el contenido cambia en cada request (últimas entradas, precio, contador, datos del usuario logueado): el save devuelve null y un render.php genera el HTML al vuelo.

Estático es más rápido (nada de PHP por request) y más robusto. Dinámico es indispensable cuando el dato no existe en el momento de guardar. El renderizado server-side de bloques vía render.php está soportado de forma nativa desde WordPress 6.1.

Convirtiendo a dinámico

Dos cambios. Primero, el save.js deja de grabar HTML y devuelve null — el contenido pasa a ser responsabilidad del PHP:

export default function save() {
	return null;
}

Segundo, asegúrate de que el block.json apunte al archivo de render (ya incluido en nuestro manifiesto):

{
	"render": "file:./render.php"
}

Ahora crea el src/render.php. Recibe tres variables listas: $attributes (los atributos del bloque), $content (el contenido interno, cuando lo hay) y $block (la instancia). Usa get_block_wrapper_attributes() para generar las clases y atributos que WordPress espera en el wrapper — el equivalente PHP de useBlockProps:

<?php
/**
 * @var array    $attributes Atributos del bloque.
 * @var string   $content    Contenido interno del bloque.
 * @var WP_Block $block      Instancia del bloque.
 */

$card_content = $attributes['content'] ?? '';
$button_url   = $attributes['buttonUrl'] ?? '';
?>
<div <?php echo get_block_wrapper_attributes(); ?>>
	<p><?php echo wp_kses_post( $card_content ); ?></p>

	<?php if ( $button_url ) : ?>
		<a class="wp-block-pixelize-card__button" href="<?php echo esc_url( $button_url ); ?>">
			<?php esc_html_e( 'Saber más', 'pixelize-card' ); ?>
		</a>
	<?php endif; ?>
</div>

Fíjate en el cuidado con la seguridad: wp_kses_post sanea el HTML del contenido, esc_url valida la URL, esc_html_e escapa y traduce el texto. Toda salida dinámica pasa por escape — regla de oro de WordPress. El get_block_wrapper_attributes() garantiza que el bloque siga compatible con los soportes del theme.json (color, espaciado, tipografía) sin que reescribas nada.

Por qué esto importa: HTML portable, cero lock-in y Core Web Vitals

Recapitulando lo que construiste y por qué le gana a un page builder en cada eje que importa:

  • HTML portable. El bloque graba markup estándar en la base de datos, dentro de un comentario <!-- wp:pixelize/card -->. ¿Cambiaste de tema? El HTML sigue ahí. ¿Desactivaste el plugin? El contenido estático permanece renderizable. No hay blob serializado ni shortcode huérfano. Eso es cero lock-in — lo opuesto a atar el sitio a un producto.
  • DOM ligero y Core Web Vitals. Controlas exactamente el HTML generado — sin las capas de <div> anidados que los page builders apilan. Menos DOM significa menos trabajo de layout y paint, lo que mejora directamente LCP y CLS. Suma a eso la carga condicional de assets (el block.json solo encola el CSS/JS cuando el bloque está en la página) y tienes una base rápida por diseño.
  • Compatible con FSE y theme.json. Como es nativo, el bloque participa del Full Site Editing y hereda los estilos globales del theme.json — colores, espaciados y tipografía definidos una vez, aplicados en todo.

Por eso los bloques nativos son la base técnica de sitios WordPress bien construidos — y la razón por la que Pixelize prefiere Gutenberg. Si tu proyecto necesita bloques a medida, rendimiento de verdad y un sitio que tú (y no un plugin) controlas, es exactamente el trabajo que hacemos en el servicio de desarrollo WordPress. Y si el objetivo es evitar los dolores del camino, vale la pena conocer también los errores comunes de WordPress.

Preguntas frecuentes

¿Necesito ser experto en React para crear un bloque en Gutenberg?

No. Necesitas entender JSX (que es casi HTML), pasar props y usar un puñado de hooks y componentes de WordPress — useBlockProps, RichText, setAttributes. @wordpress/create-block genera todo el scaffold y el build; tú solo editas edit.js y save.js. Un dev que conoce HTML, CSS y JavaScript básico registra su primer bloque en una tarde.

¿Cuál es la diferencia entre un bloque estático y uno dinámico?

Un bloque estático graba el HTML final en la base de datos mediante save.js — se sirve exactamente como se guardó, sin PHP en cada request. Un bloque dinámico tiene save devolviendo null y un render.php que genera el HTML en cada carga, usando $attributes y $content. Usa estático para contenido fijo (una tarjeta, un CTA) y dinámico para contenido que cambia (últimas entradas, precio, datos del usuario).

¿Un bloque nativo es lo mismo que ACF Blocks o Lazy Blocks?

No. ACF Blocks, Lazy Blocks y los bloques de Elementor dependen del plugin instalado para renderizar. Si desactivas el plugin, el contenido se rompe o queda como shortcode huérfano. Un bloque nativo registrado vía block.json graba HTML estándar de WordPress en la base de datos (con el comentario <!-- wp:pixelize/card -->) y no depende de ningún plugin de terceros para existir.

¿Qué es el block.json y por qué es obligatorio en apiVersion 3?

El block.json es el manifiesto del bloque: define nombre, título, categoría, icono, atributos y qué scripts y estilos cargar (editorScript, style, viewScript, render). A partir de la apiVersion 3 es la fuente única de metadatos — WordPress lee el archivo para registrar el bloque, encolar los assets bajo demanda y mantener compatibilidad con Full Site Editing y theme.json.

¿Necesito un plugin para registrar el bloque o puedo ponerlo en el tema?

Ambos funcionan. @wordpress/create-block genera un plugin con un register_block_type en init, que es la vía recomendada para bloques reutilizables y portables entre temas. También puedes registrarlo desde el functions.php del tema apuntando register_block_type a la carpeta build. El plugin aísla la lógica del diseño; el tema acopla el bloque al aspecto visual.

¿Por qué Pixelize recomienda bloques nativos en vez de Elementor?

Porque un bloque nativo genera HTML limpio y portable en la base de datos, DOM ligero, cero dependencia de plugin y compatibilidad total con Full Site Editing y theme.json. Eso se traduce en mejores Core Web Vitals, mantenimiento predecible y ningún lock-in — si el tema o la agencia cambian, el contenido sigue íntegro. Los page builders apilan divs y atan el sitio al producto.