Criar um leaderboard em Next.js com MongoDB é mais simples do que parece — e o resultado é surpreendentemente robusto. Com o App Router do Next.js 15, Server Components assíncronos, ISR (Incremental Static Regeneration) e o driver nativo do MongoDB, é possível construir um ranking que carrega instantaneamente, se atualiza sozinho, e tem SEO perfeito.

Neste tutorial, vamos implementar cada camada de um leaderboard completo — do banco de dados à interface — usando a arquitetura real do ranking do Navistron como referência passo a passo.

Visão Geral da Arquitetura

O leaderboard do Navistron é composto por 6 camadas que se comunicam de forma simples e previsível:

CamadaArquivoResponsabilidade
Conexão MongoDBlib/mongodb.jsSingleton com cache para dev HMR
Data Layerlib/data.jsFunções assíncronas com queries MongoDB
API Routesapi/scores/route.jsGET (buscar top 100) e POST (salvar score)
Páginas Rankingranking/page.jsServer Components com ISR (5 min)
Perfis de Jogadorplayer/[nickname]/page.jsSSG + ISR dinâmico (1 hora)
Componente Tabelacomponents/RankingTable.jsTabela reutilizável com medalhas e cores

Sem ORM, sem abstrações desnecessárias. São 6 arquivos com responsabilidades claras.

1. Conexão MongoDB: Singleton com HMR Guard

Em ambiente serverless (Vercel, por exemplo), cada requisição pode criar uma nova instância da função. Sem cuidado, cada requisi ção abriria uma nova conexão ao MongoDB — e rapidamente você esgotaria o pool de conexões.

O padrão usado no Navistron resolve isso com um singleton factory:

  • Em produção: cria new MongoClient(uri) e chama .connect() uma vez. A promise é armazenada em variável de módulo
  • Em desenvolvimento: armazena a promise em global._mongoClientPromise para sobreviver ao HMR (Hot Module Replacement) do Next.js sem criar conexões duplicadas
  • A variável de ambiente MONGODB_URI é lida de process.env. Se ausente, lança erro explicativo
  • O export é uma função (getClientPromise), não a promise diretamente — chamada onde necessário

Opções de conexão: {} (defaults do driver). O driver MongoDB ^5.9.2 já usa defaults otimizados para serverless.

2. Data Layer: Funções Assíncronas com Queries MongoDB

O data layer centraliza todas as queries do banco em funções exportadas. Nenhuma página ou API acessa o MongoDB diretamente — tudo passa pelo data layer. As principais funções:

FunçãoParâmetrosQuery MongoDB
getTopScoreslimit = 100.find({}).sort({ score: -1 }).limit(100)
getScoresByPeriodperiod, limit.find(dateFilter).sort({ score: -1 }).limit(100)
getAllPlayerNames.aggregate([{ $group: { _id: '$name' } }, { $sort: { _id: 1 } }])
getPlayerStatsnickname4 queries + aggregation (stats, rank, recent)
getSponsors.find({}).sort({ value: -1 })
getAggregatedStatsperiod9 queries paralelas via Promise.all

Cada função faz serialização manual antes de retornar: _id.toString() para converter ObjectId em string, e playedAt.toISOString() para datas. Isso é obrigatório porque Server Components do Next.js não podem receber tipos não-serializáveis como props.

O filtro de período é elegante — uma única função aceita 'weekly' (7 dias), 'monthly' (30 dias) ou 'all' (sem filtro), calculando a data de corte com new Date(Date.now() - dias * 86400000).

3. API Routes: GET e POST com App Router

No App Router do Next.js 15, API routes são funções nomeadas exportadas de um arquivo route.js. O Navistron usa apenas dois handlers para o ranking inteiro:

GET — retorna o top 100 scores. Chama getTopScores(100), serializa, e retorna com NextResponse.json(data). Em caso de erro, retorna { error: '...' } com status 500.

POST — salva um novo score. Lê o body com await request.json(), sanitiza cada campo individualmente (conversão de tipo com Number() e fallback || 0, string uppercase truncada em 20 chars), adiciona playedAt: new Date() do servidor, e executa insertOne(record).

Decisões de design importantes:

  • Sem CORS headers — o jogo roda no mesmo domínio (same-origin). Headers de segurança são definidos globalmente no next.config.mjs
  • Sem autenticação — para um jogo arcade casual, a sanitização no servidor é proteção suficiente
  • Timestamp do servidorplayedAt nunca vem do cliente, prevenindo manipulação de datas
  • Record construído campo a campo — campos desconhecidos no body são ignorados (não há spread operator no body)

4. Ranking Pages: Server Components Assíncronos com ISR

