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:

  • ServerSideRender recebe o mesmo block name do block.json e os attributes atuais. Ele faz uma requisição REST para /wp-json/block-editor/v1/block-preview e renderiza o HTML retornado pelo PHP.
  • useSelect com getEntityRecords busca as categorias via REST API para popular o SelectControl.
  • 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 o block.json compilado na pasta build.
  • render_callback recebe $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, bool e esc_* — nunca confie cegamente nos atributos vindos do editor.
  • WP_Query com no_found_rows, update_post_meta_cache e update_post_term_cache false melhora performance. Para listas simples, evite WP_Query completo e use get_posts se 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:

  1. Abra o console do navegador: erros de ServerSideRender aparecem lá.
  2. No PHP, use error_log(print_r($attributes, true)) dentro do callback para inspecionar o que chega.
  3. Verifique wp-content/debug.log com WP_DEBUG_LOG ativo.
  4. Teste a resposta REST em /wp-json/block-editor/v1/block-preview via Postman ou browser.

Boas práticas para levar adiante:

  • Cache: Se o bloco for pesado, considere transients no render_callback com invalidação no hook save_post.
  • Acessibilidade: Use aria-label nos links, time com datetime, 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

0 de 9 concluídos

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.