직접 운영하는 블로그를 Cloudflare Pages로 옮기면서 생각보다 많이 헤맸습니다. Next.js 16의 정적 export 자체는 단순한 모델인데, 호스팅 쪽 빌드 설정이 한 칸 어긋나면 페이지 일부가 통째로 깨지는 식의 부분 실패가 발생합니다. 같은 함정을 다시 밟지 않으려고 메모를 남깁니다.

"Next.js 프리셋"이 정답이 아니다#

Cloudflare Pages는 새 프로젝트를 만들 때 프레임워크 프리셋을 자동으로 추천합니다. 저장소에 next.config.ts가 있으면 기본값이 Next.js로 잡힙니다. 문제는 이 프리셋이 @cloudflare/next-on-pages를 빌드 명령으로 사용하고, 출력 디렉터리를 .vercel/output/static으로 설정한다는 점입니다. SSR 런타임을 가정한 구성입니다.

output: "export"로 정적 사이트를 만들고 있다면 이 프리셋이 어긋납니다. 실제로 빌드는 통과하는 경우가 있고, 홈은 멀쩡한데 일부 라우트에서 Node.JS Compatibility Error 페이지가 뜨는 식으로 부분 실패가 일어납니다. 코드 쪽 로그만 보면 원인을 잡기 어렵습니다.

저장소에 맞는 올바른 조합은 다음과 같았습니다.

  • Framework preset: Next.js (Static HTML Export)
  • Build command: npx next build
  • Build output directory: out
  • Production branch: main

이 한 칸짜리 설정이 가장 흔한 실수 지점이었습니다. 한 번 어긋나면 코드 쪽에서는 아무리 손봐도 답이 나오지 않습니다.

환경변수는 빌드 시점에 박힌다#

정적 export는 런타임이 없습니다. 모든 페이지가 빌드 시 HTML로 굳어져서 CDN으로만 흐릅니다. NEXT_PUBLIC_SITE_URL 같은 값이 sitemap, canonical, RSS 피드에 들어가야 한다면, 그 값은 Cloudflare Pages 프로젝트 설정의 환경변수에서 와야 합니다.

const SITE_URL =
  process.env.NEXT_PUBLIC_SITE_URL?.replace(/\/$/, "") ??
  "https://lifeisonecoin.com";

export const siteMetaBase = new URL(SITE_URL);

저는 fallback 값까지 운영 도메인으로 박아 두었습니다. 환경변수가 비어 있어도 결과물이 엉뚱한 도메인으로 빠지지 않게 하기 위해서입니다. canonical URL을 어떻게 설계했는지에 대해서는 Next.js 정적 블로그 SEO 설정 글에 더 자세히 적었습니다.

주의할 점은 로컬 .env.local이 운영 환경변수와 다른 값을 가질 수 있다는 사실입니다. 운영 빌드는 Cloudflare 쪽 값으로 덮이니 영향이 없지만, 로컬에서 운영 도메인 기준 산출물을 검증하고 싶다면 OS 환경변수를 우선 적용해야 합니다.

NEXT_PUBLIC_SITE_URL=https://lifeisonecoin.com npm run build

Next.js는 OS env를 .env.local보다 우선시하기 때문에, 이 한 줄로 빌드 산출물의 도메인을 운영 기준으로 강제할 수 있습니다.

trailingSlash와 정적 export의 디렉터리 구조#

trailingSlash: true는 정적 export와 궁합이 좋습니다. 모든 라우트가 posts/slug/index.html 형태로 만들어지고, Cloudflare Pages는 디렉터리 인덱스를 자동으로 처리해 줍니다. 슬래시 없는 URL로 접근해도 같은 페이지가 정상 응답합니다.

문제는 canonical URL과의 일관성입니다. canonical을 https://lifeisonecoin.com/posts/slug처럼 슬래시 없이 적어 두면, 실제 호스팅에서 응답하는 정답 URL은 슬래시가 포함된 쪽이 됩니다. 검색 엔진이 두 URL을 같은 페이지로 통합하기 전까지는 색인이 분산될 수 있습니다. canonical, sitemap, 내부 링크의 슬래시 정책을 한 가지로 통일하는 편이 안전합니다.

export function postUrl(slug: string) {
  return `${SITE_URL}/posts/${slug}/`;
}

저는 헬퍼 함수 하나로 강제했습니다. 모든 곳에서 이 함수를 통해 URL을 생성하면 슬래시 정책이 우연히 어긋날 일이 없습니다.

이미지와 정적 자산#

next/image는 기본적으로 런타임 최적화를 가정합니다. 정적 export에서는 이게 동작하지 않으니 images.unoptimized: true를 설정해야 합니다. 이걸 빠뜨리면 빌드 시점에 명시적인 에러가 발생해서 그나마 빠르게 발견할 수 있습니다.

const nextConfig: NextConfig = {
  output: "export",
  trailingSlash: true,
  images: { unoptimized: true },
};

자산 경로는 public/에 두는 게 가장 단순합니다. public/og-image.png는 빌드 후 out/og-image.png가 되고, 그대로 https://lifeisonecoin.com/og-image.png에서 서빙됩니다. CDN 캐시 정책을 따로 만질 필요도 없습니다.

배포 후 30초 체크리스트#

빌드가 성공해도 한 번씩 잘못 나가는 경우가 있어서, 배포 직후 빠르게 도는 점검 항목을 만들어 두었습니다.

  • https://lifeisonecoin.com/sitemap.xml이 운영 도메인으로 응답하는지
  • https://lifeisonecoin.com/feed.xml이 200 OK이고 <description>이 CDATA로 감싸져 있는지
  • 대표 포스트 한 편의 <link rel="canonical">이 정상 도메인을 가리키는지
  • <link rel="alternate" type="application/rss+xml">이 모든 포스트 페이지에 남아 있는지

코드 쪽 SEO 설정이 아무리 정확해도 호스팅 쪽 한 칸이 어긋나면 결과물이 무너집니다. Cloudflare Pages는 정적 export 사이트와 궁합이 좋은 편이지만, 화면이 추천하는 "기본값"이 오히려 함정인 경우가 있다는 점만 기억해 두면 디버깅 시간이 크게 줄어듭니다.