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:
| Camada | Arquivo | Responsabilidade |
|---|---|---|
| Conexão MongoDB | lib/mongodb.js | Singleton com cache para dev HMR |
| Data Layer | lib/data.js | Funções assíncronas com queries MongoDB |
| API Routes | api/scores/route.js | GET (buscar top 100) e POST (salvar score) |
| Páginas Ranking | ranking/page.js | Server Components com ISR (5 min) |
| Perfis de Jogador | player/[nickname]/page.js | SSG + ISR dinâmico (1 hora) |
| Componente Tabela | components/RankingTable.js | Tabela 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._mongoClientPromisepara sobreviver ao HMR (Hot Module Replacement) do Next.js sem criar conexões duplicadas - A variável de ambiente
MONGODB_URIé lida deprocess.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ção | Parâmetros | Query MongoDB |
|---|---|---|
getTopScores | limit = 100 | .find({}).sort({ score: -1 }).limit(100) |
getScoresByPeriod | period, limit | .find(dateFilter).sort({ score: -1 }).limit(100) |
getAllPlayerNames | — | .aggregate([{ $group: { _id: '$name' } }, { $sort: { _id: 1 } }]) |
getPlayerStats | nickname | 4 queries + aggregation (stats, rank, recent) |
getSponsors | — | .find({}).sort({ value: -1 }) |
getAggregatedStats | period | 9 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 servidor —
playedAtnunca 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:
- Server Component assíncrono — a função do componente é
asynce fazawaitdiretamente no corpo - ISR com
revalidate = 300(5 minutos) — a página é cacheada e regenerada em background a cada 5 minutos - Data fetching com
Promise.all— busca scores e sponsors em paralelo - 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. ChamagetAllPlayerNames(), pega os primeiros 500, e retorna{ nickname: encodeURIComponent(name) }para cada umdynamicParams = 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:
- Top 50 scores do jogador:
.find({ name }).sort({ score: -1 }).limit(50) - Aggregation com
$group: totalGames ($sum: 1), bestScore ($max), avgScore ($avg), bestTier, totalTime, avgTime, totalBoosts, maxSpread, firstPlay ($min: '$playedAt'), lastPlay ($max) - Ranking global: agrupa todos os jogadores por best score, conta quantos têm score maior → posição = contagem + 1
- 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/bronzepara estilização - Piloto — Nome com
<Link>para o perfil (quandoshowLinks=true). URL:/player/{encodeURIComponent(name)} - Score — Formatado com
toLocaleString(), cor ciano (#7df4ff), alinhado à direita - Tempo — Formato
M:SSviaformatTime()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:
| Tier | Cor | Hex |
|---|---|---|
| TIER I | Ciano | #7df4ff |
| TIER II | Verde | #80ff90 |
| TIER III | Amarelo | #ffdd00 |
| TIER IV | Laranja | #ff8c00 |
| TIER V | Rosa | #ff4488 |
| TIER VI | Roxo | #cc44ff |
| TIER VII | Branco | #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) ougenerateMetadata(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 sniffingX-Frame-Options: SAMEORIGIN— previne clickjacking via iframeReferrer-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: ellipsispara 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$gtee 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):
| Pacote | Versão | Uso no Leaderboard |
|---|---|---|
| next | ^15.1.0 | App Router, Server Components, ISR, API Routes, Metadata |
| react / react-dom | ^19.0.0 | Componentes, renderização |
| mongodb | ^5.9.2 | Driver 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.
