SEO Técnico

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.

CampoO Google usa?Vale declarar
urlsim — é a lista de URLs que existemsempre
lastModifiedsim, se for verificávelsó quando há data real derivável do conteúdo
changeFrequencynãonunca; não afeta nada
prioritynãonunca; 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

De onde vem cada URL do sitemapTrês colunas conectadas por setas: a primeira mostra a fonte de conteúdo, os arquivos MDX em content/blog e seu frontmatter; a segunda mostra a derivação, a função getAllPosts que lê o dateModified de cada arquivo; a terceira mostra a saída no sitemap, as tags loc, lastmod e image:loc. Uma quarta linha, em cinza tracejado, mostra a alternativa de uma lista de URLs mantida manualmente, sem derivação, que não se atualiza sozinha quando o conteúdo muda e por isso fica com lastmod ausente ou desatualizado.Fonte de conteúdoDerivaçãoSaída no sitemapcontent/blog/*.mdx (frontmatter)getAllPosts()lê dateModified<loc>, <lastmod>vindos do frontmatterDiagramasexportados do postpostImagesslug → arquivos<image:loc>no namespace de imagemAlternativa não usada neste site:Lista de URLsmantida à mãoNenhuma —a lista é digitadalastmod ausenteou desatualizado

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:

FonteDerivaçãoSaída no sitemapQuando o conteúdo muda
content/blog/*.mdx, frontmattergetAllPosts() lê o frontmatter de cada arquivo<loc> e <lastmod>, este vindo de dateModifiededitar o post atualiza o lastmod sozinho, porque a data está no próprio arquivo
diagramas exportados do postpostImages 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ãonenhuma; a lista é digitada<loc> sem lastmod confiávelnada 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 bloglastModified vem do dateModified do 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 o lastModified do dateModified mais 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 lastModified nenhum. 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

noindex vs. DisallowDuas linhas, uma por diretiva. Na linha do noindex, o Googlebot rastreia a página, lê a tag e não a indexa. Na linha do Disallow, o Googlebot nunca rastreia a página, e por isso ela ainda pode aparecer indexada como resultado somente-URL caso seja descoberta por um link externo. Uma terceira faixa, destacada na cor de acento, mostra o resultado de aplicar as duas diretivas à mesma URL: o Disallow impede o rastreamento, o noindex nunca é lido, e a URL pode acabar indexada sem título nem descrição.DiretivaO que o Googlebot fazResultadonoindex na páginarastreia a páginae lê a diretivanão é indexadaDisallow no robots.txtnunca rastreia a páginapode aparecer indexadavia link externoAs duas juntas, na mesma URLo Disallow impede o rastreamento, então o noindex nunca é lidoe a URL ainda pode indexar como resultado somente-URL, sem título nem descrição

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.

ObjetivoUseNão use
tirar a URL do índicenoindex na páginaDisallow para a mesma URL, que impede o noindex de ser lido
impedir o rastreamento da URLDisallow no robots.txtnoindex, esperando que ele resolva sozinho o rastreamento
os dois na mesma URLnunca; 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:

  1. 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.
  2. Abrir /sitemap.xml e /robots.txt direto no navegador. Confirma o que está sendo servido em produção — a versão manual do teste dedicado da seção anterior.
  3. Rodar o crawl de baseline do repositório. Foi ele que encontrou o defeito do /busca. Rodar essa checagem depois de qualquer mudança em robots.ts ou sitemap.ts é o que pega esse tipo de erro antes da produção.
  4. 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.

© 2026 Henrique Lopes Souza. Todos os direitos reservados. Citações curtas com atribuição e link para o artigo original são bem-vindas.

Perguntas frequentes

Declarar o sitemap no robots.txt, pela chave sitemap no retorno de robots.ts, já basta para o Googlebot descobri-lo. Enviar manualmente pelo Search Console não é obrigatório, mas acelera a primeira descoberta e dá acesso ao relatório de Sitemaps, que mostra quantas URLs foram lidas do arquivo — uma forma prática de confirmar que o Google está enxergando o que você espera.

Não para o Google: a documentação oficial afirma que os dois campos são ignorados. Declará-los não causa dano, mas também não influencia ranqueamento nem ordem de rastreamento. Se o objetivo é otimizar para o Google, o tempo rende mais garantindo que o lastmod seja verificável do que ajustando esses dois valores.

A data da última atualização significativa do conteúdo — mudança no texto principal, nos dados estruturados ou nos links da página — nunca a data do build ou do deploy. O Google só usa o campo se ele for consistente e verificável; new Date() a cada build produz uma data que nunca corresponde a uma mudança real, o que ensina o Google a ignorar o campo.

Confirme primeiro o caminho: no App Router o arquivo é app/sitemap.ts, e um sitemap.ts em pages/ ou fora de app/ não gera rota nenhuma. Depois confirme que a função default realmente retorna o array e que nada lançou erro durante o build, porque uma exceção na função derruba a geração da rota. Por fim, cheque se não existe um arquivo estático servindo o mesmo caminho a partir de public/.

Não. O sitemap declara quais URLs você quer que o Google rastreie e considere para indexação, então incluir uma URL noindex nele é um sinal contraditório: você pede atenção para uma página e, ao chegar nela, manda ir embora. Se a página não deve ser indexada, ela não deveria estar sendo enumerada pela função sitemap.

Condicione o conteúdo do robots.ts a uma variável de ambiente que só existe em produção, checando se ela vale exatamente a string esperada antes de permitir qualquer allow. Deploys de preview, branches e builds locais não têm essa variável definida, então caem no padrão Disallow: / automaticamente, sem configuração manual por deploy.