Next.js 정적 블로그를 React Router 8로 옮긴 후기 (prerender)

Next.js 13 정적 export(output export)로 만든 블로그를 React Router 8 framework mode와 React 19로 옮겼습니다. 주소·메타 태그·사이트맵을 그대로 유지하면서 prerender로 정적 빌드한 방법과 겪은 문제를 정리합니다.

Next.js에서 React Router 8로 옮긴 이유

이 블로그는 처음에 Next.js 13의 정적 내보내기(output: 'export')로 만들었습니다. 정적 내보내기란 서버 없이, 빌드할 때 모든 페이지를 HTML 파일로 미리 만들어 두는 방식입니다. 그 HTML을 Cloudflare Pages에 올려 서비스하고 있었습니다.

next.config.js는 이렇게 단순했습니다.

const {withContentlayer} = require("next-contentlayer");

const nextConfig = {
  reactStrictMode: true,
  output: 'export',
  pageExtensions: ["js", "jsx", "ts", "tsx", "md", "mdx"]
};

module.exports = withContentlayer(nextConfig);

문제는 서버 기능을 하나도 쓰지 않는데도 Next.js에 묶여 있는 부분이 많았다는 점입니다. Next 13.4, React 18에 머물러 있었고, 글을 MDX로 변환하는 Contentlayer도 next-contentlayer로 Next에 붙어 있었습니다. 메타 태그(next-seo), 사이트맵(next-sitemap)도 모두 Next 전용 패키지였습니다.

그래서 정적 빌드(prerender)를 기본으로 지원하는 React Router 8의 framework mode로 옮기기로 했습니다. framework mode는 React Router를 Vite 기반 프레임워크처럼 쓰는 방식으로, 예전 Remix가 이쪽으로 합쳐졌습니다. 목표는 하나였습니다.

외부에서 보이는 것(주소, 메타 태그, 사이트맵, 화면)은 하나도 바꾸지 않고 내부 프레임워크만 바꾼다.

검색엔진에 이미 등록된 주소와 평가를 잃지 않기 위해서입니다.

prerender 설정: 서버 없이 모든 페이지를 HTML로

React Router에서 정적 사이트를 만들려면 react-router.config.ts에서 ssr: false로 서버 렌더링을 끄고, prerender로 빌드할 경로를 알려 주면 됩니다1.

import type {Config} from "@react-router/dev/config";

export default {
  appDirectory: "src/app",
  buildDirectory: "build",
  ssr: false,
  prerender: ({getStaticPaths}) => [...getStaticPaths(), "/404", "/en/404", "/ja/404"],
} satisfies Config;

getStaticPaths()는 라우트 설정에 적힌 고정 경로를 전부 돌려줍니다. 이 블로그는 뒤에서 설명할 것처럼 모든 경로를 직접 적어 두었기 때문에 이것만으로 모든 페이지가 나옵니다. /404, /en/404, /ja/404는 언어별 404 페이지입니다.

loader는 빌드할 때만 실행된다

React Router의 라우트는 loader라는 함수로 데이터를 받습니다. 원래는 요청이 올 때마다 서버에서 실행되지만, ssr: false + prerender에서는 빌드할 때 한 번만 실행됩니다. 결과는 두 군데에 저장됩니다.

  • 페이지 HTML 안 (처음 접속했을 때 쓰임)
  • <경로>.data 파일 (사이트 안에서 링크로 이동할 때 브라우저가 이 파일만 받아 감)

덕분에 전체 글 데이터를 loader 안에서만 다루면 브라우저용 JavaScript에는 그 페이지에 필요한 데이터만 들어갑니다. 반대로 화면 컴포넌트에서 전체 글 목록을 import 하면 모든 글이 브라우저 번들에 같이 들어가 버리니 주의해야 합니다.

한 가지 함정은 loader에 들어오는 주소입니다. 이동용 .data 파일을 만들 때는 요청 주소가 /x.data(첫 화면은 /_.data)로 들어옵니다. 그래서 주소에서 언어와 경로를 꺼내는 함수를 따로 만들었습니다.

export function requestPath(request: Request): [Locale, string[]] {
  const pathname = new URL(request.url).pathname.replace(/\.data$/, '').replace(/^\/_$/, '/');
  const [locale, rest] = splitLocalePath(pathname);
  return [locale, rest.split('/').filter(Boolean).map(decodeURIComponent)];
}

Next Metadata API를 meta 함수로 다시 만들기

Next.js에서는 generateMetadata가 객체를 돌려주면 Next가 title, description, canonical, og 태그, twitter 카드까지 알아서 만들어 줍니다. React Router에는 이런 기능이 없고, 라우트의 meta 함수가 태그 목록을 직접 돌려줘야 합니다.

