Construindo Este Portfólio: Vike, um SQLite no build e dois motores de conteúdo
Como este portfólio roda um site rico em conteúdo, totalmente pesquisável e bilíngue sem backend — só um banco SQLite gerado no build e enviado dentro do bundle. Um tour recurso a recurso pelo site, e a história de por que fazê-lo funcionar significou construir o Indago, os dois motores de conteúdo open source por baixo.
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.
Isso 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.
O detalhe: essa 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.
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 urlLogicalprecisa 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.
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:
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
// Totalmente tipado — o tipo é gerado a partir de projects/schema.jsonimport 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
// articles/+data.ts — roda só no servidor (SSR/SSG)const { results, totalCount, totalPages } = await articleRepository.search({
locale,
searchQuery, // FTS5 entre todos os localesfilters: 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:
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:
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:
Bash
1
bun create @indago/app
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.
Updates
Trailers do YouTube em filmes e jogos; links de repo nos artigos com JSON-LD SEO; loading state
na busca com warmup serverless; navegação no lightbox do carousel com suporte a teclado.
Seções de livros, jogos e filmes com reviews, ratings e cores de destaque; layout hero grid com
ícones de plataforma; componente ImageCarousel com scroll infinito.
Melhorias na navegação de música e fotografias; paginação de playlists.
Diagramas Mermaid com zoom; refatoração de SEO com URLs canônicas e dados estruturados; upgrade
dos engines Indago para 1.6.0 (FTS detail=column + optimize); atalho de teclado (Ctrl/⌘+.)
para busca.
Sidebar tutorial com scroll-spy de seção ativa; busca dentro do artigo (texto simples para
todos, prefixo # para seções da página atual); rastreamento de estado de leitura no
localStorage.
Cards de artigos padronizados com carrosséis mobile; sistema de drafts no HyperDown; páginas 404
com noindex; migração de todas as URLs para o domínio de produção.
Publicação inicial — Vike hybrid SSG+SSR, engines Indago via npm, conteúdo bilíngue, navegação
por séries com sugestões ranqueadas por tags.
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.