Voltar ao blog
wordpressheadlessnuxtphpacfcorsarquiteturamu-plugins

WordPress Headless: mu-plugins ou tema filho? onde ficam as customizações

19 de julho de 2026 às 10:15 - Por Larissa Santos

WordPress Headless: mu-plugins ou tema filho? onde ficam as customizações

No primeiro artigo sobre esse projeto contei a decisão de arquitetura: o site da Teciklar virou headless, com um Nuxt 4 na Vercel na frente e o WordPress atrás, servindo só conteúdo pela REST API. Ninguém nunca vê uma página renderizada pelo WordPress.

Aquele texto terminou na estrutura montada e funcionando. Este conta o que veio depois, quando fui fazer manutenção e descobri que a fundação do site estava guardada no lugar errado. E que, na primeira vez que fui corrigir isso, eu tirei o código de um lugar errado e coloquei em outro.

São três situações, na ordem em que apareceram. A segunda só foi descoberta porque fui resolver a primeira, e a terceira é uma correção da resposta que dei à segunda.


O painel exigia digitar /wp-admin

O CMS tinha acabado de ser movido para um subdomínio dedicado. Mesmo assim, era preciso digitar /wp-admin no fim da URL para chegar ao painel. A raiz do subdomínio servia o tema público do WordPress, uma página sem uso nenhum aqui: o site de verdade é o Nuxt, em outro domínio.

A primeira ideia que aparece é mover o painel para a raiz. Isso não existe. O /wp-admin é um diretório físico do core, com dezenas de arquivos (edit.php, post.php, options-general.php e por aí vai). Mover aquilo não é suportado, quebra a atualização do core e é fonte clássica de site quebrado.

O que dá para fazer é redirecionar. Quem acessa a raiz cai no painel, a URL final continua sendo /wp-admin, e ninguém precisa digitar. Como o WordPress ali não tem front-end útil, fui além da home: qualquer URL pública do WordPress leva ao painel.

php
add_action('template_redirect', function () {
    if (is_admin() || wp_doing_ajax() || wp_doing_cron() || (defined('REST_REQUEST') && REST_REQUEST)) {
        return;
    }

    if (is_robots() || is_feed()) {
        return;
    }

    wp_safe_redirect(admin_url(), 302);
    exit;
});

O template_redirect dispara depois que o WordPress já decidiu qual conteúdo servir, mas antes de renderizar, então é o ponto certo para interceptar.

Os guards que fazem esse trabalho vêm do próprio WordPress: is_admin(), wp_doing_ajax(), wp_doing_cron(), is_robots() e is_feed() são funções do core, e REST_REQUEST é uma constante definida quando a requisição entra pela REST API. O trabalho foi decidir quais entram na lista. Cada uma protege alguma coisa que o redirect quebraria, e a de REST_REQUEST protege o site inteiro, porque a API que alimenta o Nuxt passa pelo mesmo hook:

GuardO que protege
is_admin()o próprio painel
wp_doing_ajax()o admin-ajax.php, usado pelo editor
wp_doing_cron()tarefas agendadas
REST_REQUEST/wp-json, ou seja, todo o conteúdo do site
is_robots()o robots.txt do subdomínio
is_feed()feeds RSS

O wp-login.php não passa por template_redirect, então o login continua acessível sem precisar de exceção.

Um efeito colateral bom: nenhuma URL pública do WordPress fica indexável, o que evita conteúdo duplicado competindo com o site real no Google.


As customizações estavam em terreno que sobrescreve

Fui abrir o functions.php para adicionar esse redirect e encontrei uma coisa bem pior que o problema original.

O site usa um tema comercial pago. E todas as customizações de que o front dependia estavam escritas dentro do tema:

  • um resolver que transforma IDs de mídia do ACF em URL na resposta da API;
  • o registro do campo blog_destaque, que marca quais posts aparecem na home;
  • os headers de CORS que permitem o Nuxt consumir a API de outro domínio.

O WordPress sobrescreve o diretório inteiro do tema ao atualizar. No dia em que alguém clicasse em "atualizar" no tema, os três sumiriam de uma vez.

O que torna isso especialmente ruim num site headless é o sintoma. Não aparece erro. O front simplesmente para de receber imagem e dado, e o repositório do front não mudou nada. É uma quebra que parece vir do nada e custa horas para diagnosticar.

A resposta que fez sentido na hora: tema filho

Tema filho é o mecanismo oficial do WordPress para isso: uma pasta separada que herda tudo do tema pai e nunca é tocada quando o pai atualiza.