태그가 한두 개라도 달라지면 검색 결과에 영향을 줄 수 있어서, Next가 만들던 HTML과 같은 태그가 나오도록 변환 함수 seoMeta를 만들었습니다. Next용으로 만들어 두었던 메타데이터 객체는 거의 그대로 loader로 옮기고, meta에서 이 함수로 태그로 바꿉니다.

// 글 페이지 라우트
export function loader({request}: LoaderFunctionArgs) {
  const [locale, [slug]] = requestPath(request);
  const found = postData[locale].allPostsMap.get(slug);
  // ...
  const metadata: Metadata = {
    title: post.title,
    description: post.description,
    keywords: post.tags,
    alternates: {canonical: `${SITE_URL}${post.url}`, languages: /* hreflang */},
    openGraph: {type: 'article', title: post.title, /* ... */},
  };
  return {locale, metadata, post, jsonLd};
}

export const meta: MetaFunction<typeof loader> = ({loaderData}) => {
  if (!loaderData) return [];
  const tags = seoMeta(loaderData.locale, loaderData.metadata);
  if (loaderData.jsonLd) tags.push({'script:ld+json': loaderData.jsonLd});
  return tags;
};

seoMeta는 Next가 하던 일을 그대로 따라 합니다. 제목 뒤에 | Snc Studio를 붙이고, openGraph가 있으면 twitter:card도 만들어 줍니다. next-seo의 ArticleJsonLd로 넣던 구조화 데이터(JSON-LD)는 script:ld+json meta로 바꿨습니다.

옮긴 뒤에는 예전 빌드 결과 HTML과 새 HTML의 <head>를 비교해서 빠진 태그가 없는지 확인하는 것이 좋습니다. 사이트맵(next-sitemap)도 같은 형식의 XML을 내는 리소스 라우트(HTML 대신 파일을 돌려주는 라우트)로 바꿨습니다.

:slug 대신 모든 경로를 직접 등록한 이유

React Router에서는 보통 글 페이지를 :slug 같은 동적 경로 하나로 등록합니다. 하지만 이 블로그는 src/app/routes.ts에서 글 데이터를 읽어 모든 경로를 하나씩 등록했습니다. 같은 라우트 모듈을 id만 다르게 여러 번 등록하는 방식입니다.

function localeRoutes(locale: Locale): RouteConfigEntry[] {
  const prefix = locale === DEFAULT_LOCALE ? '' : `${locale}/`;
  const {allPostsMap, allPagedPostsMap, pagedCategoryPostMap, pagedTagPostMap} = postData[locale];
  const routes: RouteConfigEntry[] = [];
  const add = (path: string, file: string) => {
    // ...
    routes.push(route(`${prefix}${path}`, file, {id: `${locale}:${path}`}));
  };

  for (const slug of allPostsMap.keys()) add(slug, 'routes/post.tsx');
  // 카테고리·태그·페이지 번호 경로도 같은 방식으로 추가
  routes.push(route(`${prefix}*`, 'routes/not-found.tsx', {id: `${locale}:not-found`}));
  return routes;
}

이유는 404 페이지입니다. 없는 주소로 들어오면 Cloudflare Pages는 404.html을 내려줍니다. 그다음 브라우저에서 React가 그 주소로 라우트를 다시 찾는데(hydrate), :slug가 있으면 아무 주소나 글 페이지와 매칭되어 404 화면이 "글을 찾을 수 없음" 화면으로 바뀌어 버립니다.

모든 경로를 직접 적어 두면, 없는 주소는 항상 맨 끝의 *(나머지 전부와 매칭되는 splat 라우트)에만 걸립니다. 그래서 미리 만들어 둔 404 화면이 그대로 유지됩니다. prerender에 /404를 따로 넣은 것도 이 splat 라우트를 HTML로 만들기 위해서입니다.

대신 단점도 있습니다. routes.ts는 개발 서버가 시작할 때만 읽기 때문에, 새 글을 추가하거나 카테고리·태그가 생기면 개발 서버를 다시 켜야 합니다.

주소 유지: x/index.html을 x.html로

React Router의 prerender는 /x를 x/index.html로 만듭니다. 그런데 Cloudflare Pages는 이 경우 /x를 /x/로 리다이렉트합니다. Next.js는 x.html로 만들었기 때문에 기존 주소는 끝에 /가 없었습니다.

그래서 빌드 후 스크립트에서 x/index.html을 x.html로 옮겼습니다. 이 부분은 내용이 길어서 Cloudflare Pages 끝 슬래시(/) 자동 리다이렉트 없애는 방법에 따로 정리했습니다.

package.json의 빌드 명령은 이렇게 바뀌었습니다.

"build": "bun scripts/contentlayer.ts && react-router build && bun scripts/postbuild.ts",

Contentlayer를 Next 없이 쓰기

Contentlayer는 MDX 글을 JSON과 JavaScript로 변환해 주는 도구입니다. Next.js에서는 next-contentlayer가 빌드에 끼워 넣어 줬는데, 이제는 직접 실행해야 했습니다. 여기서 문제가 세 가지 있었습니다.