As páginas de ranking são o coração do leaderboard. No Navistron, existem 3 páginas — geral, semanal e mensal — todas seguindo o mesmo padrão:

  1. Server Component assíncrono — a função do componente é async e faz await diretamente no corpo
  2. ISR com revalidate = 300 (5 minutos) — a página é cacheada e regenerada em background a cada 5 minutos
  3. Data fetching com Promise.all — busca scores e sponsors em paralelo
  4. Graceful degradation — catch vazio: se o MongoDB falhar, renderiza arrays vazios em vez de crashar

O padrão de fetching no componente é direto:

const [scores, sponsors] = await Promise.all([getTopScores(100), getSponsors()]);

A navegação entre períodos usa abas com Link — cada aba é um <Link> para a rota correspondente (/ranking, /ranking/weekly, /ranking/monthly). A aba ativa recebe a classe CSS cta-primary active; as inativas usam cta-secondary. Sem estado de cliente, sem JavaScript no browser — tudo renderizado no servidor.

Cada página exporta um objeto metadata estático com title, description, keywords, canonical URL, OpenGraph (com imagem dedicada og-ranking.webp 1200×630), e Twitter Card.

5. Perfis de Jogador: SSG + ISR Dinâmico

Cada piloto do Navistron tem uma página individual com estatísticas completas. A implementação combina três features do Next.js:

  • generateStaticParams — pré-gera até 500 perfis no build. Chama getAllPlayerNames(), pega os primeiros 500, e retorna { nickname: encodeURIComponent(name) } para cada um
  • dynamicParams = true — permite acesso a nomes que não foram pré-gerados (renderizados on-demand)
  • revalidate = 3600 (1 hora) — perfis são regenerados a cada hora

O metadata é dinâmico via generateMetadata: título "{NICKNAME} — Perfil de Piloto | Navistron", OpenGraph type profile, canonical URL dinâmica.

Os dados vêm de getPlayerStats(nickname), que executa 4 operações MongoDB:

  1. Top 50 scores do jogador: .find({ name }).sort({ score: -1 }).limit(50)
  2. Aggregation com $group: totalGames ($sum: 1), bestScore ($max), avgScore ($avg), bestTier, totalTime, avgTime, totalBoosts, maxSpread, firstPlay ($min: '$playedAt'), lastPlay ($max)
  3. Ranking global: agrupa todos os jogadores por best score, conta quantos têm score maior → posição = contagem + 1
  4. Retorna 20 scores recentes para a tabela do perfil

Se o jogador não existe, a função retorna null e a página chama notFound() — Next.js renderiza 404 automaticamente.

