Como criar blocos Gutenberg dinâmicos com React passo a passo
Aprenda a criar blocos Gutenberg dinâmicos com React, ServerSideRender e PHP. Guia prático para desenvolvedores WordPress com exemplos de código.
Você já tentou criar um bloco que lista os últimos posts de uma categoria específica e percebeu que o conteúdo fica desatualizado até alguém editar e salvar o post novamente? Esse é o problema clássico dos blocos estáticos: eles salvam o HTML no banco de dados no momento da edição, não no momento da exibição.
Blocos dinâmicos resolvem isso delegando a renderização para o servidor a cada requisição. O editor mostra uma prévia via ServerSideRender, mas o frontend executa PHP fresco — consultando o banco, aplicando lógica de negócio e retornando HTML atualizado. Neste guia, vamos construir do zero um bloco “Posts Recentes por Categoria” que demonstra o fluxo completo: React no editor, PHP no render, atributos compartilhados e build moderno.
O código final estará estruturado como plugin profissional, pronto para versionamento e deploy. Se você nunca configurou @wordpress/scripts, recomendo ler antes sobre gerenciamento de dependências Composer em plugins profissionais para entender a base do tooling moderno.
Estrutura do plugin e configuração inicial
Crie a pasta wp-content/plugins/dw-posts-recentes/ com a seguinte estrutura:
dw-posts-recentes/
├── dw-posts-recentes.php # Arquivo principal do plugin
├── block.json # Metadados do bloco (schema v2+)
├── package.json # Dependências e scripts de build
├── build/ # Gerado pelo @wordpress/scripts
│ ├── index.js # Compilado para o editor
│ ├── index.asset.php # Dependências e versão
│ └── style-index.css # Estilos do editor
└── src/
├── index.js # Entry point (registra bloco)
├── edit.js # Componente Edit (React)
├── save.js # Save retorna null (dinâmico)
└── style.scss # Estilos do editor
No package.json, o mínimo necessário:
{
"name": "dw-posts-recentes",
"version": "1.0.0",
"main": "build/index.js",
"scripts": {
"build": "wp-scripts build",
"start": "wp-scripts start",
"format": "wp-scripts format"
},
"devDependencies": {
"@wordpress/scripts": "^27.0.0"
}
}
Rode npm install e depois npm run build para gerar a pasta build/. O @wordpress/scripts já configura Webpack, Babel, ESLint e Stylelint com padrões WordPress.
Definindo o block.json com atributos dinâmicos
O block.json é a única fonte da verdade para metadados. Para blocos dinâmicos, dois campos são essenciais: "dynamic": true e o registro dos atributos que o PHP vai receber.
{
"$schema": "https://schemas.wp.org/trunk/block.json",
"apiVersion": 3,
"name": "dw-posts-recentes/posts-por-categoria",
"title": "Posts Recentes por Categoria",
"category": "widgets",
"icon": "format-aside",
"description": "Exibe lista de posts recentes filtrados por categoria.",
"supports": {
"html": false,
"dynamic": true
},
"attributes": {
"categoriaId": {
"type": "number",
"default": 0
},
"quantidade": {
"type": "number",
"default": 5,
"minimum": 1,
"maximum": 20
},
"exibirData": {
"type": "boolean",
"default": true
},
"exibirExcerto": {
"type": "boolean",
"default": false
}
},
"editorScript": "file:./build/index.js",
"editorStyle": "file:./build/style-index.css",
"style": "file:./build/style-index.css"
}
Observe "dynamic": true dentro de supports — isso avisa ao Gutenberg que o bloco não salva HTML no post_content. O save.js pode retornar null (veremos adiante). Os atributos categoriaId, quantidade, exibirData e exibirExcerto serão passados automaticamente ao render_callback no PHP.
Componente Edit com ServerSideRender e InspectorControls
O arquivo src/edit.js é onde a mágica do editor acontece. Usaremos ServerSideRender para mostrar a prévia real do PHP, e InspectorControls para os controles de configuração.
import { __ } from '@wordpress/i18n';
import {
useBlockProps,
InspectorControls,
ServerSideRender
} from '@wordpress/block-editor';
import { PanelBody, SelectControl, RangeControl, ToggleControl } from '@wordpress/components';
import { useSelect } from '@wordpress/data';
export default function Edit({ attributes, setAttributes }) {
const blockProps = useBlockProps();
// Busca categorias para o SelectControl
const categorias = useSelect((select) => {
return select('core').getEntityRecords('taxonomy', 'category', { per_page: -1 });
}, []);
const opcoesCategorias = [
{ value: 0, label: __('Todas as categorias', 'dw-posts-recentes') },
...(categorias?.map((cat) => ({ value: cat.id, label: cat.name })) || [])
];
return (
<div { ...blockProps }>
<ServerSideRender
block="dw-posts-recentes/posts-por-categoria"
attributes={attributes}
/>
<InspectorControls>
<PanelBody title={__('Configurações da Lista', 'dw-posts-recentes')} initialOpen={true}>
<SelectControl
label={__('Categoria', 'dw-posts-recentes')}
value={attributes.categoriaId}
options={opcoesCategorias}
onChange={(value) => setAttributes({ categoriaId: Number(value) })}
/>
<RangeControl
label={__('Quantidade de posts', 'dw-posts-recentes')}
value={attributes.quantidade}
onChange={(value) => setAttributes({ quantidade: value })}
min={1}
max={20}
/>
<ToggleControl
label={__('Exibir data', 'dw-posts-recentes')}
checked={attributes.exibirData}
onChange={(value) => setAttributes({ exibirData: value })}
/>
<ToggleControl
label={__('Exibir excerto', 'dw-posts-recentes')}
checked={attributes.exibirExcerto}
onChange={(value) => setAttributes({ exibirExcerto: value })}
/>
</PanelBody>
</InspectorControls>
</div>
);
}
Pontos-chave:
ServerSideRenderrecebe o mesmoblock namedoblock.jsone osattributesatuais. Ele faz uma requisição REST para/wp-json/block-editor/v1/block-previewe renderiza o HTML retornado pelo PHP.useSelectcomgetEntityRecordsbusca as categorias via REST API para popular oSelectControl.- Todos os controles atualizam atributos via
setAttributes, mantendo o estado React sincronizado.
Save mínimo e entry point
Como o bloco é dinâmico, src/save.js retorna apenas null — nada é salvo no post_content.
import { __ } from '@wordpress/i18n';
export default function save() {
return null;
}
O src/index.js registra o bloco usando o block.json:
import { registerBlockType } from '@wordpress/blocks';
import metadata from '../block.json';
import Edit from './edit';
import save from './save';
import './style.scss';
registerBlockType(metadata.name, {
...metadata,
edit: Edit,
save: save,
});
Registro no PHP e render_callback
No arquivo principal dw-posts-recentes.php, registramos o bloco apontando para a pasta build/ (onde está o block.json processado) e definimos o render_callback.
<?php
/**
* Plugin Name: DW Posts Recentes por Categoria
* Description: Bloco Gutenberg dinâmico para listar posts recentes filtrados por categoria.
* Version: 1.0.0
* Author: DevWordPress
* Text Domain: dw-posts-recentes
*/
if (!defined('ABSPATH')) {
exit;
}
function dw_posts_recentes_register_block() {
register_block_type(__DIR__ . '/build', [
'render_callback' => 'dw_posts_recentes_render_callback',
]);
}
add_action('init', 'dw_posts_recentes_register_block');
function dw_posts_recentes_render_callback($attributes, $content, $block) {
// Sanitização defensiva dos atributos
$categoria_id = isset($attributes['categoriaId']) ? absint($attributes['categoriaId']) : 0;
$quantidade = isset($attributes['quantidade']) ? absint($attributes['quantidade']) : 5;
$quantidade = max(1, min(20, $quantidade));
$exibir_data = isset($attributes['exibirData']) ? (bool)$attributes['exibirData'] : true;
$exibir_excerto = isset($attributes['exibirExcerto']) ? (bool)$attributes['exibirExcerto'] : false;
// Query otimizada
$args = [
'post_type' => 'post',
'posts_per_page' => $quantidade,
'post_status' => 'publish',
'no_found_rows' => true,
'update_post_meta_cache' => false,
'update_post_term_cache' => false,
];
if ($categoria_id > 0) {
$args['cat'] = $categoria_id;
}
$query = new WP_Query($args);
if (!$query->have_posts()) {
return '<p class="dw-posts-recentes--vazio">' . esc_html__('Nenhum post encontrado.', 'dw-posts-recentes') . '</p>';
}
$html = '<ul class="dw-posts-recentes__lista">';
while ($query->have_posts()) {
$query->the_post();
$permalink = get_permalink();
$title = get_the_title() ? get_the_title() : esc_html__('(Sem título)', 'dw-posts-recentes');
$html .= '<li class="dw-posts-recentes__item">';
$html .= '<a class="dw-posts-recentes__link" href="' . esc_url($permalink) . '">';
$html .= '<span class="dw-posts-recentes__titulo">' . esc_html($title) . '</span>';
if ($exibir_data) {
$html .= '<time class="dw-posts-recentes__data" datetime="' . esc_attr(get_the_date('c')) . '">';
$html .= esc_html(get_the_date());
$html .= '</time>';
}
if ($exibir_excerto) {
$excerpt = has_excerpt() ? get_the_excerpt() : wp_trim_words(get_the_content(), 30);
$html .= '<p class="dw-posts-recentes__excerto">' . esc_html($excerpt) . '</p>';
}
$html .= '</a>';
$html .= '</li>';
}
wp_reset_postdata();
$html .= '</ul>';
return $html;
}
Detalhes importantes:
register_block_type(__DIR__ . '/build')lê automaticamente oblock.jsoncompilado na pasta build.render_callbackrecebe$attributes(array com os valores do block.json),$content(string vazia para blocos dinâmicos) e$block(instância WP_Block).- Sanitizamos tudo com
absint,booleesc_*— nunca confie cegamente nos atributos vindos do editor. WP_Querycomno_found_rows,update_post_meta_cacheeupdate_post_term_cachefalse melhora performance. Para listas simples, eviteWP_Querycompleto e useget_postsse não precisar de paginação.wp_reset_postdata()é obrigatório após loop customizado.
Estilos no editor e frontend
O src/style.scss serve tanto para editor quanto frontend (definido em editorStyle e style no block.json). Use classes prefixadas para evitar conflitos.
.dw-posts-recentes__lista {
list-style: none;
padding: 0;
margin: 0;
&__item {
padding: 12px 0;
border-bottom: 1px solid var(--wp--preset--color--gray-200);
&:last-child {
border-bottom: none;
}
}
&__link {
text-decoration: none;
color: inherit;
display: block;
}
&__titulo {
font-weight: 600;
font-size: 1rem;
display: block;
margin-bottom: 4px;
}
&__data {
font-size: 0.8rem;
color: var(--wp--preset--color--gray-600);
}
&__excerto {
margin: 8px 0 0;
font-size: 0.9rem;
line-height: 1.5;
color: var(--wp--preset--color--gray-700);
}
&--vazio {
padding: 16px;
text-align: center;
color: var(--wp--preset--color--gray-600);
font-style: italic;
}
}
Variáveis CSS como var(--wp--preset--color--gray-600) garantem integração com o tema ativo e Performance e Core Web Vitals.
Testando, depurando e boas práticas
Após npm run build, ative o plugin e adicione o bloco no editor. Você verá a prévia renderizada pelo PHP via ServerSideRender. Altere a categoria, quantidade, datas — a prévia atualiza em tempo real.
Para depurar:
- Abra o console do navegador: erros de ServerSideRender aparecem lá.
- No PHP, use
error_log(print_r($attributes, true))dentro do callback para inspecionar o que chega. - Verifique
wp-content/debug.logcomWP_DEBUG_LOGativo. - Teste a resposta REST em
/wp-json/block-editor/v1/block-previewvia Postman ou browser.
Boas práticas para levar adiante:
- Cache: Se o bloco for pesado, considere transients no
render_callbackcom invalidação no hooksave_post. - Acessibilidade: Use
aria-labelnos links,timecomdatetime, contraste adequado. - Internacionalização: Todas as strings passam por
__()/esc_html__()no PHP e__()no React. - Segurança: Nonce não é necessário no render_callback (não há formulário), mas sanitização de saída (
esc_url,esc_html,esc_attr) é obrigatória. - Desempenho: Evite queries complexas no callback. Para dados muito dinâmicos, considere REST API + renderização client-side (React puro) — mas isso perde SEO do conteúdo renderizado no servidor.
Conclusão prática
Blocos dinâmicos separam responsabilidades: React cuida da experiência de edição, PHP cuida da lógica de dados e renderização final. O block.json conecta os dois mundos via atributos tipados, e o ServerSideRender dá ao editor uma prévia fiel sem duplicar lógica. Com essa base, você pode criar blocos para catálogos de produtos, dashboards de métricas, formulários complexos ou qualquer componente que precise de dados frescos a cada pageview. Para aprofundar em autenticação de endpoints customizados que alimentam blocos, veja autenticação via Application Passwords na REST API.
Checklist prático
Perguntas frequentes
Qual a diferença entre blocos estáticos e dinâmicos no Gutenberg?
Blocos estáticos salvam o HTML final no post_content, enquanto blocos dinâmicos renderizam o conteúdo no servidor a cada carregamento via PHP. Isso permite que blocos dinâmicos exibam dados em tempo real, como lista de posts recentes ou informações do usuário logado, sem precisar re-salvar o post.
Quando devo usar ServerSideRender em vez de renderização pura no PHP?
Use ServerSideRender quando precisar de uma prévia fiel do bloco no editor, mas a lógica de exibição depende de dados do servidor. Para blocos puramente dinâmicos sem necessidade de preview complexo, o render_callback no PHP é mais leve e direto.
Como passar atributos do React para o callback de renderização no PHP?
Os atributos definidos no block.json ficam disponíveis automaticamente no array $attributes do render_callback. Basta registrá-los corretamente no schema do bloco e acessá-los via $attributes['seu_atributo'] na função PHP.
É possível usar hooks do WordPress dentro do render_callback do bloco dinâmico?
Sim, o render_callback executa no contexto completo do WordPress, permitindo uso de get_posts, wp_query, get_option e qualquer hook ou função nativa. Apenas evite saída direta (echo) — retorne o HTML como string.
Como depurar erros no render_callback se o bloco quebrar no frontend?
Ative WP_DEBUG_LOG e use error_log() dentro do callback para gravar variáveis no debug.log. Também inspecione a resposta da REST API em /wp-json/wp/v2/blocks ou use o console do navegador para ver erros de ServerSideRender.