Engenharia

Como construímos este blog: Astro, SEO e deploy em subdiretório

As decisões técnicas por trás do blog da Iriry: /blog em subdiretório, Astro com schema como guardião de SEO, imagens OG no build e deploy em Docker.

Luiz Paulo CoutinhoPublicado em 6 min de leitura

Este blog é um site estático gerado com Astro e MDX, servido por um container Caddy numa VPS, atrás do Cloudflare, no caminho iriry.com/blog, enquanto o site institucional continua no Cloudflare Pages. O objetivo de negócio era um só: tráfego orgânico no domínio da empresa. Cada decisão técnica abaixo existe por causa dele, e várias foram registradas como ADRs no repositório.

O objetivo: autoridade no domínio da empresa

Um blog corporativo serve para duas coisas: atrair quem busca os problemas que a empresa resolve e dar autoridade ao domínio. As duas dependem de o blog somar forças com o site, e não competir com ele. Isso guiou a primeira decisão.

Por que /blog e não blog.

Subdomínio é o caminho fácil: um registro DNS apontando para outro servidor. Mas um subdomínio tende a ser tratado como um site à parte, e os backlinks que os posts recebem ficam lá. Em iriry.com/blog, tudo soma no mesmo domínio.

O problema: o site está no Cloudflare Pages e o blog roda numa VPS. A solução foi deixar o Cloudflare rotear por caminho. Uma regra encaminha /blog/* para a VPS, e o resto continua no Pages. Nenhum dos dois projetos precisou migrar.

Servir de um subdiretório tem consequências no build. No Astro:

// astro.config.mjs
export default defineConfig({
  site: "https://iriry.com",
  base: "/blog",
  trailingSlash: "always",
});

E no container, o Caddy serve os arquivos em /srv/blog, então o caminho da URL bate com o do disco sem reescrita:

:8080 {
  encode zstd gzip
  root * /srv
  redir /blog /blog/ 308

  @hashed path /blog/_astro/*
  header @hashed Cache-Control "public, max-age=31536000, immutable"

  file_server

  handle_errors 404 {
    rewrite * /blog/404.html
    file_server
  }
}

Um efeito colateral bom: como blog e site estão na mesma origem, eles compartilham o localStorage. O aviso de cookies usa a mesma chave nos dois, então quem aceitou no site não vê o aviso de novo no blog, e o GA4 é uma propriedade só.

Astro + MDX, e o schema como guardião de SEO

A referência que estudamos usa Hugo. Escolhemos Astro por um motivo concreto: MDX. Queríamos componentes dentro dos textos, como o CTA contextual, e o Hugo não suporta MDX. O Astro também entrega zero JavaScript por padrão, o que ajuda nos Core Web Vitals.

O recurso que mais vale, porém, são as content collections com schema. O frontmatter de todo post é validado no build:

// src/content.config.ts (trecho)
const seo = {
  title: z.string().min(10).max(70),
  description: z.string().min(70).max(160),
  keyword: z.string(),
};

const posts = defineCollection({
  loader: glob({ pattern: "**/[^_]*.{md,mdx}", base: "./src/content/posts" }),
  schema: ({ image }) =>
    z.object({
      ...seo,
      pubDate: z.coerce.date(),
      autor: reference("autores"),
      categoria: z.enum([...PILAR_IDS, "engenharia"]),
      pilar: z.enum(PILAR_IDS).optional(),
      // ...
    }).refine((p) => p.categoria !== "engenharia" || p.pilar, {
      message: "Post de Engenharia precisa de `pilar`",
      path: ["pilar"],
    }),
});

Na prática, um post sem description, com título longo demais para o Google ou sem autor não chega a ser publicado: o build falha no PR. As regras editoriais deixam de depender de alguém lembrar delas. O refine garante outra regra da nossa estratégia: todo post técnico aponta para um dos pilares de negócio, que é para onde vai o link interno e o CTA.

Posts agendados num site estático

Site estático não tem servidor decidindo o que mostrar agora. Um post com data no futuro só aparece num build posterior. A solução tem duas partes. O filtro de publicação:

function isLive(data: { draft: boolean; pubDate: Date }) {
  if (SHOW_DRAFTS) return true;
  return !data.draft && data.pubDate.getTime() <= Date.now();
}