O perfil exibe 9 stat cards em grid: Melhor Score (ciano), Ranking Global (dourado com #), Total de Jogos, Score Médio, Melhor Tier, Tempo Total, Tempo Médio, Total Boosts, Máx Spread.

O JSON-LD usa @type: 'Person' com name, URL e description — schema.org correto para perfis.

6. Componente RankingTable: Reutilizável com Medalhas

O RankingTable é usado em 4 contextos: ranking geral, semanal, mensal e perfil de jogador. Aceita duas props: scores (array) e showLinks (boolean, default true).

A tabela renderiza 8 colunas para cada score:

  • # — Posição com medalhas emoji para top 3 (🥇🥈🥉) e classes CSS gold/silver/bronze para estilização
  • Piloto — Nome com <Link> para o perfil (quando showLinks=true). URL: /player/{encodeURIComponent(name)}
  • Score — Formatado com toLocaleString(), cor ciano (#7df4ff), alinhado à direita
  • Tempo — Formato M:SS via formatTime() interna
  • Tier — Nome do tier com cor dinâmica do mapa TIER_COLORS
  • Boosts, Spread — Valores numéricos, alinhados à direita
  • Data — Formatada em pt-BR: toLocaleDateString('pt-BR', { month: 'short', day: 'numeric' })

O mapa de cores dos tiers é definido no componente:

TierCorHex
TIER ICiano#7df4ff
TIER IIVerde#80ff90
TIER IIIAmarelo#ffdd00
TIER IVLaranja#ff8c00
TIER VRosa#ff4488
TIER VIRoxo#cc44ff
TIER VIIBranco#ffffff

Na página de perfil, showLinks=false desabilita os links para evitar auto-referência (o perfil já está aberto).

7. SEO: Metadata, JSON-LD e Breadcrumbs

Um leaderboard público é uma oportunidade de SEO poderosa — cada página de ranking e cada perfil de jogador pode ser indexada pelo Google. O Navistron aproveita três ferramentas do Next.js:

  • Metadata API — Cada página exporta metadata (estático) ou generateMetadata (dinâmico) com title, description, keywords, canonical, OpenGraph com imagem dedicada, e Twitter Card
  • JSON-LD via componente — O componente <JsonLd> recebe um objeto schema.org e injeta via <script type="application/ld+json">. Usado com @type: 'WebPage' no ranking e @type: 'Person' nos perfis
  • Breadcrumbs estruturados — O componente <Breadcrumb> renderiza navegação visual E injeta JSON-LD @type: 'BreadcrumbList' com posições corretas. Exemplo: Home → Ranking → Semanal

Resultado: cada página tem metadata completo, dados estruturados para rich results, e breadcrumbs para navegação hierárquica — tudo renderizado no servidor, sem JavaScript no browser.

8. Segurança: Headers e Configuração

O next.config.mjs do Navistron aplica 3 headers de segurança em todas as rotas via headers():

  • X-Content-Type-Options: nosniff — previne MIME type sniffing
  • X-Frame-Options: SAMEORIGIN — previne clickjacking via iframe
  • Referrer-Policy: strict-origin-when-cross-origin — controla vazamento de referrer

Além disso, poweredByHeader: false remove o header X-Powered-By: Next.js que expõe a tecnologia usada. São 4 linhas de configuração com impacto significativo na segurança.

9. In-Game Leaderboard: Overlay no Canvas

Além das páginas SSR, o leaderboard também aparece dentro do jogo após cada partida. O Navistron exibe um overlay HTML sobre o canvas com:

  • Tabela scrollável (max 45vh) com sticky headers no topo
  • Score do jogador atual destacado com classe highlight (fundo azulado) e auto-scroll para a posição
  • Medalhas douradas, prata e bronze para top 3
  • Nomes com max-width 100px e text-overflow: ellipsis para nomes longos
  • Tamanhos responsivos com clamp() — fonte entre 10px e 13px
  • Tabela de patrocinadores abaixo do ranking com valores em R$

O leaderboard in-game busca os dados via GET /api/scores (client-side fetch) e constrói as linhas da tabela dinamicamente com DOM. A mesma tabela que aparece na página de ranking SSR aparece no jogo — dados iguais, contexto diferente.

10. Índices MongoDB: Performance do Ranking

Três índices cobrem 100% dos padrões de consulta do leaderboard:

  • { score: -1 } — Alimenta .sort({ score: -1 }) do ranking geral. Sem este índice, o MongoDB faria scan completo a cada GET
  • { playedAt: -1 } — Alimenta filtros de período (weekly/monthly) com $gte e consultas de jogos recentes
  • { name: 1 } — Alimenta lookups de perfil por nome e listagem de pilotos únicos

Os índices são criados pelo script npm run seed via createIndex(). A operação é idempotente — chamar createIndex em um índice que já existe não faz nada.

11. Stack Técnica Completa

O leaderboard do Navistron roda com apenas 3 dependências de runtime (além do React):

PacoteVersãoUso no Leaderboard
next^15.1.0App Router, Server Components, ISR, API Routes, Metadata
react / react-dom^19.0.0Componentes, renderização
mongodb^5.9.2Driver nativo — conexão, queries, aggregation

Sem ORM (Prisma, Mongoose), sem state management (Redux, Zustand), sem CSS framework (Tailwind). O leaderboard é Server Components puros com CSS vanilla e MongoDB nativo. Essa simplicidade resulta em manutenção mínima e performance máxima.

FAQ — Perguntas Frequentes sobre Leaderboard em Next.js

Server Components ou Client Components para o leaderboard?

Server Components. As páginas de ranking do Navistron são 100% Server Components — o HTML é gerado no servidor, cacheado via ISR, e nenhum JavaScript de ranking é enviado para o browser. Client Components só são usados no jogo em si (Canvas), nunca nas páginas de ranking.

Qual a diferença entre ISR e SSR para rankings?

SSR gera a página em cada requisição (lento). ISR gera uma vez, cacheia, e regenera em background após o período de revalidação. O Navistron usa revalidate = 300 (5 minutos) para rankings — um equilíbrio entre frescor dos dados e performance. Perfis de jogador usam revalidate = 3600 (1 hora) porque mudam menos.

Preciso de Prisma ou Mongoose?

Não. O driver nativo do MongoDB (mongodb ^5.9.2) é suficiente para um leaderboard. Ele expõe find(), sort(), limit(), insertOne() e aggregate() — tudo que você precisa. ORMs adicionam complexidade sem benefício claro para queries simples de ranking. O data layer do Navistron tem 284 linhas e cobre 100% do sistema.

Como lidar com nomes duplicados no ranking?

O Navistron permite nomes duplicados — cada score é um documento independente. O ranking global compara pelo melhor score de cada nome. Se dois jogadores usam o mesmo nome, ambos os scores aparecem. Para perfis, a página mostra stats agregadas de todas as partidas com aquele nome.

Como o ranking global de um jogador é calculado?

Via aggregation pipeline: agrupa todos os jogadores por nome e $max do score, filtra os que têm best score maior que o jogador consultado, e usa $count. Posição = contagem + 1. Essa abordagem é sempre precisa e não requer manter uma tabela de ranking separada. Mais detalhes em como funciona o ranking global.