Adianto que essa resposta estava incompleta, e a última seção conta por quê. Ela resolve o risco descrito aqui, e só ele. Leia o resto desta seção assim: o diagnóstico continua válido, o destino do código mudou depois.

Um tema filho precisa só de um style.css com um cabeçalho declarando de quem ele herda:

css
/*
Theme Name: Site
Description: Child theme holding the headless CMS customizations.
Template: nome-do-tema-pai
Version: 1.0.0
*/

O Template é a linha que faz o vínculo, e o valor precisa ser exatamente o nome da pasta do tema pai. Se estiver errado, o WordPress recusa o tema.

Uma ordem de instalação que derrubaria o site

O functions.php do tema filho carrega antes do pai, e os dois são carregados. Então, se você copiar as funções que já existem para o filho e ativar o filho sem antes limpar o pai, o PHP encontra a mesma função declarada duas vezes e morre com "Cannot redeclare". O resultado é tela branca no painel e API fora do ar, os dois ao mesmo tempo.

A sequência correta é:

  1. subir a pasta do tema filho;
  2. antes de ativar, remover as customizações do functions.php do tema pai;
  3. só então ativar o tema filho.

O que esse código faz

São três peças, além do redirect da seção anterior.

A primeira é o resolver de mídia do ACF. Dependendo de como o campo foi configurado, o ACF devolve imagem como ID numérico em vez de URL, e o front não tem o que fazer com um número. O filtro varre os campos da resposta e troca número por URL:

php
function cms_resolve_acf_media($value){
    if (is_array($value)) {
        foreach ($value as $key => $item) {
            $value[$key] = cms_resolve_acf_media($item);
        }
        return $value;
    }

    if (is_numeric($value)) {
        $attachment_url = wp_get_attachment_url((int) $value);
        return $attachment_url ? $attachment_url : $value;
    }

    return $value;
}

A recursão existe porque campos do ACF aninham: grupo dentro de grupo, lista dentro de grupo. Repare no efeito colateral dessa abordagem, que aparece mais tarde em outro assunto: ela troca qualquer valor numérico. Se um campo devolvesse um objeto com id, width e height, esses números virariam URL de anexo também.

A segunda é o campo blog_destaque, um booleano que marca quais posts aparecem na home. Ele só existe na API porque foi registrado com show_in_rest:

php
register_post_meta('post', 'blog_destaque', [
    'show_in_rest' => true,
    'single' => true,
    'type' => 'boolean',
]);

A terceira é o CORS. O Nuxt roda em outro domínio, então o navegador bloqueia a chamada sem os headers certos:

php
function cms_allowed_origins()
{
    return [
        'https://www.exemplo.com.br',
        'https://exemplo.com.br',
        'http://localhost:3000',
    ];
}

add_action('rest_api_init', function () {
    remove_filter('rest_pre_serve_request', 'rest_send_cors_headers');

    add_filter('rest_pre_serve_request', function ($value) {
        header('Vary: Origin', false);

        $origin = get_http_origin();
        $allowed = cms_allowed_origins();
        $match = array_search($origin, $allowed, true);

        if ($match === false) {
            return $value;
        }

        header('Access-Control-Allow-Origin: ' . $allowed[$match]);
        header('Access-Control-Allow-Methods: GET, OPTIONS');
        header('Access-Control-Allow-Headers: Content-Type');
        header('Access-Control-Max-Age: 600');

        return $value;
    });
}, 15);

O remove_filter na primeira linha tira o handler padrão do WordPress antes de instalar o próprio, para os dois não brigarem pelo mesmo header. O Vary: Origin é o par obrigatório da allowlist: sem ele, um cache no caminho pode guardar a resposta com o header de uma origem e entregar para outra.

A primeira versão desse bloco liberava Access-Control-Allow-Origin: * e anunciava Authorization nos headers aceitos. Antes de restringir, confirmei por busca no repositório do front que nada ali escreve nem autentica, em vez de confiar na memória:

bash
grep -rn "method\s*:\|body\s*:" app/composables/ server/
grep -rn "<form\|<UForm\|<input\|<textarea\|@submit" app/

As duas voltaram vazias, então POST e Authorization saíram.

Uma coisa que essa restrição não faz é tornar o conteúdo privado. Quem abrir a URL da API direto continua vendo o JSON, e deve continuar, porque é um site institucional. O que muda é quem consegue ler a resposta de dentro de outra página, no navegador.

Se a lista de origens estiver errada, o modo de falha engana: o SSR continua funcionando, porque servidor para servidor não passa por CORS. A página abre no primeiro carregamento e quebra só na navegação client-side.