E um cron no GitHub Actions que refaz o build e o deploy todo dia às 6h de Brasília:

on:
  push:
    branches: [main]
  schedule:
    - cron: "0 9 * * *"

Escrever, revisar, fazer merge com a data certa, e o post entra no ar sozinho.

Dados estruturados ligados ao site

O site já declara a empresa em JSON-LD, com um @id estável. O blog não repete a empresa: referencia o mesmo @id. Cada página do blog emite um grafo com Blog, BlogPosting (ou Article nos guias), Person para o autor e BreadcrumbList, e todos apontam para a Organization do site como publisher. Para os buscadores, blog e site são uma entidade só.

Por que trocamos o plugin de sitemap

Começamos com o plugin oficial de sitemap e trocamos por um endpoint próprio por dois motivos:

  1. Ele não preenchia lastmod com a data real de atualização dos posts.
  2. Ele incluía páginas marcadas como noindex, como tags e categorias ainda vazias. Mandar para o Google páginas que você pede para não indexar é um sinal contraditório.

O endpoint próprio tem cerca de 40 linhas. Ele percorre posts, guias, categorias com conteúdo e autores e usa updatedDate ?? pubDate como lastmod.

Imagens de compartilhamento geradas no build

Cada post ganha uma imagem Open Graph de 1200×630 gerada no build com Satori e Resvg, usando as cores e fontes da marca. Duas pegadinhas apareceram:

  • Satori não lê woff2. As fontes do site são variáveis em woff2. Convertemos para TTF estático com o instancer do fontTools, fixando o peso usado em cada uma.
  • Centralização com padding. Com padding horizontal no contêiner, o texto centralizado saía deslocado para a direita, na mesma medida do padding. Tiramos o padding horizontal e demos largura fixa ao bloco do título.

Design copiado, não compartilhado

O blog segue o design system do site, que é HTML e CSS puros, sem build. Compartilhar o CSS em produção acoplaria os deploys e traria regras que o blog não usa. A saída foi um script que copia do CSS do site só as seções que o blog usa, identificadas pelos comentários de seção:

const KEEP = ["Botões", "Header", "Footer", "WhatsApp flutuante", "Aviso de cookies", "Animações de entrada", "Páginas legais"];

O arquivo gerado nunca é editado à mão. Se o design do site mudar, um comando atualiza o blog, e o script falha se uma seção esperada sumir.

O pipeline e o resultado

Todo PR passa por checagem de tipos, lint do Markdown, build, um verificador de links internos de 30 linhas e o Lighthouse CI, que reprova se SEO, acessibilidade ou boas práticas ficarem abaixo de 95. No merge, o GitHub Actions gera a imagem Docker (Node para o build, Caddy para servir), publica no GitHub Container Registry e atualiza a VPS por SSH.

No primeiro build completo, a home e a página de autor marcaram 100 em desempenho, acessibilidade, boas práticas e SEO no Lighthouse. O mérito é menos de alguma otimização esperta e mais de não colocar nada que atrapalhe: HTML estático, fontes locais com preload, quase nenhum JavaScript.

O que faríamos diferente

  • Busca. Usamos a interface padrão do Pagefind. Na versão 1.5, a recomendação para novos projetos passou a ser a interface de componentes. Vale migrar quando a busca ganhar importância.
  • Design copiado tem prazo de validade. A cópia é a escolha certa para dois projetos, mas se surgir um terceiro, um pacote de tokens compartilhado passa a compensar.
  • Cron do GitHub. Workflows agendados são pausados depois de 60 dias sem atividade no repositório. Com quatro posts por mês isso não acontece, mas é o tipo de detalhe que derruba a publicação agendada sem aviso.

As decisões que fecham ou abrem caminhos estão registradas como ADRs, junto com um glossário dos termos editoriais. Num projeto que vai durar anos, o porquê de cada escolha vale tanto quanto o código. Para a visão de negócio de quando vale construir algo sob medida, veja o guia de software sob medida.

Guia completoSoftware sob medida: guia para empresas decidirem e contratarem
Luiz Paulo CoutinhoFundador e desenvolvedor responsável da Iriry

Mais de 5 anos em desenvolvimento de software, dados e automação. Acompanha cada projeto da Iriry do primeiro contato à entrega.