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.

by Zau Julio12 de junho de 202615 min de leitura

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.
Rendering diagram…

A stack num relance


CamadaEscolha
FrameworkVike (vike + vike-react + vike-server) — híbrido SSG/SSR
UIReact 19
ServidorHono via @vikejs/hono
RuntimeBun em dev/Docker; Node 22 na Vercel
EstiloTailwind 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
i18ni18next + react-i18next, roteamento que remove o prefixo
Lint / formatOXC (oxlint + oxfmt) — não é ESLint nem Biome
TestesVitest (unit + integridade de conteúdo) e Playwright (e2e)
DeployVercel (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
// +onBeforeRoute.ts (essência)
const { urlWithoutLocale, locale } = extractLocale(pageContext.urlParsed);
return {
  pageContext: {
    locale,
    // search + hash são essenciais — sem eles, ?q=… desaparece
    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
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
// Totalmente tipado — o tipo é gerado a partir de projects/schema.json
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
// articles/+data.ts — roda só no servidor (SSR/SSG)
const { results, totalCount, totalPages } = await articleRepository.search({
  locale,
  searchQuery, // FTS5 entre todos os locales
  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.
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
# building-this-portfolio.mdx
next: "getting-started-with-indago"

# getting-started-with-indago.mdx
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
const suggestions = await articleRepository.related({
  slug,
  tags: article.tags, // ordem de prioridade
  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".
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:

RotaModoPor quê
/, seçõesPré-renderizada (SSG)Conteúdo é estático; entregue HTML puro.
listagens /articles, /cookingSSR ao vivo (prerender: false)Busca/filtro precisam rodar por requisição.
/articles/@slug, /cooking/@slugPré-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:
  1. oxlint + oxfmt — o projeto está no OXC, não no ESLint nem no Biome. Rápido o bastante para rodar a cada save.
  2. 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.
  3. 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.
  4. 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
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.




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.