Tema filho protege de um risco, e existem três

Tempo depois, revisando o que tinha ficado, fiz a pergunta que não tinha feito antes: por que o CORS está num tema?

A situação anterior me levou a tirar as customizações do tema pai, porque atualizar o tema apagaria tudo. Isso estava certo. Só que eu tratei "sair do tema pai" como se fosse o critério, quando era apenas um dos riscos. Tema filho resolve a atualização do pai e deixa dois de pé:

  • trocar de tema. Um tema novo, ou o WordPress caindo no tema padrão depois de um erro fatal, e a API do site inteiro vai junto;
  • camada errada. CORS é header HTTP, register_post_meta é modelo de dados, meta box é interface de painel. Nada disso é apresentação. Tema é a camada que renderiza páginas, e este site não renderiza nenhuma: o redirect da primeira seção manda toda URL pública para o painel.

Olhando o functions.php do tema filho, sobrava quase nada de tema. Era um plugin morando dentro de um arquivo de tema.

Para onde o código foi

wp-content/mu-plugins/ é a pasta de must-use plugins. Um arquivo ali é carregado antes dos plugins normais, não aparece com botão de desativar no painel, e não tem relação nenhuma com o tema ativo.

O detalhe irônico é que a pasta já existia no site, com o headless-config.php que mostrei no primeiro artigo, aquele que desliga o editor de blocos e os comentários. O lugar certo estava criado e em uso. O código foi para o tema filho por inércia: eu copiei o bloco do functions.php antigo inteiro, em vez de perguntar de cada peça a que camada ela pertencia.

A organização ficou assim:

txt
wp-content/mu-plugins/
├── headless-config.php     # desliga editor, comments e block editor
├── cms-core.php       # loader
└── cms/
    ├── rest-api.php        # CORS, resolver de mídia do ACF, blog_destaque
    ├── media-field.php     # campo de mídia múltipla
    ├── redirect.php        # a raiz vai para /wp-admin
    └── assets/

O tema filho continua ativo, porque o WordPress exige um tema. Mas ficou só com o style.css, sem functions.php nenhum.

Três detalhes que quebram em silêncio

O WordPress só carrega os .php na raiz de mu-plugins/. Ele não varre subpasta. Um módulo em cms/ simplesmente não roda, e não avisa. Por isso o cms-core.php na raiz, que só faz require_once dos módulos.

Caminho de asset não pode vir do tema. O enqueue do campo de mídia usava get_stylesheet_directory_uri(), que aponta para a pasta do tema. Dentro de um mu-plugin isso resolve para o lugar errado, e o campo apareceria sem CSS e sem arrastar, sem nenhum erro no console. Virou plugin_dir_url(__FILE__).

A ordem de subida importa, de novo. O mesmo "Cannot redeclare" espreita aqui, porque mu-plugin e tema são carregados os dois. Subi o código novo primeiro e só depois apaguei o functions.php do servidor, conferindo antes que nenhuma função estivesse declarada nos dois lugares.

Uma observação sobre esse "subi" e esse "apaguei", que um leitor acostumado a esteira de deploy vai estranhar: aqui o WordPress é um site que já existia e foi adaptado, sem pipeline e sem ambiente de homologação. É arquivo enviado para o servidor, na mão. Isso muda a forma de trabalhar: a ordem das etapas vira parte da solução, e o teste tem que ser verificável de fora, por requisição, porque não existe pipeline reprovando nada antes. Do lado do Nuxt a história é outra, com repositório e deploy na Vercel.

O critério que eu deveria ter usado desde o começo

A pergunta não é "onde isso fica protegido de update?". É do que essa customização depende para existir?

TipoLugar
Contrato da API REST (endpoints, campos, headers/CORS)mu-plugins/
Modelo de dados (post meta, custom post types, taxonomias)mu-plugins/
Interface do painel (meta boxes, campos, assets de admin)mu-plugins/
Apresentação do front do WordPress (templates, layout)tema filho

Num site headless a última linha é vazia. Então, na prática, tudo vai para mu-plugins/, e o tema filho é a exceção.

Uma ressalva honesta, para não apresentar mu-plugin como escolha sem custo: ele não tem botão de desativar. Isso é bom, porque ninguém desliga sem querer, e é ruim, porque se o código quebrar a saída é apagar o arquivo por FTP, não clicar no painel.


Como verifiquei

Depois de qualquer uma dessas mudanças, o teste não é "o painel abriu". É conferir peça por peça, por fora, sem depender de olhar o site:

