Construindo Roteamento no Cliente com a History API
Crie um router vanilla com History API, pushState e popstate, parâmetros dinâmicos, URLs SEO e correções para 404 e riscos de XSS.
O roteamento no cliente (client-side routing) troca as views atualizando a URL e renderizando novamente em JavaScript, sem nenhuma ida e volta ao servidor (o servidor só é contatado no primeiro carregamento ou em um hard refresh).
Se você já publicou uma single-page app, conhece o momento: tudo funciona localmente, aí um colega de equipe clica no botão Voltar e a URL muda enquanto a página simplesmente fica ali, imóvel. Leva cinco minutos para corrigir depois que você sabe onde olhar, e pega quase todo mundo na primeira vez.
Frameworks como React Router e Vue Router encapsulam esse comportamento em componentes e hooks, mas por baixo todos acionam a mesma primitiva do navegador: a History API. Este artigo constrói um roteador vanilla mínimo, correto e implantável em cerca de 50 linhas, explica a divisão de trabalho entre pushState e popstate e cobre as duas pegadinhas (o 404 no deploy e o risco de injeção XSS) que separam um brinquedo de algo que você pode colocar em produção.
Principais Pontos
- No modo History,
history.pushState(state, '', url)altera a URL sem recarregar a página, mas não dispara um eventopopstate. Você mesmo chama sua função de renderização após opushStatee, separadamente, escuta opopstatepara lidar com Voltar e Avançar. - As URLs limpas do modo History, como
/dashboard, são melhores para SEO e compartilhamento, mas exigem que o servidor reescreva todo caminho desconhecido paraindex.html, caso contrário uma visita direta ou um refresh retorna 404. - O segundo argumento de
pushStateé um parâmetrotitlelegado que os navegadores ignoram; ele não pode ser omitido, então sempre passe uma string vazia. - Injetar uma view com
innerHTMLé um vetor de XSS para qualquer dado não confiável interpolado e descarta silenciosamente os event listeners na marcação injetada. Construa nós comcreateElement, sanitize, ou use uma biblioteca de templating, e vincule comportamento via delegação de eventos. - A Navigation API atingiu o status Baseline Newly available em janeiro de 2026 e é a sucessora emergente desse padrão, mas a History API continua sendo a base de maior compatibilidade.
Qual é a diferença entre hash mode e History mode?
O roteamento no cliente atualiza a view quando a URL muda, sem um recarregamento completo da página. Existem duas maneiras de alterar a URL sem navegar: hash mode e History mode. O hash mode codifica a rota depois de um # (/app#/users). O fragmento após o hash nunca é enviado ao servidor, então a navegação baseada em hash é puramente client-side e não precisa de nenhuma configuração de servidor, e você escuta o evento hashchange. O History mode produz caminhos limpos (/users) usando a History API e escuta o popstate.
| Hash mode | History mode | |
|---|---|---|
| Formato da URL | /app#/users | /users |
| Evento de mudança | hashchange | popstate |
| Configuração de servidor | Nenhuma | Reescrever todos os caminhos para index.html |
| Refresh / deep link | Sempre funciona | 404 sem rewrite |
| SEO / URLs compartilháveis | Mais fraco | Mais limpo, preferível |
O History mode é a escolha padrão por suas URLs limpas e indexáveis, e é o que este artigo constrói. O único custo é que ele precisa de suporte do servidor, algo abordado adiante.
As primitivas da History API que você realmente precisa
Discover how at OpenReplay.com.
Três primitivas sustentam um roteador em modo History. history.pushState(state, unused, url) adiciona uma entrada à pilha de histórico da sessão e altera a barra de endereços; history.replaceState faz o mesmo, mas sobrescreve a entrada atual em vez de adicionar uma nova. location.pathname lê o caminho atual para que você possa corresponder a uma rota. O evento popstate dispara quando o usuário pressiona Voltar ou Avançar.
A regra crítica: pushState e replaceState não disparam popstate. Você precisa chamar sua função de renderização manualmente após cada pushState e, separadamente, registrar um listener de popstate para que o Voltar e o Avançar do navegador renderizem a view novamente. Esqueça o listener e a URL muda no Voltar enquanto o DOM permanece congelado — um bug invisível em code review, mas óbvio no momento em que você assiste a um session replay da aplicação.
Mais dois detalhes importam. O argumento do meio é um valor title legado que os navegadores ignoram, e ele não pode ser omitido, então passe uma string vazia. A url precisa ser same-origin: o navegador não a carrega quando você chama pushState, e a chamada lança uma exceção se a origem for diferente da página atual. O popstate em si é antigo e confiável, disponível em todos os navegadores desde julho de 2015.
Como construir um roteador mínimo?
Um roteador funcional em modo History precisa de cinco partes: um mapa de rotas, uma função resolve que lê location.pathname e corresponde a uma rota com fallback para 404, delegação de cliques em um atributo data-link, um listener de popstate e uma renderização inicial. Aqui está o arquivo completo:
function escapeHtml(str) {
return String(str).replace(/[&<>"']/g, (c) =>
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
}
const routes = {
'/': { view: () => '<h1>Home</h1><a href="/users/42" data-link>User 42</a>', title: 'Home' },
'/users/:id': { view: (p) => `<h1>User ${escapeHtml(p.id)}</h1>`, title: 'User' },
'/404': { view: () => '<h1>404 — Not found</h1>', title: 'Not found' },
};
const app = document.getElementById('app');
function match(pathname) {
for (const pattern of Object.keys(routes)) {
const pParts = pattern.split('/');
const uParts = pathname.split('/');
if (pParts.length !== uParts.length) continue;
const params = {};
const ok = pParts.every((part, i) => {
if (part.startsWith(':')) { params[part.slice(1)] = decodeURIComponent(uParts[i]); return true; }
return part === uParts[i];
});
if (ok) return { route: routes[pattern], params };
}
return { route: routes['/404'], params: {} };
}
function resolve() {
const { route, params } = match(location.pathname);
app.innerHTML = route.view(params);
document.title = route.title;
}
function navigate(url) {
history.pushState({}, '', url); // '' is the ignored legacy title
resolve(); // pushState does NOT fire popstate — render manually
}
document.addEventListener('click', (e) => {
const link = e.target.closest('[data-link]'); // robust: works on nested markup
if (!link) return;
e.preventDefault();
navigate(link.getAttribute('href'));
});
window.addEventListener('popstate', resolve); // Back / Forward
history.replaceState({}, '', location.pathname); // seed the initial entry
resolve(); // render on first paint
A delegação de eventos via e.target.closest('[data-link]') é intencional. Ela sobrevive a cliques em nós filhos (um ícone dentro de um link) e continua funcionando quando as views são renderizadas novamente, ao contrário de anexar listeners a cada elemento ou ler e.target.attributes[0], que depende da ordem dos atributos e quebra com marcação aninhada.
Subindo de nível: params dinâmicos, títulos e a entrada inicial
A função match acima já lida com segmentos dinâmicos. Um padrão como /users/:id é dividido em partes; qualquer segmento que comece com : captura o segmento de caminho correspondente em um objeto params, então /users/42 resolve com { id: '42' }. Segmentos sem : precisam corresponder exatamente, e uma diferença de comprimento faz o padrão ser ignorado, o que impede que /users corresponda a /users/42. Definir document.title dentro de resolve atualiza a aba e o rótulo no histórico a cada navegação.
Mais uma correção pertence ao roteador. O navegador cria sua primeira entrada de histórico a partir de um carregamento de página comum, então nada fica armazenado nela, e o guia da MDN sobre trabalhar com a History API recomenda chamar history.replaceState() na inicialização para anexar estado a essa entrada. Faça isso e o primeiro Voltar poderá restaurar sua view inicial. Essa é a linha final de replaceState no roteador.
As duas pegadinhas que separam um brinquedo de um roteador de verdade
Deploy. As URLs limpas do modo History exigem que o servidor reescreva todo caminho desconhecido para index.html, caso contrário uma visita direta ou um refresh em /users/42 retorna 404. Não há contorno em JavaScript, porque a requisição chega ao servidor antes do seu bundle carregar. Configure o rewrite uma vez por host. O Express 5 mudou sua sintaxe de correspondência de caminhos: todo wildcard agora precisa ser nomeado, então o antigo catch-all app.get('*') lança um erro “Missing parameter name” na inicialização. Use o wildcard nomeado entre chaves, que corresponde tanto ao caminho raiz quanto a tudo abaixo dele:
// Express 5.x
app.get('/{*splat}', (req, res) => res.sendFile(__dirname + '/public/index.html'));
// Express 4.x used: app.get('*', ...)
# Nginx
location / { try_files $uri $uri/ /index.html; }
# Netlify — _redirects
/* /index.html 200
// Vercel — vercel.json
{ "rewrites": [{ "source": "/(.*)", "destination": "/index.html" }] }
Um 404 em hard refresh de um deep link compartilhado é outra falha que parece correta no código, mas fica evidente quando você assiste a uma sessão real chegando a uma página em branco.
Segurança. Injetar uma view com innerHTML é um vetor de XSS sempre que dados não confiáveis são interpolados (o ${p.id} acima vem diretamente da URL), e isso descarta silenciosamente os event listeners na marcação injetada. Escape os valores interpolados (a chamada escapeHtml acima), construa nós com document.createElement, ou use uma biblioteca de templating como lit-html, e vincule comportamento por meio de delegação de eventos em um pai estável, e não nos nós injetados. Strings de template estáticas, escritas pelo desenvolvedor e sem interpolação, não constituem injeção por si só; o risco está nos dados não confiáveis que você insere nelas.
Para onde a plataforma está indo: a Navigation API
A Navigation API atingiu o status Baseline Newly available em janeiro de 2026, mês em que o Firefox 147 adicionou suporte, e é a sucessora emergente desse padrão. Em vez de conectar separadamente pushState, um listener de popstate e um handler de clique, você registra um único listener de navigate. Ele roda para toda navegação que a página consegue enxergar, independentemente do que a iniciou, e chamar event.intercept() dentro desse listener deixa a barra de endereços e a pilha de histórico a cargo do navegador. Uma das limitações que ela resolve é justamente o fato de o popstate não disparar em pushState/replaceState programáticos — exatamente o atrito que este roteador contorna. Até que ela seja o piso de compatibilidade para os seus navegadores-alvo, a History API continua sendo a base de maior suporte e a forma mais clara de entender o que um roteador realmente faz.
Agora você tem um roteador em modo History executável: rotas, correspondência de params, cliques delegados, tratamento correto de popstate, uma entrada inicial inicializada e ambas as correções de produção. O próximo passo concreto é configurar o rewrite do servidor no seu host antes de fazer o deploy, para que os deep links sobrevivam a um refresh.
Perguntas Frequentes
Por que o botão Voltar muda a URL mas deixa a página inalterada na minha SPA?
Porque pushState e replaceState não disparam um evento popstate; então, se você só renderiza dentro do seu handler de clique e nunca registra um listener de popstate, Voltar e Avançar atualizam a barra de endereços sem renderizar novamente. A correção é um window.addEventListener('popstate', resolve) separado que executa sua função de renderização sempre que o navegador se move pelo histórico. Assista a um session replay e você verá uma mudança de URL sem nenhuma mudança no DOM.
Qual é a diferença entre pushState e replaceState?
pushState adiciona uma nova entrada à pilha de histórico da sessão, de modo que a view anterior continua acessível pelo botão Voltar. replaceState sobrescreve a entrada atual em vez de adicionar uma nova, então não cria um novo destino de Voltar. Use pushState para navegação normal e replaceState para inicializar a entrada da página inicial na partida ou para corrigir a URL atual sem poluir o histórico. Ambos compartilham a mesma assinatura (state, unused, url) e nenhum deles dispara popstate.
O roteamento em hash mode precisa de alguma configuração de servidor?
Não. O fragmento após o hash, como o '/users' em '/app#/users', nunca é enviado ao servidor, então a navegação baseada em hash é puramente client-side e funciona em qualquer host estático com zero regras de rewrite. Refreshes e deep links sempre resolvem porque o servidor só enxerga '/app'. O History mode é o tradeoff: ele produz URLs mais limpas, mas exige que o servidor reescreva todo caminho desconhecido para index.html, caso contrário um refresh retorna 404.
Ainda devo aprender a History API agora que a Navigation API é Baseline?
Sim. A Navigation API atingiu o status Baseline Newly available em janeiro de 2026 e é a sucessora emergente, substituindo o pushState manual, o popstate e a interceptação de cliques por um único evento navigate e event.intercept(). Mas a History API continua sendo a base de maior compatibilidade, funciona em navegadores antigos onde a Navigation API não funciona, e é o que frameworks como React Router e Vue Router ainda acionam por baixo. Aprendê-la é a forma mais clara de entender o que qualquer roteador realmente faz.