CLI 대신 스크립트로 변환하기

Contentlayer CLI(contentlayer build)는 변환은 성공하는데, 최신 Node에서 끝날 때 process.exitCode 관련 오류를 내며 실패로 끝났습니다. 그래서 CLI의 build 명령이 하는 일을 scripts/contentlayer.ts에서 직접 실행했습니다.

process.env.NODE_ENV = 'production';

import * as core from "@contentlayer/core";
import {pipe, T} from "@contentlayer/utils/effect";

const generate = () => pipe(
  core.getConfig({configPath: undefined}),
  T.chain(config => core.generateDotpkg({config, verbose: false})),
  T.tap(core.logGenerateInfo),
  core.runMain({tracingServiceName: 'contentlayer-cli', verbose: false}),
);

await generate();

개발할 때는 --watch 옵션을 주면 src/posts를 감시하다가 글이 바뀔 때마다 다시 변환하도록 했습니다.

개발 모드로 변환하면 React 19에서 렌더링 실패

맨 위의 process.env.NODE_ENV = 'production'이 중요합니다. Contentlayer가 MDX를 묶을 때 쓰는 mdx-bundler는 NODE_ENV가 production이 아니면 React의 개발용 jsx-runtime을 MDX 코드 안에 같이 넣습니다. 이 코드가 React 19 production 빌드와 맞지 않아서 글 본문이 렌더링되지 않았습니다.

production으로 변환한 결과는 개발 서버에서도 문제없이 동작하기 때문에 항상 production으로 고정했습니다.

Vite가 import assert를 읽지 못함

Contentlayer가 만든 .contentlayer/generated/index.mjs는 import ... assert {type: 'json'} 구문으로 JSON을 불러옵니다. 이 구문은 이제 with {type: 'json'}으로 바뀐 옛 문법이라 Vite에서 읽지 못했습니다. 그래서 index.mjs를 거치지 않고 JSON 파일을 직접 import 했습니다. 타입만 Contentlayer가 만든 것을 씁니다.

import type {Post} from "@/contentlayer/generated";
import allPostsJson from "../../.contentlayer/generated/Post/_index.json";

const allPosts = allPostsJson as unknown as Post[];

상대 경로를 쓴 이유는 이 파일을 routes.ts에서도 읽기 때문입니다. routes.ts는 Vite가 처리하기 전에 실행되어 @/ 같은 경로 별칭이 통하지 않습니다.

그 밖에 바꾼 것

  • MDX 렌더링: next-contentlayer/hooks의 useMDXComponent와 같은 구현을 20줄 정도의 작은 함수로 직접 만들었습니다. Contentlayer가 만든 코드 문자열을 new Function으로 실행해 React 컴포넌트를 꺼내는 방식입니다.
  • 링크 버튼: Next의 <Link legacyBehavior> 안에 버튼을 넣던 곳은, 버튼에 href와 클릭 시 이동을 붙여 주는 LinkButton 컴포넌트로 바꿔 같은 DOM이 나오게 했습니다.
  • 루트 데이터: 사이드바의 카테고리·태그 개수 같은 공통 데이터는 루트 loader에서 한 번만 받고, shouldRevalidate가 false를 돌려주게 해서 페이지를 옮길 때마다 다시 받지 않게 했습니다.
  • 다크 모드: next-themes는 이름과 달리 Next 없이도 동작해서 그대로 썼습니다.

빠진 패키지는 next, next-contentlayer, next-seo, next-sitemap, @next/mdx, eslint-config-next 등이고, React는 18에서 19로 올라갔습니다.

옮기고 나서

외부에서 보이는 것은 의도대로 거의 그대로입니다. 주소, 메타 태그, 사이트맵 형식을 예전과 같게 맞췄기 때문에 검색엔진에 등록된 주소를 그대로 쓸 수 있습니다. 내부적으로는 Next 전용 패키지가 모두 빠지고 Vite 기반이 되어 구조가 단순해졌습니다.

정리하면, 서버 기능을 쓰지 않는 정적 블로그라면 React Router 8의 ssr: false + prerender로 충분히 옮길 수 있습니다. 다만 다음 세 가지는 미리 챙겨 두는 것이 좋습니다.

  • 메타 태그는 자동으로 만들어지지 않으니, 옮기기 전후 HTML의 <head>를 직접 비교합니다.
  • Cloudflare Pages처럼 index.html 위치로 주소 끝 /를 정하는 호스팅이라면 파일 구조를 확인합니다.
  • 404 페이지가 다른 화면으로 바뀌지 않는지, 없는 주소로 직접 들어가서 확인합니다.

언어별 자동 이동은 Cloudflare Pages Functions로 브라우저 언어에 맞춰 자동 이동하는 방법에 정리했습니다.

참조

Footnotes

  1. https://reactrouter.com/how-to/pre-rendering ↩

2026 © snc studio·개인정보처리방침