txt
GET      /                             -> 302 para /wp-admin/
GET      /wp-json/wp/v2/posts          -> 200, JSON, não HTML
OPTIONS  com Origin permitido          -> 200, access-control-allow-origin: <o domínio>
OPTIONS  com Origin desconhecido       -> 200, sem access-control-allow-origin

O preflight é o teste que faltou na primeira vez. Um curl simples devolve o JSON mesmo sem CORS nenhum, porque curl não é navegador e não valida origem. Pedir OPTIONS com Origin é o que reproduz de verdade o que o Nuxt faz.

A última linha é a que prova que a restrição existe. Com * no lugar da allowlist, as três primeiras passariam exatamente igual.

Por isso o teste de CORS é um par de comandos, nunca um comando só:

bash
curl -sI -H "Origin: https://www.exemplo.com.br" .../wp-json/wp/v2/posts
curl -sI -H "Origin: https://origem-desconhecida.com" .../wp-json/wp/v2/posts

O primeiro precisa trazer access-control-allow-origin com o domínio. O segundo precisa responder 200 e não trazer o header. Rodar só o primeiro não distingue uma allowlist funcionando de um * esquecido.

E o 302 na raiz virou prova dupla: como o functions.php do tema não existe mais, ele só pode estar vindo do mu-plugin.


Conclusões

Num site headless, o código que sustenta a API não pertence ao tema. CORS, formato de dados e campos personalizados não são apresentação. Tema pai é uma pasta que o sistema se propõe a substituir, tema filho é uma pasta que o administrador pode trocar num clique, e nenhuma das duas é fundação.

Um risco resolvido não é o risco resolvido. Eu tirei o código do tema pai, confirmei que a atualização não apagava mais nada, e dei o assunto por encerrado. Faltava perguntar quais outros jeitos aquilo tinha de sumir. O critério bom quase nunca é "isto está a salvo do problema que eu vi", é "de que isto depende para existir".

Customização que o front depende precisa ser verificável por fora. Todas as peças aqui têm um comando que prova que estão no ar, e nenhuma delas depende de abrir o navegador e achar que a página está com jeito de certa.

No próximo artigo da série conto o campo de galeria múltipla que escrevi para substituir dez campos numerados do ACF, sem comprar o ACF PRO, e como migrei o conteúdo dos posts com o site no ar.


Leituras relacionadas

Perguntas frequentes

Tema filho é suficiente para guardar customizações de um WordPress headless?

Não. Tema filho protege de um risco só, a atualização do tema pai sobrescrever os arquivos. Ele continua vulnerável à troca de tema e ao WordPress cair no tema padrão depois de um erro fatal. Num site headless, CORS, campos personalizados e formato de resposta da API não são apresentação, então o lugar deles é mu-plugins.

O que são mu-plugins no WordPress?

São must-use plugins: arquivos PHP em wp-content/mu-plugins/ que o WordPress carrega sempre, antes dos plugins normais, sem precisar de ativação no painel e sem botão de desativar. A pasta não existe por padrão, basta criá-la. É onde fica a configuração que não pode depender do tema ativo nem de alguém lembrar de ligar.

Por que meu mu-plugin não está sendo carregado?

Provavelmente ele está numa subpasta. O WordPress só carrega os arquivos .php que estão na raiz de wp-content/mu-plugins/, sem varrer subdiretórios, e não emite nenhum aviso quando o arquivo é ignorado. A solução é deixar um arquivo loader na raiz que faz require_once dos módulos internos.

Devo liberar Access-Control-Allow-Origin com asterisco na API de um WordPress headless?

Não é necessário. O correto é uma lista de origens permitidas, devolvendo no header a origem que fez o pedido quando ela está na lista, acompanhada de Vary: Origin para que nenhum cache entregue a resposta de uma origem para outra. Se o front só lê conteúdo, os métodos ficam em GET e OPTIONS e o header Authorization não precisa ser anunciado. Isso não torna o conteúdo privado: quem abrir a URL da API direto continua vendo o JSON, porque CORS controla quem lê a resposta de dentro de outra página, não quem acessa o endpoint.

Como redirecionar a home do WordPress para o painel num site headless?

Com um hook em template_redirect chamando wp_safe_redirect(admin_url()). O cuidado está nos guards: é preciso sair da função quando a requisição for do painel, do admin-ajax, do cron, da REST API, do robots.txt ou de um feed. Sem o guard de REST_REQUEST o redirect derruba a API que alimenta o front.

LarissaSantos

Desenvolvedora Frontend apaixonada por criar experiências digitais incríveis

Navegação

2026 © Larissa Santos Feito com Vue.js