Construindo Este Portfólio
Sem backend. Sem serviço de banco de dados. Sem conta de CMS. Este site roda uma
experiência rica em conteúdo, totalmente pesquisável e bilíngue a partir de um arquivo SQLite
gerado em tempo de build e enviado dentro do bundle. Não há nada para manter acordado, nada
para escalar e nada a pagar além de uma hospedagem quase estática — e ainda assim ele se
comporta como se tivesse um servidor.
Essa última frase é o pitch inteiro, e ela corta para os dois lados. Para um negócio,
significa um site de marketing-e-portfólio rico em conteúdo com o custo de operação de uma
página estática, sem lock-in de fornecedor e com o conteúdo vivendo no git em vez do dashboard
de outra pessoa. Para um engenheiro, significa busca full-text de verdade, filtragem por
facetas e i18n sem nenhuma API para operar. Este post é o tour: um passeio recurso a recurso
pelo site, e a história da peça que precisei construir para tornar tudo possível.
Porque aqui está a parte honesta: esta arquitetura não existia de prateleira. Para ter um banco
que vai junto no build, eu tive que escrever os motores que o produzem. Esse toolkit é o
Indago, e agora é open source. Este artigo explica o
portfólio; o post companheiro,
Começando com o Indago, explica os
motores — o que
são e como funcionam por baixo dos panos. Vou linkar para as seções dele conforme aparecem, em
vez de repeti-las aqui.
A grande ideia: um banco de dados que vai junto no build
O truque central é este: o conteúdo vive como arquivos comuns no repositório —
Markdown/MDX para prosa, JSON para dados estruturados — e o build compila esses
arquivos em um banco SQLite e num conjunto de módulos tipados. Em tempo de
execução, os loaders do servidor consultam esse arquivo SQLite diretamente com
bun:sqlite (ou node:sqlite no Node 22+, que é o que a Vercel roda). Nada é
buscado de um serviço externo.
Por que usar um banco, em vez de só importar JSON? Porque os artigos e receitas
precisam de
busca full-text, filtragem por facetas, ordenação e paginação — e
fazer isso sobre um array em memória não continua rápido conforme o conteúdo cresce.
O
FTS5 do SQLite me dá um índice invertido de verdade, e como o índice é
contentless (sem conteúdo), o banco fica pequeno: ele guarda os tokens pesquisáveis
e os metadados do frontmatter, mas
nunca o corpo do artigo. (Esse modelo de
armazenamento é o mergulho profundo do post do motor — veja
o índice invertido contentless.)
O resultado é um site que parece ter um backend — busca ao vivo que responde a cada
tecla no servidor — com o perfil operacional, e a conta, de um site estático.
A stack num relance
| Camada | Escolha |
|---|
| Framework | Vike (vike + vike-react + vike-server) — híbrido SSG/SSR |
| UI | React 19 |
| Servidor | Hono via @vikejs/hono |
| Runtime | Bun em dev/Docker; Node 22 na Vercel |
| Estilo | Tailwind CSS v4 + componentes estilo shadcn + ícones lucide |
| Conteúdo em prosa | @indago/hyper-down — MDX → SQLite FTS5 (apenas SSR) |
| Dados estruturados | @indago/hyper-json — JSON Schema → imports tipados |
| i18n | i18next + react-i18next, roteamento que remove o prefixo |
| Lint / format | OXC (oxlint + oxfmt) — não é ESLint nem Biome |
| Testes | Vitest (unit + integridade de conteúdo) e Playwright (e2e) |
| Deploy | Vercel (Build Output API) ou Docker (SSR self-hosted) |
Duas coisas nessa lista são minhas: os motores de conteúdo. Todo o resto é
de prateleira, conectado de propósito.
Roteamento e i18n bilíngue
O site é totalmente bilíngue — inglês e português do Brasil — e a estratégia de
i18n é remoção de prefixo de locale. O locale padrão (en) não tem prefixo,
então a home é /, os artigos ficam em /articles, e assim por diante. O português
vive sob /pt: /pt/articles, /pt/cooking, etc.
Um hook +onBeforeRoute do Vike remove o prefixo /pt antes do roteamento, define
pageContext.locale e calcula um urlLogical contra o qual o resto da aplicação
roteia. A parte sutil — e a origem de um bug que só peguei com um teste e2e — é que
o urlLogical precisa manter a query string e o hash. O Vike reparseia a URL a
partir desse valor lógico, então uma versão só com o pathname esvazia silenciosamente
todo loader baseado em busca. A correção é montá-lo como
pathnameSemLocale + search + hash.
TypeScript
1
2
3
4
5
6
7
8
9
const { urlWithoutLocale, locale } = extractLocale(pageContext.urlParsed);
return {
pageContext: {
locale,
urlLogical: urlWithoutLocale + searchOriginal + hashOriginal,
},
};
Há um detalhe equivalente no cliente: o helper de navegação por search params tem
que montar o destino a partir de window.location.pathname (que tem o prefixo de
locale), não do pageContext.urlPathname (que já teve o locale removido) — senão um
visitante de /pt é jogado de volta para a versão em inglês no primeiro clique de
filtro.
Dois motores de conteúdo, uma pasta de conteúdo
Todo o conteúdo vive sob content/, dividido por tipo, depois por locale:
text
1
2
3
4
5
6
7
8
9
10
11
12
content/
├── article/ HyperDown (MDX)
│ ├── en/*.mdx
│ └── pt-BR/*.mdx
├── recipe/ HyperDown (MDX)
│ ├── en/*.mdx
│ └── pt-BR/*.mdx
├── projects/ HyperJson (JSON + schema)
│ ├── schema.json
│ └── en/projects.json
├── profile/ skills/ education/ languages/
├── music/ photography/
A divisão é pelo
formato do conteúdo. O
HyperJson cuida dos dados
estruturados — qualquer coisa que seja uma lista de registros com formato fixo
(projetos, skills, playlists, álbuns de fotos). O
HyperDown cuida da prosa —
qualquer coisa com um corpo que você queira ler e pesquisar (artigos, receitas).
Eles não compartilham código nem dependência; a única coisa que compartilham é o
diretório
content/ e a convenção de subpastas por locale. O post do motor cobre
por que dois motores
por completo; aqui eu só mostro como o
site usa cada um.
Conteúdo estruturado com o HyperJson
Um tipo de conteúdo do HyperJson é uma pasta com um schema.json e arquivos de dados
por locale. Em tempo de build, cada arquivo de dados é validado contra seu schema —
uma chave desconhecida ou um tipo errado quebra o build — e tipos TypeScript são
gerados a partir do schema, para que todo import seja totalmente tipado:
TypeScript
1
2
import projects from "@content/projects/en/projects.json";
Essa única garantia — conteúdo inválido não pode ir para produção, e conteúdo
válido chega tipado — é o que sustenta toda a metade estática do site: a seção
Sobre, as grades de Projetos e Skills, os blocos de Educação e Idiomas, as playlists
de Música e os álbuns de Fotografia leem direto de JSON tipado. Por cima dos imports
tipados, o HyperJson traz hooks headless (useFilter, useSearch, useSort,
usePaginate e um useComposed que encadeia os quatro) para moldar esses dados no
React — a página de Música os usa para filtrar playlists por gênero e buscar por
título/artista inteiramente no cliente, sem impor nenhuma UI.
Prosa e receitas com o HyperDown
O HyperDown segue o caminho oposto: compila cada arquivo Markdown/MDX em um banco
SQLite com um índice FTS5 contentless, consultado apenas no servidor. Para o site, a
superfície importante é o repositório gerado, tipado e exclusivo de servidor que cada
tipo de conteúdo ganha:
TypeScript
1
2
3
4
5
6
7
8
const { results, totalCount, totalPages } = await articleRepository.search({
locale,
searchQuery,
filters: activeTag ? { tag: activeTag } : {},
sort: { sortBy: "date", sortDir: "desc" },
pagination: { page, pageSize: 9 },
});
Os tipos de conteúdo são declarados uma vez em
frontmatter.json (o formato do
FrontMatter CMS, então o site é editável pelo painel do FrontMatter no VS Code), que é
a fonte única da verdade tanto para o schema do SQLite quanto para os tipos TypeScript
gerados. Dois campos opcionais que adicionei ali —
prev e
next — movem a série de
artigos, e um método
related() move o conteúdo sugerido; os dois aparecem abaixo. A
mecânica de como esse corpo nunca chega ao armazenamento, e de como o índice FTS é
ajustado para ficar pequeno, é o coração do post do motor — veja
o ciclo de vida de uma requisição
e o mergulho no índice por lá. O que importa para o site é o retorno na próxima seção.
A experiência de Artigos
A lista de artigos é a vitrine de toda a arquitetura, porque é busca ao vivo no
servidor. A página de listagem define prerender: false, então sob o servidor
Hono cada mudança de URL roda o loader de novo no servidor e devolve uma página
recém-consultada. Todo o estado vive na URL — q, tag, page, sort, dir — o
que significa que todo conjunto de resultados é compartilhável e favoritável, e o
botão "voltar" simplesmente funciona.
Recursos da listagem:
- Busca full-text sobre títulos, descrições e corpos dos artigos, com casamento
por prefixo (digitar
hyper casa com HyperDown). A query é debounced no cliente
e empurrada para a URL; o servidor faz o FTS de fato.
- Facetas de tags construídas a partir da distribuição real de tags no banco
(
distinctValues, ordenadas por frequência), com um "ver mais".
- Ordenação por data ou título, ascendente ou descendente.
- Paginação com uma contagem total exata, calculada na mesma query.
A página de detalhe (/articles/@slug) é o oposto — totalmente pré-renderizada
para HTML estático. Todo slug é enumerado em tempo de build e renderizado,
incluindo seu gêmeo /pt. Ela mostra a capa, a barra de meta (autor, data, tempo de
leitura, um link canônico se a peça foi publicada em outro lugar), o corpo MDX
renderizado e os chips de tag que linkam de volta para a lista filtrada.
Auxílios de leitura: minimapa de TOC e hash scroll
Posts técnicos longos precisam de navegação, então a página de detalhe tem dois
auxílios de leitura que deram mais trabalho do que parecem.
O PageMinimap renderiza um espelho clicável e reduzido do artigo na lateral — um
índice cujo formato você consegue enxergar. O detalhe: ele é um clone literal do DOM
do artigo, então duplicaria todo id de cabeçalho. A navegação por hash então
pularia para a cópia espelho. A correção é remover todo id descendente do clone,
deixando os ids únicos no artigo real.
O comportamento de hash scroll trata os links #secao no índice. O Vike
intercepta cliques em <a href="#…"> via pushState, então um handler normal nunca
os vê. A solução é um listener de clique em fase de captura que pega os cliques
do índice antes do Vike e rola suavemente até o alvo.
Os dois comportamentos são cobertos por specs do Playwright, justamente por serem o
tipo de coisa que quebra silenciosamente num upgrade de framework.
Navegação em série e conteúdo sugerido
Dois recursos transformam uma pilha de artigos numa experiência de leitura guiada.
A navegação em série transforma artigos relacionados num caminho de leitura
explícito e ordenado — uma lista duplamente encadeada expressa inteiramente no
frontmatter. Cada artigo pode declarar um prev e um next opcionais:
yaml
1
2
3
4
5
next: "getting-started-with-indago"
prev: "building-this-portfolio"
O loader de detalhe resolve esses slugs nos seus metadados e renderiza um paginador
anterior/próximo no rodapé do artigo. É totalmente opt-in: um artigo sem prev/next
(como o meu mergulho sobre SOM) simplesmente não mostra paginador.
O conteúdo sugerido é a contraparte automática. No rodapé de todo artigo e
receita, o site mostra até três itens relacionados — e o ranqueamento é feito pela
ordem das tags. As tags do item atual são tratadas como uma lista de prioridade:
candidatos que compartilham a primeira tag preenchem as vagas primeiro, depois a
segunda tag complementa até três, e assim por diante. Adicionei isso como um método
related() de primeira classe no HyperDown, então ele roda como uma única query SQL
indexada contra a bridge de tags, ranqueada com um MIN(CASE …) sobre as posições
das tags casadas:
TypeScript
1
2
3
4
5
6
const suggestions = await articleRepository.related({
slug,
tags: article.tags,
locale,
limit: 3,
});
Como o ranqueamento se ancora no artigo que você está lendo, as sugestões permanecem
genuinamente relevantes, em vez de serem uma faixa genérica de "posts recentes".
Cozinha, Fotografia, Música e Links
A mesma maquinaria sustenta o resto do site, e esse é o ponto — uma vez que os
motores existem, cada seção sai barata.
- Cozinha espelha Artigos, mas para receitas: a listagem tem facetas de
culinária, tipo de refeição e tipo de prato (cada uma uma coluna real no SQLite),
além de busca e paginação. As páginas de detalhe de receita renderizam o modo de
preparo e as listas de ingredientes em MDX, e ganham a mesma faixa de conteúdo
sugerido ranqueado por tag no rodapé.
- Fotografia lê álbuns de JSON tipado (HyperJson) e os dispõe como uma galeria;
as imagens vivem em
public/photos.
- Música é a melhor vitrine dos hooks headless — playlists e favoritos de JSON,
filtrados por gênero e buscados ao vivo no cliente com
useComposed.
- Links é uma página compacta estilo linktree para links sociais e de contato,
também movida a conteúdo em vez de markup hardcoded.
O pipeline de renderização MDX
Os corpos de artigo e receita são MDX de verdade, então podem conter JSX, e o
pipeline de renderização é afinado para escrita técnica. O componente MdxRender
(do HyperDown) renderiza o corpo carregado preguiçosamente com um fallback de
Suspense, e a cadeia de plugins adiciona:
rehype-highlight (highlight.js) para blocos de código com realce de
sintaxe — carregado só nas páginas que de fato renderizam MDX, para mantê-lo fora
do caminho crítico de toda outra página.
rehype-katex + remark-math para matemática em LaTeX, para que eu possa
escrever argmin/Σ/integrais no post sobre SOM.
remark-gfm para tabelas, listas de tarefas e texto riscado.
- mermaid para diagramas inline (os fluxogramas e diagramas de sequência destes
posts) renderizados a partir de blocos de código cercados.
O CSS de código e matemática (github-dark e katex.min.css) é importado no nível
da página de detalhe, e não globalmente, então a home nunca paga por ele.
SEO, Open Graph e o sitemap
Como as páginas de detalhe são pré-renderizadas, elas são HTML estático totalmente
rastreável com metadados de verdade. Cada artigo emite suas próprias tags de Open
Graph e Twitter card — og:title, og:description, og:image (a capa),
article:published_time e uma article:tag por tag — além dos links canônicos e
hreflang cientes de locale vindos do head raiz.
O sitemap é gerado em tempo de build pelo plugin de sitemap do HyperDown a partir
de um bloco declarativo no hyperdown.config.json: rotas estáticas com suas
prioridades, mais uma entrada por item de conteúdo nos dois locales. Ele escreve
direto em public/sitemap.xml, então os buscadores recebem um mapa preciso a cada
build sem que eu o mantenha à mão.
Estratégia de prerender: o que é estático, o que é ao vivo
A divisão híbrida SSG/SSR é deliberada e por rota:
| Rota | Modo | Por quê |
|---|
/, seções | Pré-renderizada (SSG) | Conteúdo é estático; entregue HTML puro. |
listagens /articles, /cooking | SSR ao vivo (prerender: false) | Busca/filtro precisam rodar por requisição. |
/articles/@slug, /cooking/@slug | Pré-renderizada (SSG) | Todo slug é conhecido no build; renderize uma vez. |
Globalmente a aplicação roda com prerender: { partial: true }, então a maior parte
do site é HTML estático, enquanto as duas rotas de listagem voltam para SSR. Manter
as listagens como SSR também é o que mantém um bundle de servidor real na saída, que
o adaptador Hono então serve.
Deploy: Vercel e Docker
O mesmo build mira dois lares bem diferentes.
Na Vercel, um plugin (vite-plugin-vercel, habilitado só quando a Vercel define
VERCEL=1) reescreve o build no layout da Build Output API sob .vercel/output/.
As funções SSR rodam no Node 22, que é exatamente a razão de o cliente SQLite ser
escrito para cair de bun:sqlite para node:sqlite — mesmo código, dois runtimes.
Os arquivos .db gerados são copiados para o bundle da função para que os loaders
consigam lê-los na borda da requisição.
Para self-hosting, um build comum produz um servidor SSR Hono executável, e o
Dockerfile / docker-compose.yml incluídos o empacotam para que bun run start
sirva tudo a partir de um único container. Sem banco de dados externo, porque o banco
já está dentro da imagem.
Portões de qualidade
Nada vai para produção sem passar por quatro portões, nesta ordem:
oxlint + oxfmt — o projeto está no OXC, não no ESLint nem no Biome.
Rápido o bastante para rodar a cada save.
tsc --noEmit — TypeScript estrito por toda a aplicação, incluindo os tipos
de conteúdo gerados, então uma mudança de schema que quebra um consumidor falha
aqui.
- Vitest — testes unitários mais uma suíte de integridade de conteúdo que
parseia cada arquivo de conteúdo e garante que as coleções não estão vazias e
estão bem tipadas.
- Playwright e2e — os comportamentos que quebram silenciosamente: busca ciente
de locale, hash scroll, preservação da posição de scroll, a remoção de ids do
minimapa.
Os testes de integridade de conteúdo e e2e são os que valem o seu peso: pegam os
modos de falha que os tipos sozinhos não pegam, como um loader de busca que devolve
nada porque uma URL foi parseada sem a query string.
O que eu mandaria você copiar
Se você levar uma ideia disto: você provavelmente não precisa de um backend para
um site de conteúdo. Compile seu conteúdo em um artefato indexado em tempo de
build, consulte-o a partir dos loaders do servidor, e você ganha busca e filtragem
de verdade sem nenhum peso operacional — e sem o custo recorrente ou o lock-in de
fornecedor que vêm com um CMS hospedado. O conteúdo fica no git, próprio e
versionado, e o site faz deploy em qualquer lugar quase estático.
E você não precisa reconstruir nada disso do zero. Os dois motores que tornam isso
ergonômico são publicados, documentados e com scaffold pronto — um comando te dá
exatamente esta arquitetura (Vike, React Router v7, TanStack Start ou Next.js), já
montada e testada:
O post companheiro é o mergulho nos próprios motores — o que é o Indago, como o
índice contentless e o ciclo de vida de requisição do HyperDown funcionam, e como o
HyperJson valida e tipa os seus dados.
A seguir:
Começando com o Indago — os
motores que transformam esta pasta de Markdown e JSON numa camada de conteúdo
pesquisável e tipada que vai dentro do build.
Este portfólio é open source, e os motores também. HyperDown e HyperJson estão no npm como
@indago/hyper-down e
@indago/hyper-json (código no
GitHub). Se algo disto for útil para você, pegue — e
me conte o que você construir.