Atualizado em 19/06/2026
No editor de blocos (Gutenberg), existem dois jeitos fundamentalmente diferentes de um bloco produzir o seu HTML. Confundir os dois é a origem de quase todo bug de "meu bloco não atualiza" ou "o conteúdo sumiu depois que mudei o código". Neste artigo eu separo de uma vez o bloco estático do dinâmico, mostro a abordagem moderna baseada em block.json e foco no que interessa para quem vem do PHP: gerar o markup no servidor, com segurança, via render_callback.
Estático × dinâmico: onde o HTML nasce
A diferença está em quem gera o markup e quando:
- Bloco estático: a função
save(em JavaScript) retorna o HTML, que é gravado dentro do conteúdo do post no momento em que você clica em "Publicar/Atualizar". Esse HTML fica salvo no banco; o PHP não participa da renderização no front-end. Ideal para conteúdo que não muda: um aviso, um botão, uma citação. - Bloco dinâmico: o
saveretornanull(nada é gravado além dos atributos). O HTML é montado pelo PHP a cada exibição, através de umrender_callback. Ideal para tudo que depende do estado atual do site: últimos posts, preço de um produto, contagem de comentários, saudação ao usuário logado.
| Bloco estático | Bloco dinâmico | |
|---|---|---|
| Quem gera o HTML | save (JavaScript) | render_callback (PHP) |
| Quando é gerado | Ao salvar o post | A cada requisição da página |
| O que fica no banco | O HTML completo | Só os atributos (save = null) |
| Reflete dados que mudam? | Não (fica congelado) | Sim, sempre atualizado |
| Quando usar | Conteúdo fixo | Listas, preços, dados dinâmicos |
Por que o estático "quebra" ao mudar o código: como o HTML do bloco estático fica salvo no post, se você alterar a função save o markup gravado deixa de bater com o que o JavaScript agora espera, e o editor mostra o aviso de "este bloco contém conteúdo inesperado". Blocos dinâmicos não sofrem disso — eles não gravam HTML.
A abordagem moderna: tudo gira em torno do block.json
Desde o WordPress 5.8, o registro de blocos é centralizado em um arquivo de metadados, o block.json. Ele descreve o bloco (nome com namespace, título, categoria, atributos) e aponta para os assets — e é a partir dele que o WordPress faz o enfileiramento automático de scripts e estilos. O name precisa ter namespace (vendor/bloco) para não colidir com outros plugins.
{
"$schema": "https://schemas.wp.org/trunk/block.json",
"apiVersion": 3,
"name": "meu-plugin/ultimos-posts",
"version": "1.0.0",
"title": "Últimos posts",
"category": "widgets",
"icon": "list-view",
"description": "Lista os posts mais recentes, renderizada no PHP.",
"attributes": {
"quantidade": { "type": "number", "default": 5 },
"mostrarData": { "type": "boolean", "default": true }
},
"supports": {
"html": false
},
"textdomain": "meu-plugin",
"editorScript": "file:./index.js",
"style": "file:./style-index.css"
}
O editorScript aponta para o JavaScript que registra o bloco no editor (gerado pelo build). Para renderizar no front-end há duas opções: o campo render (a partir do WordPress 6.1) no block.json, apontando um arquivo PHP, ou o render_callback em register_block_type(). Use uma ou outra; neste exemplo usamos o render_callback, por isso o block.json acima não traz o campo render.
Registrando o bloco no PHP, no hook init
Do lado do PHP, o registro é minimalista: aponte register_block_type() para a pasta que contém o block.json. O WordPress lê os metadados e cuida do resto. Se preferir definir o render em PHP (em vez do campo render do JSON), passe o render_callback aqui.
<?php
add_action( 'init', 'meu_plugin_registrar_blocos' );
function meu_plugin_registrar_blocos() {
// __DIR__ deve apontar para a pasta com o block.json gerado no build (ex.: build/).
register_block_type(
__DIR__ . '/build/ultimos-posts',
array(
'render_callback' => 'meu_plugin_render_ultimos_posts',
)
);
}
Se você declarou "render": "file:./render.php" no block.json, pode chamar apenas register_block_type( __DIR__ . '/build/ultimos-posts' ), sem o segundo argumento — o WordPress usa o arquivo de render automaticamente. O render_callback explícito é útil quando a lógica fica em uma classe ou método.
O render_callback em PHP, com saída escapada
A assinatura é render_callback( $attributes, $content, $block ). Ele recebe os atributos do bloco (o array vindo do block.json), o conteúdo interno (relevante em blocos com InnerBlocks) e o objeto WP_Block. Ele deve retornar (não imprimir) o HTML — e todo dado deve ser escapado na saída. Use get_block_wrapper_attributes() para que o bloco respeite as classes e estilos definidos pelo usuário no editor (alinhamento, cor, etc.).
<?php
function meu_plugin_render_ultimos_posts( $attributes, $content, $block ) {
$quantidade = isset( $attributes['quantidade'] ) ? absint( $attributes['quantidade'] ) : 5;
$mostrar_data = ! empty( $attributes['mostrarData'] );
$query = new WP_Query( array(
'post_type' => 'post',
'posts_per_page' => $quantidade,
'no_found_rows' => true,
'ignore_sticky_posts' => true,
) );
if ( ! $query->have_posts() ) {
return '';
}
// Atributos do wrapper: classes/estilos que o editor aplicou ao bloco.
$wrapper = get_block_wrapper_attributes();
$itens = '';
while ( $query->have_posts() ) {
$query->the_post();
$data = $mostrar_data
? ' <time>' . esc_html( get_the_date() ) . '</time>'
: '';
$itens .= sprintf(
'<li><a href="%1$s">%2$s</a>%3$s</li>',
esc_url( get_permalink() ),
esc_html( get_the_title() ),
$data
);
}
wp_reset_postdata();
// %1$s já vem escapado por get_block_wrapper_attributes().
return sprintf( '<ul %1$s>%2$s</ul>', $wrapper, $itens );
}
Escape sempre na saída. Mesmo dados vindos do seu próprio banco passam por esc_html(), esc_url() e esc_attr(). Concatenar título de post ou URL sem escapar é uma porta aberta para XSS. Note ainda que o callback deve retornar a string — fazer echo dentro dele desordena a saída da página. Os princípios estão no artigo de segurança no código.
O lado do editor: edit em React, save retornando null
No JavaScript, o edit desenha a interface do bloco no editor usando useBlockProps() (que aplica as classes e atributos corretos ao elemento raiz). Para um bloco dinâmico, o save simplesmente devolve null — sinalizando que o HTML virá do PHP. Este é o trecho que o wp-scripts transpila do JSX:
// index.js — registra o bloco no editor (transpilado por wp-scripts)
import { registerBlockType } from '@wordpress/blocks';
import { useBlockProps } from '@wordpress/block-editor';
import metadata from './block.json';
registerBlockType( metadata.name, {
edit: ( { attributes } ) => {
const blockProps = useBlockProps();
return (
<div { ...blockProps }>
Pré-visualização: últimos { attributes.quantidade } posts.
</div>
);
},
// Bloco dinâmico: nada é gravado no conteúdo; o PHP renderiza.
save: () => null,
} );
Em produção, o edit normalmente traz controles (um RangeControl para a quantidade, um ToggleControl para a data) no painel lateral (InspectorControls) e até uma prévia real via ServerSideRender, que chama o seu PHP dentro do editor. O ponto que não muda: save retorna null.
Scaffolding e build com @wordpress/scripts
Você não escreve esse esqueleto à mão. O pacote oficial @wordpress/create-block gera a estrutura completa (com block.json, index.js, render.php e package.json já configurados), e o @wordpress/scripts faz o build. A flag --variant=dynamic já cria o esqueleto de um bloco dinâmico:
# Cria o plugin/bloco já no formato dinâmico (gera render.php)
npx @wordpress/create-block meu-bloco-dinamico --variant=dynamic
cd meu-bloco-dinamico
# Build de produção: transpila o JSX e gera a pasta build/
npm run build
# Durante o desenvolvimento: recompila a cada mudança
npm run start
A etapa de build não é opcional. O navegador não executa JSX nem entende import de @wordpress/* diretamente. Sem rodar npm run build (que por baixo chama wp-scripts build), a pasta build/ não existe, o editorScript aponta para um arquivo inexistente e o bloco simplesmente não aparece no inserter. Em servidores de produção, faça o build antes do deploy e versione a pasta build/ — ou rode o build no pipeline.
Armadilhas comuns
- Esquecer que precisa de build. O sintoma é o bloco não aparecer no editor; quase sempre é a pasta
build/ausente ou o caminho doregister_block_type()apontando para a pasta errada. - Bloco dinâmico com save retornando HTML. Se o
savenão retornanull, o HTML é gravado no post e orender_callbackdeixa de ser a única fonte da verdade — você acaba com markup duplicado ou desatualizado. - Omitir o apiVersion. Sem
"apiVersion": 3, o bloco cai no comportamento antigo (versão 1) e não funciona bem no editor com iframe. - Imprimir em vez de retornar. O
render_callbackdevereturna string. Umechosolto aparece no lugar errado da página. - Não escapar a saída. Todo dado dinâmico passa por
esc_html()/esc_url()/esc_attr(). - Registrar fora do init. O registro precisa rodar no hook
init; veja actions e filters. - name sem namespace. O
namenoblock.jsondeve servendor/bloco, todo em minúsculas, para evitar colisões.
Boas práticas
- Deixe o block.json mandar. Centralize metadados e assets nele; assim o enfileiramento de scripts/estilos é automático e o bloco fica disponível também na REST API.
- Use get_block_wrapper_attributes(). É o que faz o bloco respeitar alinhamento, cor e classes que o usuário definiu — sem isso, os supports não têm efeito no front-end.
- Carregue script de front só quando preciso. Se o bloco tem JS para o visitante, use
viewScriptnoblock.json: o WordPress só o enfileira nas páginas onde o bloco aparece. Detalhes em enfileirar scripts e estilos. - Prefira o campo render do JSON para casos simples; reserve o
render_callbackem PHP para quando a lógica mora em uma classe. - Empacote como plugin. Blocos costumam viver em um plugin próprio, não no tema — assim sobrevivem à troca de tema.
Blocos dinâmicos são a ponte natural entre o editor moderno e o seu conhecimento de PHP: o React cuida da edição, mas a renderização — onde entram WP_Query, transients e a sua regra de negócio — continua no servidor, exatamente onde você já sabe trabalhar.
Perguntas frequentes
Qual a diferença entre bloco estático e dinâmico?
Um bloco estático grava o HTML final dentro do conteúdo do post no momento em que você salva — quem gera o markup é a função save em JavaScript. Um bloco dinâmico não grava HTML: o seu save retorna null e o markup é montado pelo PHP a cada requisição, via render_callback. Use dinâmico sempre que a saída depender de dados que mudam (últimos posts, preço, estoque, data, dados do usuário logado).
Por que o save de um bloco dinâmico retorna null?
Porque, se o save gravasse HTML, esse HTML ficaria "congelado" no conteúdo do post e não refletiria mudanças nos dados. Retornando null, nada é persistido além dos atributos do bloco (em um comentário HTML), e o WordPress chama o seu render_callback em PHP toda vez que a página é exibida — garantindo conteúdo sempre atualizado.
Preciso de Node e npm para criar um bloco?
Para a abordagem moderna com React/JSX, sim: você precisa de Node/npm para rodar @wordpress/scripts (o comando wp-scripts build), que transpila o JSX e gera os assets que o navegador entende. O npx @wordpress/create-block cria todo o esqueleto. O navegador não executa JSX diretamente; sem a etapa de build o editor não carrega o bloco.
O que é o apiVersion no block.json?
É a versão da API de marcação/comportamento do bloco no editor. A versão atual recomendada é a 3, que adota o uso de useBlockProps() e prepara o bloco para o editor iframe. Omitir apiVersion faz o bloco cair na versão 1, com comportamento antigo. Em blocos novos, declare sempre "apiVersion": 3.
Como o WordPress sabe quais scripts e estilos enfileirar?
Pelo próprio block.json. Ao registrar o bloco com register_block_type( __DIR__ ) apontando para a pasta com o block.json, o WordPress lê campos como editorScript, script, viewScript e style e cuida do enfileiramento automaticamente — inclusive carregando o viewScript só nas páginas onde o bloco aparece. Veja enfileirar scripts e estilos.
Onde devo registrar o bloco?
No hook init. O registro depende de APIs (tipos de post, REST) que só estão prontas a partir desse momento, e o editor de blocos espera que os tipos estejam disponíveis cedo. Registrar fora do init — direto no carregamento do arquivo, por exemplo — causa avisos e blocos que não aparecem no inserter.