Como implementar JSON-LD no Next.js (App Router)
Publicado em
Para adicionar JSON-LD no Next.js com App Router, você renderiza uma tag
<script type="application/ld+json"> dentro de um Server Component — no
page.tsx da rota ou no layout.tsx, dependendo do escopo do schema — e coloca
o objeto de dados dentro dela com JSON.stringify. Não vai em <Head> e não
precisa de next/script. Essa é a recomendação oficial do próprio Next.js, e o
resto deste artigo é sobre onde cada tipo de schema deve viver, como derivá-lo da
sua fonte de verdade em vez de escrever à mão, e como impedir que ele quebre
silenciosamente depois.
Este é o segundo caminho do guia de SEO técnico para Next.js: não "como colar um script", e sim como a implementação de dados estruturados fica quando é código de produção, derivado do conteúdo e travado por teste automatizado. Cada seção abaixo é uma decisão de arquitetura — onde o schema nasce, o que o alimenta, e o que acontece quando ele para de fazer sentido.
Uma ressalva que precisa vir antes de qualquer código, porque metade do conteúdo em português erra nisso: dados estruturados não são um fator direto de ranking. O JSON-LD não empurra sua posição para cima. O que ele faz é tornar a página elegível a rich results — o caminho de breadcrumb no lugar da URL, a exibição de autor e datas do artigo — e ajudar mecanismos a entender a estrutura da página. O ganho é de elegibilidade e de apresentação, não de classificação.
Onde o JSON-LD vive no App Router
O JSON-LD precisa estar no HTML que o Googlebot lê no primeiro acesso. Essa é a
única regra que decide a arquitetura inteira. No App Router, o caminho natural
para isso é renderizar o script em um Server Component — o padrão de qualquer
page.tsx ou layout.tsx que não tem "use client" no topo.
O mecanismo é o seguinte. O Googlebot lê a página em duas ondas. Na primeira, ele processa o HTML que chega do servidor. Só depois, numa fila separada de renderização, ele executa o JavaScript e observa o DOM final. Se o seu bloco de JSON-LD só passa a existir depois que o React hidrata no cliente, existe uma janela em que o crawler vê o HTML inicial sem schema nenhum, e a renderização da segunda onda pode demorar.
Aqui cabe uma precisão que quase todo artigo sobre o assunto erra. O problema
não é o arquivo ter "use client" no topo. Um client component também é
renderizado no servidor: o HTML dele sai na resposta inicial, script de schema
incluído. O que quebra é o schema que só nasce depois da hidratação — montado
dentro de um useEffect, ou derivado de um estado que só existe no navegador.
Esse é o schema que não está no HTML inicial, porque no momento em que o servidor
respondeu ele ainda não existia.
O que garante o schema no HTML inicial é ele ser derivado de dados que o servidor já tem — não o arquivo estar fora de um client component.
| Como o schema é montado | Está no HTML inicial? | Quando o Googlebot vê |
|---|---|---|
| Derivado de dado do servidor | sim | 1ª onda, junto do HTML |
| Renderizado dentro de client component com dado do servidor | sim | 1ª onda, junto do HTML |
Criado em useEffect ou a partir de estado do cliente | não | só na 2ª onda, enfileirada |
Neste site a regra é seguida pelos dois lados. Os componentes de schema de
página são Server Components puros; o bloco de FAQPage mora dentro do
componente de FAQ, que é um client component por causa do acordeão — e mesmo
assim o script sai no HTML inicial, porque as perguntas vêm do frontmatter, não
de estado. É o caso que confirma a regra: o que importa é a procedência do dado.
O componente base: um <script> e nada mais
O componente que emite o schema é deliberadamente burro: ele recebe um objeto,
serializa com JSON.stringify e injeta o resultado dentro de uma tag <script>.
Sem estado, sem efeito, sem next/script. Esse último ponto é intencional e a
própria documentação do Next.js explica o porquê: o next/script serve para
carregar e executar JavaScript, e JSON-LD é dado, não código executável — a tag
<script type="application/ld+json"> nativa é a escolha certa.
Este é o padrão que roda em produção neste site — todos os schemas do site passam por essas poucas linhas:
// src/components/seo/JsonLd.tsx
function JsonLdScript({ schema }: { schema: Record<string, unknown> }) {
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(schema).replace(/</g, '\\u003c') }}
/>
)
}O dangerouslySetInnerHTML assusta pelo nome, mas aqui ele é o caminho correto:
é como se injeta o texto do schema sem o React escapar as aspas do JSON. O nome
"dangerous" é um lembrete legítimo, e vale levá-lo a sério — a documentação do
Next.js alerta que JSON.stringify não sanitiza strings usadas em injeção de
XSS, e recomenda trocar o caractere de menor-que pelo equivalente unicode antes
de injetar. É exatamente o que aquele .replace() faz: dentro de uma string
JSON, \u003c é a forma escapada do caractere <. O parser lê o mesmo dado, mas
nenhum texto do schema consegue mais escrever um </script> literal que feche a
tag no meio do HTML.
A pergunta que dimensiona o risco é: de onde vêm os dados do schema? Se vierem de input de usuário, essa sanitização não é opcional. Neste site cada objeto é montado a partir do frontmatter do próprio conteúdo e de constantes do repositório — fontes que eu controlo, nunca texto digitado por um terceiro. Ou seja: aqui o escape não corrige uma vulnerabilidade, ele remove a dependência de uma disciplina editorial. Sem ele, o dia em que uma resposta de FAQ mencionar uma tag HTML vira um bug de HTML quebrado. É uma linha de código para nunca precisar lembrar disso — e é por isso que ela está no componente, e não numa regra de revisão.
Com esse componente pronto, o trabalho de cada tipo de schema vira apenas montar o objeto certo e passá-lo adiante. As próximas seções são exatamente isso, tipo por tipo.
Article: o schema que sai do frontmatter, não da mão
O erro mais comum com Article é escrever o objeto à mão em cada post. Isso
garante que, em algum momento, o headline do schema vai divergir do <title>
da página, ou a dateModified vai ficar congelada numa data que não corresponde
mais à última edição. Schema escrito à mão apodrece.
A saída é derivar o Article da mesma fonte que gera a página. Neste site, todo
post do blog e o guia têm um bloco de frontmatter no topo do arquivo MDX, e o
componente de Article recebe esse frontmatter inteiro em vez de valores
digitados:
// src/components/seo/JsonLd.tsx
export function ArticleJsonLd({
frontmatter,
path,
}: {
frontmatter: PostFrontmatter
path?: string
}) {
const schema = {
'@context': 'https://schema.org',
'@type': 'Article',
headline: frontmatter.title,
description: frontmatter.description,
datePublished: frontmatter.datePublished,
dateModified: frontmatter.dateModified,
inLanguage: frontmatter.lang,
mainEntityOfPage: `${site.url}${path ?? `/blog/${frontmatter.slug}`}`,
author: personSchema(),
publisher: { '@type': 'Organization', name: site.name, url: site.url },
}
return <JsonLdScript schema={schema} />
}O mapeamento é direto e é o ponto inteiro: headline vem do title do
frontmatter, description do description, as datas das datas, inLanguage do
lang, e mainEntityOfPage é a URL canônica montada a partir da base do site
mais o caminho da rota. O author e o publisher não são digitados nunca: saem
de uma função e de um módulo central. Escrevi o frontmatter uma vez; o schema se
deriva dele. Não há um segundo lugar onde a data possa divergir, porque só existe
um lugar onde ela é escrita.
Esse é o princípio que se repete no resto do artigo: schema não se escreve por página, se deriva da fonte de verdade. Se você tem que atualizar o conteúdo em um lugar e o schema em outro, é só questão de tempo até os dois discordarem — e um schema que discorda da página é pior que schema nenhum, porque descreve uma página que não existe.
Vale notar que Article continua gerando rich result: implementado com
headline, datas e as entidades de autor e publisher, ele habilita a exibição de
autor e das datas de publicação e modificação no resultado. Diferente do FAQ, que
veremos adiante, aqui o esforço ainda tem contrapartida visual na busca.
BreadcrumbList: o schema que quase todo mundo esquece
O BreadcrumbList é o schema de melhor custo-benefício que a maioria dos sites
ignora. Ele descreve a trilha de navegação até a página — Home › Blog › título do
post — e é um dos rich results que seguem plenamente ativos: no resultado de
busca, ele substitui a URL crua por esse caminho legível. É trabalho pequeno com
retorno visual concreto, e por isso vale ser sistemático.
"Sistemático" aqui significa não escrever o breadcrumb por página. Ele é montado
a partir da hierarquia da rota, do mesmo jeito que o Article é derivado do
frontmatter:
// src/components/seo/JsonLd.tsx
export function BreadcrumbJsonLd({ items }: { items: BreadcrumbItem[] }) {
const schema = {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: items.map((item, i) => ({
'@type': 'ListItem',
position: i + 1,
name: item.name,
item: `${site.url}${item.path}`,
})),
}
return <JsonLdScript schema={schema} />
}Dois detalhes que o Google exige e que o mapeamento acima respeita: cada
ListItem precisa de uma position sequencial começando em 1, e precisa de
name. O item — a URL do nível — é o que faz cada degrau da trilha ser
clicável. Um BreadcrumbList precisa de pelo menos dois níveis para fazer
sentido; um único item não é uma trilha. É a mesma regra que o
gerador de JSON-LD deste site aplica antes de
deixar você gerar um breadcrumb: sem dois níveis, ele recusa.
FAQPage: o que mudou em maio de 2026 e por que ainda mantenho
Aqui está a seção onde a maior parte do conteúdo em português está desatualizada, então vou ser direto: desde 7 de maio de 2026, o Google não exibe mais o rich result de FAQ. Aquele dropdown de perguntas e respostas que expandia embaixo do seu resultado de busca não aparece mais — para nenhum site. Não é uma restrição a certas categorias, como foi em 2023, quando o recurso ficou limitado a sites de governo e saúde. Desta vez o recurso saiu do ar por completo, e em 15 de junho de 2026 o Google removeu a própria documentação do recurso.
Isso tem uma consequência prática incômoda: boa parte do que você encontra pesquisando "FAQPage schema" hoje descreve um recurso que não existe mais, e a página oficial que serviria de referência não está mais lá para desmentir. Em dados estruturados, verificar a data da fonte deixou de ser preciosismo.
Duas reações erradas circularam quando o aviso saiu, e vale desarmar as duas. A
primeira: "schema morreu". Não morreu — o FAQPage continua um tipo válido do
schema.org, o markup não gera erro e não prejudica a página, e outros tipos como
Article e BreadcrumbList seguem produzindo rich results normalmente. A
segunda: "então FAQPage agora é essencial para IA". Também não. No guia oficial
de otimização para busca com IA generativa, publicado em maio de 2026, o Google
afirma que não é preciso adicionar markup especial nem schema.org para aparecer
em AI Overviews ou no AI Mode. Schema segue útil para os rich results
tradicionais — que, por sua vez, alimentam as respostas de IA de forma indireta —
mas não é uma alavanca direta de citação por IA. Quem trocou "vai virar rich
result" por "vai ser citado pela IA" só trocou uma promessa sem base por outra.
Então por que eu mantenho o FAQPage neste site, se o rich result acabou? Por
uma razão de arquitetura: aqui, o FAQ visível na página e o FAQPage estruturado
nascem da mesma fonte. Não existe um componente de FAQPage escrito à mão. O que
existe é um array faq no frontmatter do artigo — as mesmas perguntas e
respostas que aparecem no fim desta página para você — e um único componente lê
esse array para renderizar duas coisas ao mesmo tempo: o acordeão visual e o
JSON-LD correspondente.
// src/components/sections/FaqSection.tsx
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify({
'@context': 'https://schema.org',
'@type': 'FAQPage',
mainEntity: items.map(({ question, answer }) => ({
'@type': 'Question',
name: question,
acceptedAnswer: { '@type': 'Answer', text: answer },
})),
}).replace(/</g, '\\u003c'),
}}
/>O items desse trecho é exatamente o mesmo array que o acordeão percorre para
desenhar as perguntas na tela, algumas linhas acima no mesmo arquivo. Como a
fonte é única, o custo marginal de manter o FAQPage é praticamente zero: eu
escrevo o FAQ para o leitor de qualquer jeito, e o schema sai do mesmo array sem
trabalho extra. E resolve por construção a exigência que o Google sempre fez do
FAQPage — o conteúdo do schema tem que estar visível na página. Não pode
divergir, porque é literalmente o mesmo dado.
Esse detalhe importa mais do que parece, porque a validação automática não pega divergência. O teste de pesquisa aprimorada, quando ainda validava FAQ, nunca comparou o JSON-LD ao texto visível: ele checa sintaxe e propriedades, não se o schema bate com o que o usuário vê. Um schema que declara uma resposta e uma página que mostra outra passa na validação e mesmo assim viola a política. Derivar os dois da mesma fonte elimina essa classe de erro antes de ela existir.
A recomendação, então, não é "adicione FAQPage para ganhar o rich result" — esse conselho morreu em maio. É: se você tem um FAQ genuíno na página, que responde perguntas reais, marcá-lo com FAQPage tem custo baixo e mantém a página bem descrita para os mecanismos que ainda leem o markup. Se o seu FAQ só existia para caçar o dropdown no SERP, a hora de repensá-lo é agora.
Person e Organization: a entidade autor e o E-E-A-T
O author do Article lá em cima não é um nome solto numa string. É uma
referência a uma entidade Person única, montada uma vez a partir do módulo
central do site e reaproveitada por todos os artigos:
// src/components/seo/JsonLd.tsx
function personSchema() {
const sameAs = [site.author.github, site.author.linkedin].filter(Boolean)
return {
'@type': 'Person',
name: site.author.name,
jobTitle: site.author.jobTitle,
url: site.url,
...(sameAs.length > 0 ? { sameAs } : {}),
}
}O campo que faz o trabalho pesado é o sameAs: ele liga a entidade Person do
site aos perfis externos — GitHub, LinkedIn — que corroboram quem é o autor. Não
é uma declaração de que a pessoa existe; é um conjunto de âncoras para outras
presenças verificáveis dessa mesma pessoa na web. É assim que dados estruturados
participam do E-E-A-T: não afirmando expertise, mas conectando a autoria a uma
identidade rastreável.
Repare no filter(Boolean) e no espalhamento condicional: se um perfil não
estiver preenchido, o campo simplesmente não entra no schema. É uma regra que
vale para todo tipo — campo vazio é pior que campo ausente, porque descreve a
página com uma informação que não existe. O gerador do site segue a mesma
política: campos em branco são omitidos da saída em vez de virarem string vazia.
SoftwareApplication e o caso das páginas de ferramenta
Nem toda página é um artigo. Uma página que entrega uma ferramenta interativa é
descrita pelo tipo SoftwareApplication, não por Article. Neste site, as
páginas de ferramenta emitem esse schema com os campos que descrevem o que a
ferramenta é:
{
"@context": "https://schema.org",
"@type": "SoftwareApplication",
"applicationCategory": "DeveloperApplication",
"operatingSystem": "Web",
"isAccessibleForFree": true,
"offers": { "@type": "Offer", "price": "0", "priceCurrency": "BRL" }
}Os valores não são decorativos: applicationCategory diz que é uma ferramenta
para desenvolvedores, operatingSystem que roda no navegador,
isAccessibleForFree mais o offers com preço zero declaram que é gratuita. É a
descrição honesta de uma ferramenta que roda inteiramente no cliente e não cobra
nada. O princípio geral atravessa todos os tipos: o @type que você escolhe tem
que corresponder ao que a página realmente é. Marcar uma página de ferramenta
como Article, ou um artigo como SoftwareApplication, é o tipo de incoerência
que o Google trata como sinal ruim.
Page vs. layout: onde cada schema deve ser renderizado
Com os tipos definidos, sobra uma decisão de arquitetura que confunde bastante: o
schema vai no page.tsx da rota ou no layout.tsx? A resposta não é uma
preferência de estilo — ela cai direto da pergunta "de onde vêm os dados desse
schema?".
Schema que depende do conteúdo específico da rota vai no page.tsx. Article e
BreadcrumbList de um post são assim: os dados que os alimentam — título, datas,
trilha — só existem no nível daquela página. Não há como montá-los no layout,
porque o layout não conhece o post.
Schema estável para uma seção inteira pode viver no layout.tsx correspondente.
Um WebSite ou uma Organization que valem para o site todo não precisam ser
remontados a cada rota. A regra que resolve todos os casos: renderize cada
schema no nível onde os dados que o alimentam realmente vivem. Se o dado é da
página, o schema é da página. Se o dado é da seção, o schema é do layout.
Cada tipo de rota emite um conjunto fixo de tipos de schema. O FAQPage é o único condicional: ele só aparece quando o conteúdo tem um FAQ.
| Tipo de rota | @type emitidos |
|---|---|
| Home | WebSite, Person |
| Sobre | Person, BreadcrumbList |
| Blog | Article, BreadcrumbList, FAQPage (se houver faq) |
| Guia | Article, BreadcrumbList |
| Ferramenta | SoftwareApplication, BreadcrumbList |
Isso vale também para o caso mais dinâmico de todos — uma rota com parâmetro,
como /blog/[slug]. O schema não é escrito para cada slug; ele é derivado, dentro
da função assíncrona da rota, dos mesmos dados que geram a página. Você busca o
conteúdo pelo parâmetro, monta o objeto de schema a partir desses campos, e
renderiza o componente de script no retorno. O schema de uma rota dinâmica é
dinâmico pela mesma razão que a página é: os dois saem da mesma busca.
Como validar: qual ferramenta para qual schema
Validar dados estruturados hoje exige uma distinção que ficou nova este ano: nem
toda ferramenta valida todo tipo de schema, porque o FAQ saiu do catálogo de rich
results do Google. O roteiro depende de qual @type você está checando.
Para os tipos que ainda geram rich result — Article, BreadcrumbList,
SoftwareApplication — o
teste de pesquisa aprimorada do Google
continua sendo a ferramenta certa. Cole a URL, não o código: pela URL a
ferramenta renderiza a página como o Google a vê, o que importa justamente se
algo do seu schema depender de renderização. Depois confira duas coisas. Erros
bloqueiam a elegibilidade — uma propriedade obrigatória faltando, um @type que
o Google não reconhece, um JSON que não faz parse. Avisos, não: eles apontam
propriedades recomendadas ausentes, e o rich result ainda funciona sem elas, só
com menos detalhe. Corrija todos os erros; trate os avisos como melhoria
opcional.
Para o FAQPage, o roteiro mudou. Sem rich result de FAQ, não há elegibilidade a
testar — o que continua fazendo sentido é validar a sintaxe do schema, e para
isso o caminho é o validador do schema.org, que
confere se o markup está de acordo com o vocabulário independentemente de o
Google gerar algum recurso visual a partir dele.
Depois de publicar, o monitoramento contínuo é o Search Console. Os relatórios de
melhorias mostram, em escala de site, quando um tipo de schema quebra em muitas
páginas de uma vez — uma mudança de template que derruba o Article em duzentos
posts aparece ali como um pico de itens inválidos que nenhum teste de URL
individual pegaria. É a ferramenta de monitoramento ao longo do tempo,
complementar ao teste pontual de antes de publicar. Cabe a ressalva honesta: este
site é novo, então eu ainda não tenho histórico do relatório de melhorias para
mostrar. O que descrevo é o papel da ferramenta, não um resultado meu.
Como impedir que quebre: o teste que roda no meu CI
Validar antes de publicar resolve o dia do lançamento. Não resolve o terceiro mês, quando alguém — talvez você mesmo — mexe num template e derruba o schema de todas as páginas sem perceber. O jeito de garantir que isso não passe despercebido é transformar a validação em teste automatizado que bloqueia o deploy.
A suíte roda em toda pull request, no GitHub Actions, e bloqueia o merge se
falhar. O detalhe que a torna robusta é que as rotas testadas não são escritas à
mão numa lista: elas vêm do sitemap.ts, que por sua vez deriva de todos os
posts existentes em /content. Na prática, um artigo novo entra na suíte no
momento em que o arquivo é commitado — inclusive este que você está lendo. Eu
não preciso lembrar de adicionar o post ao teste; o teste descobre o post
sozinho.
Por rota, a suíte afirma um conjunto de invariantes. Existe exatamente um <h1>
não vazio. O <title> está presente e tem no máximo 60 caracteres. A meta
description está presente e tem no máximo 155. O canonical é autorreferente. E, o
que importa aqui: todo bloco application/ld+json da página faz JSON.parse
sem erro, e o conjunto de @type esperado para aquele tipo de rota está
presente.
// tests/seo/seo.spec.ts — trecho da suíte de regressão de SEO
const jsonLdBlocks = await page
.locator('script[type="application/ld+json"]')
.allTextContents()
const foundTypes: string[] = []
for (const raw of jsonLdBlocks) {
let parsed: unknown
try {
parsed = JSON.parse(raw)
} catch {
throw new Error(`JSON-LD block on ${route} is not valid JSON:\n${raw}`)
}
for (const node of Array.isArray(parsed) ? parsed : [parsed]) {
const type = (node as { '@type'?: string })['@type']
if (type) foundTypes.push(type)
}
}
for (const type of expectedJsonLdTypes(route)) {
expect.soft(foundTypes, `JSON-LD @type "${type}" on ${route}`).toContain(type)
}O expectedJsonLdTypes é o mapa que você já viu no diagrama, escrito como
função: uma rota de blog deve conter Article e BreadcrumbList, mais FAQPage
se — e somente se — o frontmatter daquele post tiver um array faq; uma rota de
ferramenta deve conter SoftwareApplication e BreadcrumbList, e assim por
diante. A consequência é a frase que resume por que o teste existe: se um bloco
de JSON-LD deixar de fazer parse, ou um @type sumir de um template, o teste
falha e o merge é bloqueado.
Há uma segunda camada, no próprio build. O frontmatter é validado na hora de
montar a página: se faltar um campo obrigatório, ou se o title passar de 60
caracteres, ou a description de 155, o build quebra. Não é um aviso que se
ignora; é uma falha que impede a geração. Entre o build que recusa frontmatter
inválido e a suíte que recusa schema inválido, o caminho para publicar uma página
com dados estruturados quebrados fica fechado nos dois pontos onde o erro costuma
entrar.
O que não perseguir
Fecho com a calibragem honesta, porque metade do valor de um artigo técnico está
em dizer o que não fazer. Não persiga ranking com JSON-LD: ele não é fator de
classificação e nenhum volume de schema vai mudar isso. Não adicione FAQPage
esperando o dropdown no SERP — ele foi descontinuado em maio de 2026 e não vai
voltar por bem-intencionado que seja o seu markup. Não trate schema como um
atalho para ser citado por IA; o próprio Google diz que não há markup especial
exigido para isso. E não escreva schema à mão por página: o que se deriva da
fonte de verdade não diverge, o que se digita duas vezes diverge.
O que dados estruturados de fato entregam é mais modesto e mais real: eles tornam a página elegível aos rich results que ainda existem, descrevem a estrutura do conteúdo para quem lê o markup, e — se você travar isso com teste — permanecem corretos ao longo do tempo sem vigilância manual. Isso fecha o segundo caminho do guia de SEO técnico para Next.js; os outros tópicos seguem a mesma lógica de derivar do conteúdo em vez de escrever à mão. Quando você precisar gerar um schema rápido para um caso específico, o gerador de JSON-LD produz o objeto e o componente Next.js prontos, no mesmo padrão de Server Component que descrevi aqui. Este artigo é a arquitetura; a ferramenta é o atalho para quando você já entendeu a arquitetura e só quer o resultado.