Pular para o conteúdo
Sunstone Apps
← Voltar para o blog
Astro e Cloudflare Pages

Datas do blog com um dia a menos: o bug de timezone que afeta todo site Astro

O título do post dizia "Publicado: 10 de julho", mas a página mostrava "9 de julho". O servidor Astro rodava em UTC-3, e new Date("2026-07-10T00:00:00Z") meia-noite UTC vira 9 de julho às 21h no Brasil. A correção parecia ser uma linha, mas o problema tinha três camadas.

Publicado: 6 min de leitura
Imagem de prévia OpenGraph deste artigo. Datas do blog com um dia a menos: o bug de timezone que afeta todo site Astro

O bug apareceu numa sessão de revisão visual do blog. Um post publicado em 10 de julho aparecia como 9 de julho na página. Verifiquei outro. Mesmo problema. Todos os 46 posts do site estavam com a data deslocada em um dia.

A causa: new Date com string UTC vs timezone do servidor

O frontmatter de cada post tinha uma data simples:

publishedAt: "2026-07-10"

O código de formatação fazia:

const dateFormatter = new Intl.DateTimeFormat("pt-BR", {
  day: "numeric",
  month: "short",
  year: "numeric",
});
const publishedDate = dateFormatter.format(new Date(`${entry.data.publishedAt}T00:00:00Z`));

A string "2026-07-10T00:00:00Z" cria um objeto Date em meia-noite UTC. O Intl.DateTimeFormat sem timeZone usa o fuso horário do ambiente onde o código executa. Em desenvolvimento local (macOS em UTC-3), meia-noite UTC vira 21h do dia anterior. O resultado: 9 de julho na tela, 10 de julho no frontmatter.

Em produção na Cloudflare Pages, o build roda em UTC, então o bug não aparecia no deploy. Mas qualquer desenvolvedor rodando pnpm sites:dev no Brasil via datas erradas.

Primeira correção: timeZone: "UTC"

A correção imediata foi adicionar timeZone: "UTC" em cada Intl.DateTimeFormat:

const dateFormatter = new Intl.DateTimeFormat(locale, {
  day: "numeric",
  month: "short",
  year: "numeric",
  timeZone: "UTC",
});

Três componentes tinham o formatter: BlogPostPage.astro, BlogIndexPage.astro e LandingPage.astro. Todos no mesmo site (sunstoneapps.com). Os outros dois sites do monorepo não têm blog, então não precisaram mudança.

O problema maior: ordenação por localeCompare

Com o fix de timezone, as datas apareciam corretas. Mas havia um problema estrutural que valia resolver no mesmo ciclo.

A ordenação dos posts usava localeCompare em strings de data:

.sort((left, right) =>
  right.data.publishedAt.localeCompare(left.data.publishedAt),
);

Isso funciona para datas no formato YYYY-MM-DD porque a ordenação lexicográfica coincide com a cronológica. Mas quebra em dois cenários:

  1. Se duas datas usarem sufixos de timezone diferentes ("2026-07-10T00:00:00Z" vs "2026-07-10T00:00:00-03:00"), a comparação de string não reflete a ordem cronológica real.
  2. Não permite ordenar posts publicados no mesmo dia — todos empatam em localeCompare.

Migração para ISO 8601 com timestamp

A solução foi converter todos os 46 frontmatters para ISO 8601 com timestamp:

publishedAt: "2026-07-10T00:00:00Z"

E mudar a ordenação para usar Date.getTime():

.sort(
  (left, right) =>
    new Date(right.data.publishedAt).getTime() -
    new Date(left.data.publishedAt).getTime(),
)

Agora, para ordenar dois posts do mesmo dia, basta ajustar o timestamp: T08:00:00Z aparece antes de T12:00:00Z.

As concatenações que quebraram

O código tinha cinco lugares que concatenavam T00:00:00Z na data do frontmatter antes de passar para new Date():

new Date(`${post.data.publishedAt}T00:00:00Z`);

Com a migração para ISO, isso produzia "2026-07-10T00:00:00ZT00:00:00Z" — uma string inválida que resulta em Invalid Date. Todas as cinco concatenações foram removidas, passando a usar new Date(date) diretamente.

O RSS feed também usava a concatenação para gerar <pubDate>. A correção foi a mesma: new Date(post.data.publishedAt).toUTCString().

O ID canônico do post

O monorepo tem uma função canonicalBlogEntryId que monta o ID do post a partir da data e do slug:

function canonicalBlogEntryId(entry: BlogPost): string {
  return `${entry.data.publishedAt}-${entry.data.slug}.${entry.data.locale}`;
}

O ID do arquivo gerado pelo Astro é 2026-07-10-slug.pt-BR (só a data, sem timestamp). Com a migração, publishedAt passou a ser "2026-07-10T00:00:00Z", e o ID montado ficaria 2026-07-10T00:00:00Z-slug.pt-BR — não batia com o ID do arquivo.

A correção foi extrair só a parte da data:

function canonicalBlogEntryId(entry: BlogPost): string {
  const datePart = entry.data.publishedAt.split("T")[0];
  return `${datePart}-${entry.data.slug}.${entry.data.locale}`;
}

O nome do arquivo continua usando apenas YYYY-MM-DD, como antes.

Validação no schema

O schema Zod da coleção de blog foi atualizado de z.string() para z.string().datetime():

publishedAt: z.string().datetime(),
updatedAt: z.string().datetime().optional(),

Se alguém digitar "2026-07-10" sem timestamp, o Astro agora rejeita na validação do content collection em vez de produzir datas erradas silenciosamente.

O que ficou como regra

Três princípios ficaram documentados no AGENTS.md do monorepo:

  • Frontmatter de blog usa ISO 8601 com timestamp ("2026-07-11T00:00:00Z"), validado por z.string().datetime().
  • O nome do arquivo usa apenas a data (2026-07-11-slug.md).
  • Formatação com Intl.DateTimeFormat(locale, { timeZone: "UTC", ... }) e new Date(publishedAt) — nunca concatenar T00:00:00Z.
  • Ordenação por new Date(a).getTime() - new Date(b).getTime(), não localeCompare.

A regra cobre o cenário que motivou o bug original (fuso do servidor diferindo de UTC) e o cenário que motivou a migração completa (necessidade de ordenar posts do mesmo dia). Qualquer desenvolvedor, em qualquer fuso, vê as datas corretas.

Esse bug também tem um lado de SEO: o article:published_time no JSON-LD e nas meta tags OpenGraph usava o mesmo publishedAt, então o Google lia uma data diferente da intencionada. Para o checklist completo de indexação e metadados em sites Astro no Cloudflare Pages, veja Checklist de indexação no Google para sites Astro no Cloudflare Pages.

Resumo prático

Antes Depois
publishedAt: "2026-07-10" publishedAt: "2026-07-10T00:00:00Z"
new Date(\${date}T00:00:00Z`)` new Date(date)
localeCompare para ordenar Date.getTime() para ordenar
z.string() no schema z.string().datetime() no schema
Sem timeZone no formatter timeZone: "UTC" no formatter

O bug existia desde o primeiro post do blog. Nunca foi detectado em produção porque a Cloudflare builda em UTC. Mas qualquer pessoa rodando o site localmente no Brasil via datas erradas — e isso inclui toda a equipe.

Falar com a Sunstone Apps