간단한 SPA 라우터를 직접 만들다 보면 금방 지저분해진다. 링크 클릭은 click 이벤트로 가로채서 preventDefault 하고, 주소는 history.pushState로 바꾸고, 뒤로/앞으로 가기는 또 popstate 이벤트로 따로 처리해야 한다. 가로채는 지점이 두 군데라 로직이 쪼개지고, 어느 한쪽을 빠뜨리기 쉽다.
navigate 이벤트 하나로 묶기
Navigation API는 페이지 내 모든 이동 — 링크 클릭이든 뒤로 가기든 location.href 변경이든 — 을 navigate 이벤트 하나로 흘려보낸다. intercept()로 가로채고 핸들러만 넘기면 끝이다.
navigation.addEventListener('navigate', (event) => {
// 같은 출처가 아니거나 가로채면 안 되는 이동은 그냥 넘긴다
if (!event.canIntercept || event.hashChange || event.downloadRequest) {
return;
}
const url = new URL(event.destination.url);
if (url.origin !== location.origin) return;
event.intercept({
async handler() {
const html = await loadRoute(url.pathname);
document.querySelector('#app')!.innerHTML = html;
},
});
});
pushState 호출도, popstate 리스너도 없다. 뒤로 가기를 눌러도 같은 navigate 이벤트가 발동하므로 핸들러가 그대로 재사용된다. 주소 표시줄 갱신은 브라우저가 알아서 한다.
로딩·취소까지 공짜로 따라온다
intercept()가 반환하는 Promise가 곧 "전환 진행 중" 상태다. 전환 중에 사용자가 다른 링크를 누르면 이전 핸들러의 signal이 자동으로 abort된다. 데이터 페칭에 그 신호를 그대로 물려주면 취소가 공짜로 처리된다.
event.intercept({
async handler() {
showSpinner();
const res = await fetch(`/api/page?path=${url.pathname}`, {
signal: event.signal, // 다음 이동이 시작되면 자동 취소
});
render(await res.json());
hideSpinner();
},
});
전환 성공/실패는 navigatesuccess, navigateerror 이벤트로도 들어온다. 로딩 바를 한곳에서 켜고 끄기 좋다.
주의할 점
- Chrome·Edge는 잘 되지만 Safari는 2026년에도 아직 부분 지원이고 Firefox도 뒤처져 있다. 메인 라우터로 쓸 땐
'navigation' in window로 감지해서 폴백을 준비하자. - 가로채면 안 되는 경우(다운로드 링크, 외부 출처, 해시 이동)를 위에서 먼저 걸러내야 한다. 안 그러면 파일 다운로드까지 막아버린다.
링크 클릭과 뒤로 가기를 따로 붙잡던 코드가 navigate 이벤트 하나로 합쳐진다. 로딩 상태와 취소 신호까지 이벤트에 딸려 오니, 프레임워크 없이 라우팅을 다룰 때 history API를 손으로 꿰매던 수고가 확 줄어든다.