Links em HTML: href, target, âncora e link de e-mail
Link para outra página, para uma seção da mesma página, para e-mail e para telefone — e por que abrir em nova aba pede rel=noopener.
Link em HTML é a tag <a> com o atributo href. O que vai dentro do href não
é o endereço final: é uma instrução que o navegador resolve contra o endereço
da página em que você está. Entender essa resolução é o que separa o site que
funciona no seu computador do site que funciona depois de publicado.
Os exemplos deste artigo montam a página da Clínica Veterinária Pata Firme, que hoje só tem um perfil no Instagram e precisa de um site com serviços, horários e três formas de a pessoa chamar a clínica.
O processo técnico se chama resolução de URL. Em palavras simples, o
navegador combina o href com a URL da página atual para descobrir o destino
completo antes de navegar.
A rota calculada a partir do ponto onde você está
Uma orientação como “entre na próxima rua” muda conforme o lugar de onde a
pessoa parte. servicos/vacinas.html funciona do mesmo modo: o endereço atual é
a origem do cálculo. Uma barra inicial muda o ponto de partida para a raiz do
site; uma URL absoluta já traz protocolo, domínio e caminho completos.
Antes de clicar no primeiro conjunto de links, escreva a URL final que espera
para cada href. Depois passe o mouse, leia o destino mostrado pelo navegador e
confira na barra de endereço. Repita imaginando a página movida para uma
subpasta: os links que mudarem dependem da localização atual.
Este é o menu da clínica, com quatro links de tipos diferentes:
<nav>
<a href="index.html">Início</a>
<a href="servicos.html">Serviços</a>
<a href="contato/">Contato</a>
<a href="#horarios">Horários</a>
</nav>O href é um atributo, como qualquer outro:
um par nome/valor dentro da tag de abertura. Só que o valor dele passa por um
processamento antes de virar navegação. Dá para ver esse processamento
acontecendo com o jsdom, que é o parser de HTML do navegador rodando dentro do
Node — a propriedade href do elemento devolve o endereço já resolvido, e
getAttribute('href') devolve o valor do atributo antes dessa resolução:
import { JSDOM } from 'jsdom';
const html = `
<a href="servicos.html">Serviços</a>
<a href="/contato">Contato</a>
<a href="../index.html">Voltar</a>
<a href="https://wa.me/5511999998888">WhatsApp</a>
`;
const { document } = new JSDOM(html, {
url: 'https://patafirme.com.br/vacinas/gatos.html',
}).window;
for (const a of document.querySelectorAll('a')) {
console.log(a.getAttribute('href').padEnd(30), '->', a.href);
}Quatro entradas, quatro saídas — e só a última saiu igual ao que foi escrito. O mecanismo é sempre o mesmo:
Relativo, absoluto e o dia em que a página muda de pasta
A diferença que mais custa caro é entre servicos.html e /servicos.html. A
barra na frente muda tudo: sem ela, o caminho parte da pasta da página atual;
com ela, parte da raiz do site. Enquanto o site tem uma pasta só, os dois se
comportam igual — e é exatamente por isso que o problema aparece tarde:
const paginas = [
'https://patafirme.com.br/index.html',
'https://patafirme.com.br/vacinas/gatos.html',
];
for (const pagina of paginas) {
console.log(pagina);
for (const href of ['servicos.html', '/servicos.html']) {
console.log(' ', href.padEnd(16), '->', new URL(href, pagina).href);
}
}Na primeira página, resultado idêntico. Na segunda, o relativo foi parar em
/vacinas/servicos.html, um arquivo que não existe. O menu não mudou; a página
que carrega o menu mudou de pasta.
| forma | exemplo | parte de | quando usar |
|---|---|---|---|
| relativo | servicos.html |
pasta da página atual | arquivos que andam juntos, na mesma pasta |
| relativo com subida | ../index.html |
uma pasta acima | sair de uma subpasta |
| absoluto de raiz | /servicos.html |
raiz do domínio | menu, rodapé, tudo que se repete no site |
| absoluto completo | https://wa.me/... |
lugar nenhum, já é o endereço | outro domínio |
A regra que eu sigo: navegação que se repete em várias páginas usa barra
inicial. Ela é a única forma que continua certa quando alguém cria a pasta
/vacinas/ seis meses depois.
O & no meio do endereço
Dentro de um atributo, o navegador ainda procura
entidades HTML. Um & cru num endereço com
parâmetros pode ser engolido:
import { JSDOM } from 'jsdom';
const html = `
<a href="busca.html?tipo=vacina©felinos">1</a>
<a href="busca.html?a=1¶b=2">2</a>
<a href="busca.html?a=1&para;b=2">3</a>
`;
const { document } = new JSDOM(html, { url: 'https://patafirme.com.br/' }).window;
for (const a of document.querySelectorAll('a')) {
console.log(a.textContent, '->', a.getAttribute('href'));
}© virou © e ¶ virou ¶ dentro do endereço. Nenhum dos dois é
caractere exótico: copy e para são nomes de entidade que colidem com nomes de
parâmetro perfeitamente comuns. O gatilho é o ; logo depois — dentro de um
atributo, uma entidade sem ponto e vírgula seguida de = ou de letra fica como
está, e por isso ?tipo=vacina©=felinos escapa ileso enquanto
?tipo=vacina©felinos não. Só o terceiro link, escrito com &, chegou
íntegro. Na dúvida, & dentro de href vira sempre & — custa quatro
caracteres e nunca dá problema.
Âncora: o link que não sai da página (nem chega ao servidor)
Um href que começa com # aponta para um elemento da própria página, achado
pelo id:
<a href="#horarios">Horários</a>
<h2 id="horarios">Horários</h2>
<p>Segunda a sexta, das 8h às 19h. Sábado, das 8h às 13h.</p>O casamento entre #horarios e id="horarios" tem que ser exato. Este teste
percorre todas as âncoras da página e diz quais não têm destino:
import { JSDOM } from 'jsdom';
const html = `
<a href="#horarios">Horários</a>
<a href="#vacinas">Vacinas</a>
<h2 id="horarios">Horários</h2>
<h2 id="vacina">Vacinas</h2>
`;
const { document } = new JSDOM(html, { url: 'https://patafirme.com.br/' }).window;
for (const a of document.querySelectorAll('a[href^="#"]')) {
const alvo = document.getElementById(a.getAttribute('href').slice(1));
console.log(
a.getAttribute('href').padEnd(12),
alvo ? `achou <${alvo.tagName.toLowerCase()}>` : 'NAO EXISTE na pagina',
);
}
const url = new URL('#horarios', 'https://patafirme.com.br/index.html');
console.log('\nurl completa :', url.href);
console.log('pathname :', url.pathname);
console.log('hash :', url.hash);url completa : https://patafirme.com.br/index.html#horarios pathname : /index.html hash : #horarios
#vacinas no link, id="vacina" no título: uma letra de diferença e a página
não sai do lugar. Sem erro no console, sem aviso — o navegador troca o endereço
na barra, guarda a entrada no histórico e continua exatamente onde estava. É
essa quietude que faz o defeito sobreviver semanas na página no ar.
Repare também que o hash é uma parte separada da URL. E ele nunca sai do
navegador. Dá para comprovar isso subindo um servidor que imprime o que recebe.
A pasta site/ aqui é o site da clínica como ele está no disco — index.html
com o menu inteiro, servicos.html, contato/index.html e
arquivos/tabela-precos.pdf — e é ela que vai servir de cobaia até o fim do
artigo:
import express from 'express';
const app = express();
app.use((req, res, next) => {
console.log('chegou no servidor ->', req.url);
next();
});
app.use(express.static('site'));
app.listen(4321);curl -s -o /dev/null "http://localhost:4321/index.html#horarios"
curl -s -o /dev/null "http://localhost:4321/servicos.html?utm=whatsapp"A query string ?utm=whatsapp chegou inteira. O #horarios não chegou. Por isso
âncora não é rota: quem decide para onde a página rola é o navegador, sozinho,
depois que o HTML já está na mão dele.
target="_blank": a aba nova e o rel que fecha a porta
target="_blank" faz o link abrir em outra aba. É o atributo mais usado errado
do HTML, por dois motivos: gente coloca em link interno, onde ele só atrapalha, e
gente esquece do rel.
<a href="https://vacinacao.sp.gov.br/" target="_blank" rel="noopener">
calendário oficial de vacinação
</a>O rel descreve a relação com o destino. noopener corta o acesso que a página
aberta teria à aba de origem pela propriedade window.opener — sem ele, um site
de terceiros consegue trocar o endereço da sua aba enquanto a pessoa está olhando
para a outra. noreferrer vai além e também esconde de onde a pessoa veio.
Este teste separa os links de aba nova e mostra o que cada um declarou:
import { JSDOM } from 'jsdom';
const html = `
<a href="https://vacinacao.sp.gov.br/" target="_blank">Calendário sem rel</a>
<a href="https://vacinacao.sp.gov.br/" target="_blank" rel="noopener noreferrer">Calendário com rel</a>
<a href="servicos.html" target="_blank">Serviços do próprio site</a>
`;
const { document } = new JSDOM(html, { url: 'https://patafirme.com.br/' }).window;
for (const a of document.querySelectorAll('a[target="_blank"]')) {
console.log({
texto: a.textContent,
rel: a.rel || '(vazio)',
temNoopener: a.relList.contains('noopener'),
externo: new URL(a.href).origin !== 'https://patafirme.com.br',
});
}O terceiro caso é o que mais aparece em site de iniciante: target="_blank" num
link para uma página do próprio site. Isso enche o navegador da pessoa de abas e
quebra o botão de voltar, que é o botão mais usado da web. Aba nova só para
destino de fora, e por escolha do leitor.
mailto, tel e wa.me: quando o link entrega você a outro aplicativo
Nem todo href é uma página. Alguns esquemas mandam o sistema operacional abrir
outro programa — e o do WhatsApp, apesar da fama, é um endereço https comum:
<a href="mailto:agenda@patafirme.com.br">Agendar por e-mail</a>
<a href="tel:+551140028922">(11) 4002-8922</a>
<a href="https://wa.me/5511999998888">Falar no WhatsApp</a>O mailto aceita assunto e corpo prontos, na forma de parâmetros. Como esse
texto tem espaço e acento, ele precisa ser codificado — e a função que faz isso
é a mesma que você usa em JavaScript:
const assunto = 'Consulta para o Thor (labrador)';
const corpo = 'Olá! Queria agendar para quinta à tarde.';
const href =
'mailto:agenda@patafirme.com.br' +
`?subject=${encodeURIComponent(assunto)}` +
`&body=${encodeURIComponent(corpo)}`;
console.log(href);
const url = new URL(href);
console.log('\nprotocol:', url.protocol);
console.log('pathname:', url.pathname);
console.log('subject :', url.searchParams.get('subject'));
const telefone = '(11) 4002-8922';
console.log('\ntel href:', 'tel:+55' + telefone.replace(/\D/g, ''));
console.log('wa href :', 'https://wa.me/55' + telefone.replace(/\D/g, ''));protocol: mailto: pathname: agenda@patafirme.com.br subject : Consulta para o Thor (labrador)
tel href: tel:+551140028922 wa href : https://wa.me/551140028922
Três coisas que valem a pena guardar. O espaço virou %20 e o á virou %C3%A1
— endereço não carrega esses caracteres crus. O tel: precisa começar com + e
o código do país: é o que a RFC 3966 chama de número global, e é a única forma
que continua certa para quem clica de fora do Brasil. Hífen, ponto e parênteses
são separadores visuais permitidos pela mesma RFC; espaço não é — e o espaço
é justamente o que vem junto quando alguém copia o telefone do rodapé. Gerar o
href com replace(/\D/g, ''), como no código acima, encerra a discussão. E o
wa.me é um endereço https comum: funciona no celular chamando o aplicativo e
no computador abrindo o WhatsApp Web, o que o esquema whatsapp:// não faz.
download: o atributo que o servidor nem vê
O atributo download pede ao navegador para salvar o arquivo em vez de abrir:
<a href="arquivos/tabela-precos.pdf" download="pata-firme-precos.pdf">
Baixar a tabela de preços
</a>O valor do atributo é o nome sugerido para o arquivo salvo. E “sugerido” é a palavra certa: nada disso viaja pela rede. Servindo o mesmo PDF pelo servidor de teste e olhando os cabeçalhos da resposta:
curl -s -I "http://localhost:4321/arquivos/tabela-precos.pdf"Nenhum Content-Disposition, nenhuma menção ao nome pata-firme-precos.pdf. O
download é uma decisão que acontece só no navegador, e ele é ignorado quando o
arquivo está em outro domínio — regra de segurança, para um site não conseguir
forçar download de arquivo alheio com nome falso.
O texto do link é a interface — e “clique aqui” quebra ela
Quem usa leitor de tela costuma pedir a lista de links da página, para navegar sem ler tudo. Nessa lista o link aparece sozinho, fora do parágrafo. É por isso que “clique aqui” é problema, e não frescura:
import { JSDOM } from 'jsdom';
const pagina = `
<p>Consulta de rotina. <a href="servicos.html">Clique aqui</a> para ver os preços.</p>
<p>Vacina antirrábica. <a href="vacinas.html">Clique aqui</a> para o calendário.</p>
<p>Castração. <a href="castracao.html">Saiba mais</a>.</p>
<p>Endereço: <a href="mapa.html">https://patafirme.com.br/mapa.html</a></p>
`;
const { document } = new JSDOM(pagina, { url: 'https://patafirme.com.br/' }).window;
const nomes = [...document.querySelectorAll('a')].map((a) => a.textContent.trim());
console.log('Lista de links da página:');
nomes.forEach((n, i) => console.log(` ${i + 1}. ${n}`));
const repetidos = nomes.filter((n, i) => nomes.indexOf(n) !== i);
console.log('\nTextos repetidos apontando para destinos diferentes:', [...new Set(repetidos)]);Textos repetidos apontando para destinos diferentes: [ ‘Clique aqui’ ]
Essa lista é inútil. Dois links com o mesmo nome levam a lugares diferentes, um terceiro não diz nada e o quarto obriga a pessoa a ouvir um endereço letra por letra. A versão que resolve tudo não é mais longa que a original:
<p>Consulta de rotina. <a href="servicos.html">Ver a tabela de preços</a>.</p>
<p>Vacina antirrábica. <a href="vacinas.html">Ver o calendário de vacinas</a>.</p>
<p>Castração. <a href="castracao.html">Como funciona a castração</a>.</p>
<p>Endereço: <a href="mapa.html">Rua das Acácias, 120, no mapa</a>.</p>O buscador usa exatamente o mesmo sinal: o texto do link é a principal pista que ele tem sobre o conteúdo do destino. Escrever bem aqui melhora acessibilidade e busca com a mesma frase. O assunto tem mais camadas em acessibilidade em HTML.
Auditando a página inteira antes do deploy
Agora o pedaço que ninguém faz e devia. Este script abre o site/index.html da
clínica — o mesmo do servidor de teste, com o menu e os links de contato —,
resolve cada href contra o endereço de produção e confere, arquivo por arquivo,
se o destino existe, respeitando maiúscula e minúscula:
import { readFileSync, readdirSync } from 'node:fs';
import { dirname, join, basename } from 'node:path';
import { JSDOM } from 'jsdom';
const PASTA = 'site';
const SITE = 'https://patafirme.com.br';
const PAGINA = `${SITE}/index.html`;
const { document } = new JSDOM(readFileSync(join(PASTA, 'index.html'), 'utf8'), {
url: PAGINA,
}).window;
// existe um arquivo com ESTE nome, respeitando maiúscula e minúscula?
const existeExato = (caminho) => {
try {
return readdirSync(join(PASTA, dirname(caminho))).includes(basename(caminho));
} catch {
return false;
}
};
const linhas = [];
for (const a of document.querySelectorAll('a[href]')) {
const escrito = a.getAttribute('href');
const url = new URL(escrito, PAGINA);
let situacao;
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
situacao = `outro app (${url.protocol.slice(0, -1)})`;
} else if (url.origin !== SITE) {
situacao = 'site externo';
} else if (url.hash && url.pathname === '/index.html') {
situacao = document.getElementById(url.hash.slice(1)) ? 'âncora ok' : 'ÂNCORA SEM ALVO';
} else {
let arquivo = url.pathname.slice(1);
if (arquivo === '' || arquivo.endsWith('/')) arquivo += 'index.html';
situacao = existeExato(arquivo) ? 'ok' : '404 EM PRODUÇÃO';
}
linhas.push([escrito, url.href.replace(SITE, '~'), situacao]);
}
const larg = (i) => Math.max(...linhas.map((l) => l[i].length));
console.log(`(~ = ${SITE})\n`);
for (const [escrito, virou, situacao] of linhas) {
console.log(`${escrito.padEnd(larg(0))} ${virou.padEnd(larg(1))} ${situacao}`);
}index.html ~/index.html ok servicos.html ~/servicos.html ok contato/ ~/contato/ ok #horarios ~/index.html#horarios âncora ok Servicos.html ~/Servicos.html 404 EM PRODUÇÃO https://wa.me/5511999998888 https://wa.me/5511999998888 site externo mailto:agenda@patafirme.com.br mailto:agenda@patafirme.com.br outro app (mailto) tel:+551140028922 tel:+551140028922 outro app (tel) arquivos/tabela-precos.pdf ~/arquivos/tabela-precos.pdf ok https://vacinacao.sp.gov.br/ https://vacinacao.sp.gov.br/ site externo /politica-de-privacidade ~/politica-de-privacidade 404 EM PRODUÇÃO equipe.html ~/equipe.html 404 EM PRODUÇÃO
Doze links, três problemas. equipe.html é a página que ainda não foi criada.
/politica-de-privacidade é o link que alguém copiou de outro site. E
Servicos.html, com S maiúsculo, é o mais perigoso dos três — porque na máquina
de quem escreveu ele funciona.
O link que só funciona na sua máquina
Duas armadilhas moram aqui, e nenhuma delas aparece antes do deploy.
A primeira é abrir o site com dois cliques no arquivo, o que usa o esquema
file://. Nesse esquema a “raiz” não é o site: é a raiz do computador.
const hrefs = ['servicos.html', '/politica-de-privacidade', 'contato/', '//vacinacao.sp.gov.br'];
const bases = {
'na sua máquina': 'file:///Users/ana/pata-firme/index.html',
'em produção ': 'https://patafirme.com.br/index.html',
};
for (const href of hrefs) {
console.log(href);
for (const [nome, base] of Object.entries(bases)) {
console.log(` ${nome} -> ${new URL(href, base).href}`);
}
}O caminho com barra inicial apontou para file:///politica-de-privacidade, a
raiz do disco. E o endereço sem protocolo, //vacinacao.sp.gov.br, virou
file://vacinacao.sp.gov.br/ — em produção ele funciona, aberto do disco ele não
existe. É por isso que se testa com um servidor local, e não com dois cliques no
arquivo.
A segunda armadilha é a caixa das letras, e ela é a mais cruel. O Servicos.html
do menu foi para o servidor de teste e não deu erro nenhum:
for p in /servicos.html /Servicos.html /equipe.html; do
curl -s -o /dev/null -w "%{http_code} $p\n" "http://localhost:4321$p"
doneDuzentos nos dois. O motivo é o disco: o macOS formata em APFS sem diferenciar maiúscula de minúscula, e o Linux do servidor de produção diferencia. O Node mostra a contradição de forma bem direta:
import { existsSync, readdirSync } from 'node:fs';
console.log("existsSync('Servicos.html') :", existsSync('site/Servicos.html'));
console.log('nome exato na pasta :', readdirSync('site').includes('Servicos.html'));
console.log('arquivos que existem de verdade :', readdirSync('site').filter((n) => n.endsWith('.html')));O sistema diz que o arquivo existe e a listagem da pasta diz que não. Quem
resolve a diferença é o servidor de produção, publicando um
Failed to load resource: 404 para o
seu cliente. Por isso o auditor da seção anterior compara com readdirSync em
vez de existsSync: rodando no seu Mac, ela é a única que reproduz o Linux.
A saída disso é uma convenção, não disciplina: nome de arquivo e de pasta sempre
em minúsculo, sem acento e com hífen no lugar do espaço. tabela-precos.pdf,
nunca Tabela de Preços.pdf.
O próximo passo
Rode o auditor na sua própria página antes do próximo deploy — são menos de
cinquenta linhas e ele acha o que você não vê relendo o HTML. Depois disso, a próxima peça da
página da clínica é a foto dos animais atendidos, que é
imagens em HTML: a tag img usa o mesmo mecanismo de
resolução de caminho que você acabou de ver, e sofre exatamente das mesmas
armadilhas de caixa e de file://. Se quiser conferir onde esta lição entra na
sequência, o guia de HTML mostra a ordem inteira e a
trilha de HTML lista as lições publicadas.
Perguntas frequentes
Quando usar caminho relativo e quando usar caminho começando com barra?
Posso colocar uma imagem dentro de um link?
Preciso de rel="noopener" em link para uma página do meu próprio site?
Link de WhatsApp funciona em computador?
Por que meu link para uma seção não desce a página?
Dúvidas e comentários
Travou em algum passo? Pergunte aqui — a equipe e outros alunos respondem.
Entrar para perguntarÉ o mesmo login gratuito dos cursos.
Nenhuma dúvida por aqui ainda — a primeira pode ser a sua.
Todo o código deste artigo foi executado em Node 24.16.0 com jsdom 30.0.1 e Express 5.2.1, e as saídas exibidas são as reais — como produzimos este conteúdo.
Fontes consultadas
- MDN — O elemento <a> — developer.mozilla.org
- WHATWG HTML Standard — Links — html.spec.whatwg.org
- RFC 6068 — The 'mailto' URI Scheme — rfc-editor.org
- RFC 3966 — The 'tel' URI for Telephone Numbers — rfc-editor.org
- WHATWG URL Standard — URL parsing — url.spec.whatwg.org


