Content collections, busca sem servidor e capas geradas no build
Publicar um post virou criar um arquivo. A busca roda inteira no navegador, sem backend e sem serviço pago. E cada artigo ganha um card social gerado automaticamente, sem eu abrir editor de imagem.

Performance Baseline
- Páginas indexadas na busca
- 11
- JavaScript de framework
- 0 KB
Com a estrutura em pé, faltava o que o site existe para servir: conteúdo. Três peças precisavam funcionar sem servidor — publicação, busca e prévia social.
Conteúdo como arquivo, validado no build
As content collections do Astro resolvem publicação com Markdown, mas o que importa mesmo é o schema. Cada coleção declara a forma do frontmatter, e o build falha se algum campo estiver errado.
import { defineCollection, reference } from 'astro:content';
import { glob } from 'astro/loaders';
import { z } from 'zod';
const blog = defineCollection({
loader: glob({ base: './src/content/blog', pattern: '**/*.mdx' }),
schema: ({ image }) =>
z.object({
titulo: z.string(),
resumo: z.string(),
data: z.coerce.date(),
categoria: z.string(),
tags: z.array(z.string()).default([]),
descricaoSeo: z.string().max(160).optional(),
capa: image().optional(),
rascunho: z.boolean().default(false),
}),
});
const diario = defineCollection({
loader: glob({ base: './src/content/diario', pattern: '**/*.mdx' }),
schema: ({ image }) =>
z.object({
// Referência validada: apontar para projeto inexistente quebra o build.
projeto: reference('projetos'),
titulo: z.string(),
data: z.coerce.date(),
metricas: z
.array(
z.object({
rotulo: z.string(),
valor: z.string(),
percentual: z.number().min(0).max(100),
})
)
.default([]),
}),
});Dois detalhes que economizam tempo depois:
O reference('projetos') liga esta entrada de diário ao projeto e valida no
build. Se eu renomear um projeto e esquecer de atualizar as entradas, o build
avisa em vez de gerar uma página órfã.
O descricaoSeo com .max(160) impede que eu escreva uma meta description que
o Google vai cortar no meio. O limite vira erro de build, não algo para lembrar.
O rascunho que evitou um problema real
O campo rascunho: true esconde o conteúdo em produção mas mantém em
desenvolvimento:
const incluirRascunhos = import.meta.env.DEV;
function visivel(entrada: { data: { rascunho: boolean } }): boolean {
return incluirRascunhos || !entrada.data.rascunho;
}Isso nasceu como conveniência e virou salvaguarda. Os layouts vieram com posts de exemplo inventados — artigos sobre Kafka, projetos de kernel em Rust, experiências em empresas onde eu nunca trabalhei. No momento em que o site passou a ter meu nome real, esse texto deixou de ser placeholder inofensivo e virou afirmação falsa sobre mim.
Marcar tudo como rascunho resolveu na hora: eu seguia revisando o layout com conteúdo na tela, e o build de produção saía sem nenhuma alegação que eu não pudesse sustentar.
Busca client-side com Pagefind
Busca costuma ser o motivo pelo qual um site estático deixa de ser estático. Algolia cobra, Elasticsearch pede servidor, e ambos são desproporcionais para um blog pessoal.
O Pagefind resolve invertendo o problema: em vez de consultar um índice remoto, ele gera o índice a partir do HTML já construído e o navegador baixa só os fragmentos que a consulta precisa.
{
"scripts": {
"build": "astro build && pagefind --site dist"
}
}A integração no markup é um atributo. data-pagefind-body no <main> marca o
que indexar; data-pagefind-ignore no header e no rodapé evita que o menu
apareça como resultado em toda página.
Hoje são 11 páginas e 2.576 palavras indexadas. O índice fica em dist/pagefind
e é servido como qualquer outro arquivo estático — sem processo rodando, sem
mensalidade.
Capas sociais geradas no build
Link sem imagem tem menos clique no LinkedIn e no WhatsApp. Mas abrir editor de imagem a cada post é atrito que garante que eu vou parar de fazer.
Resolvi com um endpoint que gera PNG em tempo de build, usando satori para transformar layout em SVG e resvg para rasterizar:
export const getStaticPaths = (async () => {
const posts = await getPosts();
return [
...posts.map((post) => ({
params: { slug: post.id },
props: { titulo: post.data.titulo, categoria: post.data.categoria },
})),
{ params: { slug: 'default' }, props: { titulo: site.descricao } },
];
}) satisfies GetStaticPaths;
export const GET: APIRoute = async ({ props }) => {
const png = await gerarCardSocial({
titulo: props.titulo as string,
categoria: props.categoria as string | undefined,
});
return new Response(new Uint8Array(png), {
headers: { 'Content-Type': 'image/png' },
});
};Um tropeço no caminho: o satori não lê woff2, que é o único formato que os
pacotes @fontsource-variable fornecem. Tive que versionar dois arquivos TTF em
src/assets/fonts/og/ só para a geração das capas. Custou 15 minutos de
confusão até eu ler a mensagem de erro com atenção.
O número que eu mais queria ver
O bundle JavaScript em dist/_astro ficou em 0 KB. Não há framework no
cliente. O único JavaScript do site são 2,8 KB inline, divididos entre o menu
mobile, o botão de copiar código e o formulário que abre o WhatsApp.
A home tem 22,4 KB de HTML, que viram 5,3 KB depois do gzip. Isso cabe em um único pacote TCP.