Next.js NEXT_PUBLIC 빌드타임 인라인이 정적 페이지 canonical 을 localhost 로 샌다
Docker 로 셀프호스팅한 Next.js 정적 페이지의 canonical 과 og:url 이 localhost 로 박혀 색인이 안 됐습니다. NEXT_PUBLIC 은 빌드타임에 인라인되니 build args 로 넘겨야 합니다.
Next.js(App Router) 블로그를 Docker 로 셀프호스팅하고 있었는데, Google Search Console 에 /about, /contact 같은 정적 페이지가 "발견됨 - 현재 색인이 생성되지 않음" 으로 잡혔습니다.
실서버 HTML 을 확인해 보니 이랬습니다.
<link rel="canonical" href="http://localhost:3000/about">canonical 과 og:url 이 전부 localhost 로 박혀 있었습니다. 그런데 DB 에서 동적 렌더되는 글 상세 페이지는 멀쩡했습니다.
같은 코드인데 왜 페이지마다 다를까?
사이트 URL 은 보통 이런 폴백으로 잡습니다.
process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:3000"원인은 NEXT_PUBLIC_* 환경변수가 런타임이 아니라 빌드타임에 문자열로 인라인된다는 점입니다. 빌드 컨테이너(docker build)에 변수를 넘기지 않으면 빌드 시점에는 값이 없으니, 폴백인 localhost:3000 이 코드에 그대로 박힙니다.
그런데 같은 metadataBase: new URL(SITE.url) 를 쓰는데도 페이지마다 달랐습니다. 렌더 시점이 다르기 때문입니다.
정적 생성(SSG) 페이지는 빌드 시점에 HTML 이 확정되니, 그때의 localhost 가 canonical 에 굳습니다. export const dynamic = "force-dynamic" 라우트(sitemap, feed 등)는 요청 시점에 평가됩니다. 런타임 컨테이너에는 env 가 있으니 정상이죠.
canonical 이 도달할 수 없는 호스트를 가리키면 크롤러는 색인을 꺼립니다. 단순한 오타가 아니라 색인을 적극적으로 방해하는 SEO 버그입니다.
빌드 스테이지에 직접 넘깁니다
NEXT_PUBLIC_* 는 클라이언트 번들과 정적 HTML 에 빌드타임으로 인라인되니, 빌드 스테이지에 반드시 주입해야 합니다. 멀티스테이지 Dockerfile 의 builder 단계에서 ARG 와 ENV 로 받고, compose 에서는 build args 로 전달합니다.
# builder 에 NEXT_PUBLIC_* 를 전달하지 않으면
# 정적 HTML 에 폴백값(localhost)이 인라인된다.
FROM node:20-alpine AS builder
WORKDIR /app
ARG NEXT_PUBLIC_SITE_URL
ENV NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL
COPY . .
RUN npm run build# docker-compose.yml: 호스트 .env 값을 빌드 args 로 전달
services:
web:
build:
context: .
args:
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-}
env_file: .env # 런타임용 (force-dynamic 라우트가 읽는 값)env_file 은 런타임용이라, 빌드타임 인라인 변수는 이것만으로 채워지지 않습니다. build.args 로 따로 줘야 합니다.
확인은 빌드 산출물로 합니다
런타임이 아니라 빌드 산출물을 봐야 합니다. 정적 페이지의 canonical 은 이렇게 확인합니다.
curl -s https://example.com/about | grep canonical/robots.txt 의 Sitemap:, Host: 줄도 같이 봅니다. 동적 라우트만 보면 멀쩡해 보여서 놓치기 쉽습니다.
정적 페이지는 빌드에서, 동적 라우트는 런타임에서 값이 정해진다는 걸 놓친 게 원인이었습니다. 환경변수가 빌드에 필요한지 런타임에 필요한지는 따로 구분해 둬야겠습니다.