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
Acompanhe o código-fonte no GitHubVer Repositório

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.
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.
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.

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.