RETROSPECTIVE · 회고
Next.js 정적 export에서 useSearchParams가 페이지를 비워버리는 이유
Next/Express
개인공부 목록 페이지를 output: "export"로 정적 생성했는데, 빌드된 HTML을 열어보니 글 링크가 하나도 없었다. 브라우저에서는 멀쩡히 보이는데 크롤러가 받는 HTML에는 목록이 통째로 빠져 있었다.
원인: CSR bailout
카테고리를 URL로 유지하려고 ?category=Java를 useSearchParams()로 읽고 있었다. 쿼리스트링은 요청마다 달라지는 값이라 빌드 시점에는 알 수 없다. 그래서 Next.js는 정적 렌더링 중 useSearchParams를 만나면 가장 가까운 Suspense 경계까지를 서버에서 그리지 않고 클라이언트 렌더링으로 넘긴다(client-side rendering bailout).
- Suspense로 감싸지 않으면 production 빌드에서 에러가 난다.
- Suspense로 감싸면 빌드는 되지만, 경계 안쪽은 HTML에 fallback만 남는다.
빌드 결과물을 검색해보면 BAILOUT_TO_CLIENT_SIDE_RENDERING 표시가 남아 있는 걸 확인할 수 있다.
해결: 초기 렌더에서는 쿼리를 읽지 않는다
SEO가 필요한 영역은 쿼리와 무관하게 전부 렌더링하고, 선택 상태만 마운트 후에 반영하도록 바꿨다.
const [selected, setSelected] = useState(CATEGORIES[0]);
useEffect(() => {
// 정적 HTML에는 기본값으로 그려지고, 브라우저에서만 URL 값을 반영
const fromUrl = new URLSearchParams(window.location.search).get("category");
if (isCategory(fromUrl)) setSelected(fromUrl);
}, []);
return CATEGORIES.map((cat) => (
// 모든 카테고리 목록을 HTML에 남기고 선택된 것만 보이게
<section key={cat} hidden={cat !== selected}>…</section>
));
이렇게 하면 빌드된 HTML에 모든 글 링크가 남고, 사용자는 기존처럼 카테고리를 전환할 수 있다.
정리
- 정적 export에서
useSearchParams는 사실상 "이 영역은 클라이언트에서 그린다"는 선언이다. - 검색엔진에 보여야 하는 콘텐츠는 요청 시점 값(쿼리, 쿠키 등)에 의존하지 않게 렌더링한다.
- 눈으로 확인하는 것만으로는 모른다. 빌드 결과 HTML을 직접 열어보는 습관이 필요하다.