Sitemap dinâmico no Next.js com sitemap.ts e robots.ts
Publicado em
Um sitemap dinâmico no Next.js é um arquivo app/sitemap.ts que exporta uma
função default retornando um array — o Next.js serve esse array em
/sitemap.xml automaticamente. A parte "dinâmica" não é o roteamento, que o
Next.js já resolve: é o array em si ser derivado da fonte de conteúdo, em vez
de digitado à mão. Publicar um post deveria bastar para o sitemap crescer
sozinho; se isso não acontece no seu projeto, o sitemap não é dinâmico — é um
arquivo .ts com uma lista fixa dentro.
O resto deste artigo não é sobre criar o arquivo, que sai em três linhas logo
abaixo. É sobre a pergunta que a maior parte dos tutoriais em português não
responde: dos quatro campos que o sitemap.ts permite declarar por URL, quantos
o Google realmente lê? E o que fazer com os outros, que seguem sendo copiados de
tutorial em tutorial sem efeito nenhum.
// app/sitemap.ts
import type { MetadataRoute } from 'next'
export default function sitemap(): MetadataRoute.Sitemap {
return [{ url: 'https://seusite.com', lastModified: new Date() }]
}Isso já é um sitemap válido. O problema começa quando esse array não muda sozinho conforme o site cresce — e é aí que a maior parte dos tutoriais para. Este é o terceiro caminho do guia de SEO técnico para Next.js, e cada seção abaixo é uma decisão tomada neste próprio site, com o código que está em produção agora.
O que o Next.js gera e o que o Google realmente lê
O formato que aparece em praticamente todo tutorial — incluindo o exemplo oficial da documentação do Next.js — é este:
{ url: '...', lastModified: new Date(), changeFrequency: 'weekly', priority: 0.8 }Quatro campos. Segundo a documentação do Google sobre como construir um sitemap,
<priority> e <changefreq> são ignorados. O único metadado que o Google
efetivamente usa é o <lastmod> — e apenas quando o valor é consistente e
verificável, por exemplo comparando com a última modificação real da página. De
quatro campos, portanto, dois não fazem nada e o terceiro só funciona se for
verdade.
Isso não é opinião de blog de SEO, está na documentação. E a razão de o padrão
persistir em tanto tutorial é simples: o exemplo oficial do próprio Next.js
também usa lastModified: new Date() em toda entrada, que é exatamente o padrão
que ensina o Google a desconfiar do campo — o assunto da seção depois da
próxima.
| Campo | O Google usa? | Vale declarar |
|---|---|---|
url | sim — é a lista de URLs que existem | sempre |
lastModified | sim, se for verificável | só quando há data real derivável do conteúdo |
changeFrequency | não | nunca; não afeta nada |
priority | não | nunca; não afeta ordem de rastreamento nem ranqueamento |
O tipo MetadataRoute.Sitemap, exportado por next, tipa esse array. É o que o
Next.js serializa para XML e serve em /sitemap.xml sem nenhum passo adicional.
Sitemap dinâmico de verdade: derivar do conteúdo
O sitemap é uma projeção do conteúdo: a fonte é o arquivo MDX, a derivação é uma função, e a saída acompanha sozinha. Sem derivação, ele fica desincronizado até alguém lembrar de editar a lista.
A diferença entre um sitemap dinâmico e uma lista de URLs com extensão .ts
cabe em uma função: getAllPosts(). Neste site, o sitemap.ts não lista posts —
ele lê /content/blog, mapeia cada arquivo MDX para uma entrada e usa o
dateModified do frontmatter como lastModified. Publicar um arquivo é o único
passo. Não existe uma segunda lista para lembrar de atualizar.
import { getAllPosts } from '@/lib/content'
// Data local, não UTC: `new Date('2026-07-20')` seria interpretada como
// meia-noite UTC e poderia voltar um dia no fuso de quem lê.
const toDate = (d: string) => new Date(`${d}T00:00:00`)
export default function sitemap(): MetadataRoute.Sitemap {
return getAllPosts().map(({ frontmatter }) => ({
url: `https://seotecnico.dev.br/blog/${frontmatter.slug}`,
lastModified: toDate(frontmatter.dateModified),
}))
}Repare que getAllPosts() devolve objetos com o frontmatter aninhado —
frontmatter.slug, frontmatter.dateModified — porque a função separa o
frontmatter validado do corpo do MDX. Essa validação acontece no build: um
title acima de 60 caracteres ou um campo obrigatório ausente derruba a
compilação antes de qualquer coisa chegar ao sitemap.
A tabela abaixo é o espelho textual do diagrama acima — a mesma lógica, em texto:
| Fonte | Derivação | Saída no sitemap | Quando o conteúdo muda |
|---|---|---|---|
content/blog/*.mdx, frontmatter | getAllPosts() lê o frontmatter de cada arquivo | <loc> e <lastmod>, este vindo de dateModified | editar o post atualiza o lastmod sozinho, porque a data está no próprio arquivo |
| diagramas exportados do post | postImages mapeia slug para arquivos | <image:loc> | declarar um diagrama novo o inclui no sitemap sem edição extra |
| (alternativa não usada aqui) lista mantida à mão | nenhuma; a lista é digitada | <loc> sem lastmod confiável | nada muda sozinho: a lista fica desatualizada até alguém lembrar dela |
A última linha é o que a maioria dos projetos Next.js acaba fazendo por padrão: um array literal que funciona até o dia em que alguém esquece de adicionar uma URL nova, ou remove uma página sem tirá-la da lista. Derivar do conteúdo elimina essa categoria inteira de erro, porque o sitemap nunca fica dessincronizado — ele é uma projeção do conteúdo.
Vale marcar o que fica de fora dessa derivação: páginas estáticas sem data de
conteúdo associada, como /ferramentas, /sobre e /politica-de-privacidade,
não têm um dateModified de onde derivar. A próxima seção explica por que isso
é uma omissão deliberada, e não uma lacuna.
O problema do lastmod
A condição que o Google publica para usar o <lastmod> é específica: o valor
precisa ser consistente e verificável — por exemplo, comparável com a última
modificação real da página. Isso exclui, na prática, o padrão que aparece em
quase todo exemplo de sitemap que existe, inclusive o da documentação do
Next.js:
// o padrão que ensina o Google a ignorar o lastmod
{ url: '...', lastModified: new Date() }new Date() executa no momento do build. Toda vez que o site é implantado —
mudando ou não o conteúdo daquela página — a URL declara que mudou agora. Um
redeploy de segunda sem alteração nenhuma produz o mesmo lastmod que um
redeploy de terça com o artigo reescrito do zero. Não há como o Google verificar
essa data contra coisa alguma, porque ela nunca corresponde a uma mudança real.
É a razão prática por trás da recomendação de que o valor reflita apenas
atualizações significativas — conteúdo principal, dados estruturados, links — e
não uma data de build ou de copyright.
A regra deste site inverte a lógica, e o comentário está no topo do arquivo em produção:
// lastmod só é emitido quando derivável do frontmatter: um lastmod que muda a
// cada build sem mudança real de conteúdo ensina o Google a ignorá-lo.Na prática, isso produz três comportamentos diferentes dentro do mesmo
sitemap.ts:
- Posts do blog —
lastModifiedvem dodateModifieddo frontmatter. Se o post não muda, o campo não muda, mesmo que o site seja reimplantado dez vezes na semana. - Home e
/blog— derivam olastModifieddodateModifiedmais recente entre o guia e todos os posts, porque essas páginas de fato mudam quando o conteúdo que elas listam muda. Continua verificável: a data reflete uma mudança real no conjunto exibido. - Páginas estáticas sem data de conteúdo — não carregam
lastModifiednenhum. Omitir é a decisão. Inventar uma data seria pior do que não declarar nada, porque reintroduziria exatamente o padrão não verificável descrito acima.
A terceira linha é a que costuma gerar dúvida: por que não usar a data de deploy
também aqui? Porque a data de deploy não é a data em que o conteúdo daquela
página mudou — é a data em que o código mudou, o que acontece por motivos sem
relação nenhuma com o conteúdo, como uma dependência atualizada ou um ajuste de
estilo em outro componente. Declarar isso como lastmod seria tecnicamente
fácil e editorialmente falso.
robots.ts: o padrão é não indexar
O robots.ts resolve um problema diferente do sitemap.ts, mas os dois andam
juntos porque é o robots.txt que anuncia o sitemap. A decisão de design aqui é
que o padrão é não indexar, e qualquer coisa que fuja do caminho feliz de
produção cai nesse padrão automaticamente:
// src/app/robots.ts
export default function robots(): MetadataRoute.Robots {
if (!indexable) {
return { rules: [{ userAgent: '*', disallow: '/' }] }
}
return {
rules: [{ userAgent: '*', allow: '/', disallow: ['/api/'] }],
sitemap: `${site.url}/sitemap.xml`,
}
}E o indexable vem de uma variável de ambiente, em src/lib/site.ts:
export const indexable = process.env.SITE_INDEXABLE === 'true'O ponto está na direção do fail-safe. indexable só é true se a variável
existir e valer exatamente 'true'. Variável ausente, deploy de preview na
Vercel, build local — todos falham fechado: a ausência de configuração produz
Disallow: /, não o contrário. Indexar é opt-in, e o opt-in é uma variável que
só existe em produção.
Isso resolve um acidente comum e caro: uma URL de staging indexada, aparecendo na busca e competindo com a página de produção equivalente — duas URLs para o mesmo conteúdo, dividindo sinais. Com o padrão invertido, esse acidente exigiria alguém definir a variável errada num ambiente que não deveria indexar, em vez de simplesmente esquecer de defini-la, que é o modo de falha muito mais comum.
noindex e Disallow não se somam
O noindex só impede a indexação se a página for rastreada. O Disallow
impede o rastreamento. Juntas na mesma URL, elas se cancelam.
Uma auditoria da baseline de rastreamento deste próprio site, rodada em 20 de
julho de 2026, encontrou um defeito real: a página /busca estava
simultaneamente marcada como noindex, follow na meta tag robots e
bloqueada por Disallow: /busca no robots.txt.
Parece redundante — duas formas de dizer "não quero isso na busca" — mas as duas
regras não se somam, elas se cancelam. O Disallow impede o rastreamento: o
Googlebot nunca baixa a página, logo nunca lê o noindex que está no HTML dela.
Se o Google descobre a URL por outro caminho, como um link externo, ele ainda
pode indexá-la — agora como um resultado somente-URL, sem título nem descrição
extraídos do conteúdo, porque nunca teve permissão de olhar o conteúdo.
| Objetivo | Use | Não use |
|---|---|---|
| tirar a URL do índice | noindex na página | Disallow para a mesma URL, que impede o noindex de ser lido |
| impedir o rastreamento da URL | Disallow no robots.txt | noindex, esperando que ele resolva sozinho o rastreamento |
| os dois na mesma URL | — | nunca; o Disallow vence por impedir que a outra diretiva seja lida |
A regra prática, sem exceção: noindex quando o objetivo é tirar a URL do
índice; Disallow apenas quando o objetivo é impedir o rastreamento por
completo. Nunca os dois na mesma URL.
O fix aqui foi remover o Disallow: /busca do robots.ts e manter apenas o
noindex na página — deixando o Googlebot rastrear, ler a diretiva e de fato
respeitá-la. É por isso que o bloco de código da seção anterior lista somente
/api/ no disallow.
Imagens, idiomas e sitemaps grandes
O campo images de uma entrada emite <image:image><image:loc> no namespace de
imagem do protocolo de sitemap. Neste site ele declara os diagramas de cada
post, sem precisar de um arquivo de sitemap de imagens separado:
const postImages: Record<string, string[]> = {
'melhorar-lcp-nextjs': ['/images/blog/melhorar-lcp-nextjs/lcp-subpartes-timeline.webp'],
}
// dentro do map dos posts:
images: postImages[frontmatter.slug]?.map((path) => `${base}${path}`),Dois detalhes que o exemplo da documentação não deixa explícito. O primeiro: as
URLs precisam ser absolutas, daí o ${base} na frente do caminho. O segundo: o
encadeamento opcional devolve undefined para posts sem imagem declarada, e o
Next.js simplesmente omite o campo — que é o comportamento desejado, diferente
de emitir uma lista vazia.
O campo alternates.languages emite
<xhtml:link rel="alternate" hreflang="…"> por entrada. Este site ainda não tem
nenhuma página em inglês publicada, então nenhuma entrada usa o campo hoje; ele
entra quando os pares pt-BR/en existirem, e o assunto tem artigo próprio no
guia.
Para sites grandes, generateSitemaps divide a saída em vários arquivos,
respeitando o limite de 50.000 URLs ou 50 MB descomprimidos por arquivo — o que
vier primeiro. A saída fica em caminhos como /product/sitemap/1.xml. Vale ser
direto: você provavelmente não precisa disso. Esse limite existe para sites
na casa das dezenas de milhares de URLs; abaixo disso, dividir o sitemap é
complexidade sem benefício mensurável.
O sitemap como fonte de verdade do teste
Esta é a inversão que a maioria dos artigos sobre sitemap no Next.js não cobre,
porque exige tratar o sitemap.ts não como uma saída, mas como uma entrada para
outra coisa.
A suíte de SEO deste site, escrita em Playwright, importa a função do sitemap diretamente e deriva dela a sua lista de rotas:
// tests/seo/seo.spec.ts
import sitemap from '../../src/app/sitemap'
const sitemapPaths = sitemap().map((entry) => new URL(entry.url).pathname)
const routes = [...sitemapPaths, ...noindexPaths]O efeito prático: toda rota que aparece no sitemap é automaticamente submetida
ao conjunto de asserções — exatamente um <h1>, título e descrição dentro dos
limites, canonical auto-referenciado, JSON-LD que parseia, meta robots com o
valor certo. Publicar um arquivo MDX não apenas adiciona a URL ao sitemap: adiciona
a URL a todo teste de SEO que existe, sem nenhuma linha de configuração.
Existe ainda um teste dedicado que confirma que o /sitemap.xml servido de fato
contém cada URL que a função enumera. Ele cobre um modo de falha específico: a
função está correta, mas o que chega ao visitante está velho — cache de CDN,
revalidação que não disparou, deploy parcial. Sem esse teste, o sitemap.ts
pode estar perfeito no código e ainda assim mentir em produção.
O mesmo padrão de derivar-do-conteúdo produz o feed.xml em
src/app/feed.xml/route.ts, declarado para autodiscovery via alternates.types
nos metadados. Não é o assunto deste artigo, mas é o mesmo princípio aplicado a
outro formato.
Como verificar que está funcionando
Nada acima vale alguma coisa se não for confirmado contra o comportamento real, em vez do que o código deveria fazer:
- Search Console, relatório de Sitemaps. Envie a URL do sitemap, ou
confirme que ela já foi descoberta pelo
robots.txt, e observe a contagem de URLs descobertas. Esse número reflete o que o Google leu do arquivo. - Abrir
/sitemap.xmle/robots.txtdireto no navegador. Confirma o que está sendo servido em produção — a versão manual do teste dedicado da seção anterior. - Rodar o crawl de baseline do repositório. Foi ele que encontrou o defeito
do
/busca. Rodar essa checagem depois de qualquer mudança emrobots.tsousitemap.tsé o que pega esse tipo de erro antes da produção. - Não confundir descoberta com indexação. Uma URL aparecer como descoberta garante que o Google sabe que ela existe, não que ela será indexada. A indexação depende de conteúdo, canonical e dos outros sinais que os demais artigos deste guia cobrem.
Este artigo mostrou o sitemap.ts e o robots.ts como estão em produção neste
domínio hoje, incluindo o erro que eu mesmo tinha cometido. Se você está montando
a primeira versão do seu, o guia de SEO técnico para Next.js
coloca esses dois arquivos no contexto dos outros tópicos do cluster. E se a
próxima dúvida for como o mesmo princípio de fonte única se aplica a dados
estruturados, o artigo sobre JSON-LD no Next.js percorre
a mesma suíte de testes pelo outro lado — com o
gerador de JSON-LD para quando você quiser o
schema pronto em vez da arquitetura por trás dele.