React Router 사용 규칙
참고: https://www.heropy.dev/p/9tesDt
기존 React(Vite/CSR) 프로젝트에 React Router 8.x(7.x 호환) Data Mode(createBrowserRouter + RouterProvider)를 도입하거나, 이미 react-router가 구성된 프로젝트에 라우트/레이아웃/내비게이션/Loader 등 부분 기능을 추가하는 스킬.
이 스킬은 Data Mode 기준이다. <BrowserRouter><Routes><Route>로 구성되는 Declarative Mode 또는 Remix 기반 Framework Mode(@react-router/dev)가 필요하면 이 스킬은 적합하지 않다.
필수 실행 체크리스트 (MANDATORY)
스킬 시작 즉시, 아래 항목을 TodoWrite에 1:1로 등록한 뒤 순서대로 진행한다. 건너뛰기 금지.
- 프로젝트 상태 감지 (1단계)
- 작업 모드 결정: 초기 도입 vs 기능 추가 (2단계)
react-router 설치 [미설치 시] (3단계)
- 기본 라우터 골격 생성 [라우터 파일 없을 때] (4단계)
- 사용자가 추가로 요청한 기능을 [기능 가이드](#기능-가이드) 섹션에서 찾아 적용 (5단계)
- 최종 검증: 1단계 감지 표를 다시 돌며 누락된 자동 적용 항목이 있으면 재실행하고, lint와 build를 통과시킨다 (6단계)
각 항목은 조건 충족 시 "skipped"로 완료 처리하되, 조건 판단 근거(파일/패키지 존재 여부)를 명시한 뒤 넘어간다.
동작 흐름
1단계: 프로젝트 상태 감지
| 확인 대상 |
감지 방법 |
| React 프로젝트 |
package.json의 dependencies에 react 존재 |
| React 버전 |
package.json의 react 버전이 19.2.7 이상인지 (미만이면 v8 설치 불가, v7 사용) |
| TypeScript |
tsconfig.json 또는 tsconfig.app.json 존재 |
| react-router 설치 |
package.json의 dependencies에 react-router 존재 |
react-router-dom 사용 여부 |
package.json의 dependencies 또는 src/ 소스에 from 'react-router-dom' 임포트 존재 (v6/v7 react-router-dom -> react-router 마이그레이션 감지용) |
| 라우터 파일 |
src/routes/index.tsx 존재 여부 |
| 레이아웃 파일 |
src/routes/layouts/DefaultLayout.tsx 존재 여부 |
| 헤더 컴포넌트 |
src/components/TheHeader.tsx 존재 여부 |
main.tsx 렌더 구조 |
src/main.tsx의 createRoot(...).render(...) 자식이 (a) 단순 <App />인지, (b) 이미 <Router />/<RouterProvider>가 연결되어 있는지, (c) 다른 Provider/Wrapper(QueryClientProvider, ThemeProvider, ErrorBoundary, i18n 등)가 감싸고 있는지 |
@/* 경로 별칭 |
tsconfig 또는 vite.config에 @/* alias 존재 (이 스킬의 예제는 @/ 임포트 사용) |
| 패키지 매니저 |
pnpm-lock.yaml -> pnpm, yarn.lock -> yarn, bun.lockb 또는 bun.lock -> bun, package-lock.json 또는 lock 파일 없음 -> npm |
2단계: 작업 모드 결정
초기 도입 모드: react-router 미설치 그리고 react-router-dom 미사용 그리고 src/routes/index.tsx 없음:
- 3단계로
react-router를 설치한다
- 4단계로 기본 라우터 골격(
src/routes/index.tsx, src/routes/layouts/DefaultLayout.tsx, src/components/TheHeader.tsx, src/routes/pages/Home.tsx/About.tsx)을 자동 생성하고 src/main.tsx의 렌더 호출을 surgical하게 라우터 연결로 바꾼다(자세한 규칙은 4단계 src/main.tsx 절 참고)
- 사용자가 추가로 요청한 기능만 5단계에서 적용한다
기능 추가 모드: react-router 이미 설치되어 있거나 라우터 파일이 이미 존재:
- 3, 4단계는 skipped로 처리한다 (조건 미충족)
- 5단계에서 사용자 요청에 해당하는 기능 가이드만 골라 적용한다
마이그레이션 모드: 1단계 감지에서 react-router-dom 사용이 확인됨:
- 사용자에게
react-router-dom -> react-router 마이그레이션을 진행할지 명시적으로 확인한다. 동의 없이 임의로 임포트를 바꾸지 않는다.
- 동의가 있으면, 모든
from 'react-router-dom' 임포트를 from 'react-router'로 일괄 변경하고 react-router-dom을 제거({pm} remove react-router-dom)한 뒤 3단계로 react-router를 설치한다. 그 외 기존 라우트 구조는 그대로 둔다.
- 마이그레이션 후 사용자가 새로 요청한 기능만 5단계에서 적용한다.
이미 존재하는 파일은 덮어쓰지 않는다. 기존 파일은 사용자가 명시적으로 변경을 요청한 부분만 최소한으로 수정한다.
3단계: react-router 설치 [조건: 미설치 시]
v7부터 react-router-dom은 사용하지 않고, v8에서는 패키지 자체가 제거됐다. 항상 react-router만 설치한다.
v8은 React 19.2.7 이상, Node.js 22.22 이상이 필요하다. 1단계에서 감지한 React 버전에 따라 분기한다.
React 19.2.7 이상 (v8):
{pm} add react-router
React 19.2.7 미만 (v7 유지):
{pm} add react-router@7
{pm}은 프로젝트의 lock 파일로 판별한 패키지 매니저로 대체한다.
pnpm-lock.yaml -> pnpm, yarn.lock -> yarn, bun.lockb 또는 bun.lock -> bun, package-lock.json 또는 lock 파일 없음 -> npm.
{pmx}는 해당 패키지 매니저의 실행 명령으로 대체한다. npm -> npx, pnpm -> pnpm dlx, yarn -> yarn dlx, bun -> bunx.
4단계: 기본 라우터 골격 생성 [조건: 라우터 파일 없을 때]
다음 폴더/파일 구조를 생성한다. 이미 존재하는 파일은 건드리지 않는다.
src/
├─components/
│ └─TheHeader.tsx
├─routes/
│ ├─layouts/
│ │ └─DefaultLayout.tsx
│ ├─pages/
│ │ ├─About.tsx
│ │ └─Home.tsx
│ └─index.tsx
└─main.tsx
임포트 규칙: 상대 경로는 같은 폴더 안의 파일을 가져올 때만 쓴다. 다른 폴더의 파일은 @/ 별칭으로 가져온다.
별칭이 없으면 react-vite-scaffold 스킬의 경로 별칭 단계를 먼저 적용한다. 사용자가 거부하면 상대 경로로 바꾼다.
각 파일의 초기 내용은 다음과 같다.
// src/routes/pages/Home.tsx
export default function Home() {
return <h1>Home</h1>
}
// src/routes/pages/About.tsx
export default function About() {
return <h1>About</h1>
}
src/components/TheHeader.tsx: <Link>/<NavLink>를 쓰면 페이지 이동 시 전체가 다시 로드되지 않고 필요한 부분만 업데이트된다.
// src/components/TheHeader.tsx
import { NavLink } from 'react-router'
const navigations = [
{ to: '/', label: 'Home' },
{ to: '/about', label: 'About' }
]
export default function TheHeader() {
return (
<header>
<nav>
{navigations.map(nav => (
<NavLink
key={nav.to}
to={nav.to}>
{nav.label}
</NavLink>
))}
</nav>
</header>
)
}
src/routes/layouts/DefaultLayout.tsx: <Outlet> 자리에 자식 라우트가 렌더링된다. <ScrollRestoration>은 페이지 이동 시 스크롤 위치를 자동으로 처리한다.
// src/routes/layouts/DefaultLayout.tsx
import { Outlet, ScrollRestoration } from 'react-router'
import TheHeader from '@/components/TheHeader'
export default function DefaultLayout() {
return (
<>
<TheHeader />
<Outlet />
<ScrollRestoration />
</>
)
}
src/routes/index.tsx: 경로(path) 없이 element만 지정한 최상위 라우트의 children에 페이지 라우트를 둔다. 그러면 자식 라우트가 렌더링될 때 부모 <DefaultLayout />도 같이 렌더링된다.
// src/routes/index.tsx
import { createBrowserRouter, RouterProvider } from 'react-router'
import DefaultLayout from '@/routes/layouts/DefaultLayout'
import Home from '@/routes/pages/Home'
import About from '@/routes/pages/About'
const router = createBrowserRouter([
{
element: <DefaultLayout />,
children: [
{
path: '/',
element: <Home />
},
{
path: '/about',
element: <About />
}
]
}
])
export default function Router() {
return <RouterProvider router={router} />
}
src/main.tsx: 항상 이미 존재하는 파일이므로 전체를 덮어쓰지 않는다. 기존 wrapper(<StrictMode>, 다른 Provider)는 유지하고 <Router />만 끼워 넣는다. QueryClientProvider가 있으면 그 안에 둔다. 1단계의 main.tsx 렌더 구조 감지 결과에 따라 다음과 같이 처리한다.
- (a) 자식이 단순
<App />인 경우 (Vite 기본 템플릿): App 임포트를 제거하고 Router 임포트를 추가한 뒤 렌더 자리의 <App />만 <Router />로 교체한다. <StrictMode> 등 기존 wrapper는 그대로 유지한다. App.tsx가 더 이상 어디에서도 사용되지 않으면 사용자에게 삭제 여부를 확인한 뒤 제거한다.
- (b) 이미
<Router />/<RouterProvider>가 연결되어 있는 경우: main.tsx는 건드리지 않는다.
- (c) 다른 Provider/Wrapper(
QueryClientProvider, ThemeProvider, ErrorBoundary, i18n 등)가 <App />을 감싸고 있는 경우: wrapper는 모두 유지하고 가장 안쪽의 <App />만 <Router />로 교체한다. <Router />를 wrapper 바깥으로 빼는 것은 사용자가 명시적으로 요청한 경우에만 한다.
원하는 결과 예시 (가장 단순한 (a) 케이스. 기존 ./index.css 임포트가 있으면 그대로 둔다):
// src/main.tsx
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import Router from '@/routes'
import '@/index.css'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<Router />
</StrictMode>
)
5단계: 사용자 요청에 따른 기능 적용
사용자가 명시적으로 추가 기능을 요청한 경우에만 [기능 가이드](#기능-가이드) 섹션에서 해당 항목을 찾아 적용한다. 요청이 없으면 기본 골격만 두고 종료한다.
요청과 기능의 매핑 예:
| 사용자 요청 예시 |
적용할 기능 |
| "/movies/:movieId 같은 동적 페이지 만들어 줘" |
[동적 세그먼트](references/routing-patterns.md) |
| "검색 결과를 모달로 띄우고 싶어" / "중첩 라우트로 처리" |
[중첩 라우팅](references/routing-patterns.md) |
| "404 페이지 만들어 줘" |
[찾을 수 없는 페이지](references/routing-patterns.md) |
| "로그인한 사용자만 접근하게 해 줘" / "Protected Route" |
[보호된 경로](references/protected-routes.md) |
| "초기 로딩 줄이게 코드 스플리팅" / "lazy 적용" |
[페이지 지연 로딩](references/lazy-loading.md) |
| "페이지 바뀔 때 페이드 효과" |
[페이지 전환 애니메이션](references/animation-and-deploy.md) |
| "Vercel/Netlify/Firebase 배포 시 새로고침에서 404" |
[배포 설정](references/animation-and-deploy.md) |
| "NavLink 활성 스타일 / end / caseSensitive" |
[NavLink 활용](references/navigation.md) |
| "프로그래밍 방식으로 페이지 이동" |
[useNavigate / Navigate / redirect](references/navigation.md) |
| "Link에 state 넘기기 / replace / 스크롤 유지" |
[Link 활용](references/navigation.md) |
| "Declarative/Framework 모드 차이" |
[모드 비교](#모드-비교) |
여러 기능을 함께 적용했을 때 src/routes/index.tsx가 어떤 모습이어야 하는지는 [누적 적용 완성 예시](references/routing-patterns.md#누적-적용-완성-예시)를 기준으로 한다.
6단계: 최종 검증 (MANDATORY)
모든 단계 수행 후, 1단계의 감지 표를 다시 한 번 스캔해 다음을 확인한다.
누락 항목이 있으면 해당 단계로 돌아가 즉시 보완한다. 검증 통과 전에는 작업 종료 금지.
기능 가이드
사용자가 명시적으로 요청한 기능만 골라 적용한다. 각 항목의 변경 사항은 누적되도록 설계되어 있으므로, 이미 다른 항목이 적용된 상태에서 추가로 적용해도 충돌하지 않는다.
모드 비교
React Router는 선언적(Declarative), 데이터(Data), 프레임워크(Framework)의 3가지 모드를 제공하며 기능이 누적적으로 확장된다. 이 스킬은 Data 모드를 기준으로 한다.
- Declarative 모드:
<BrowserRouter><Routes><Route> 기반. 가장 기본적인 API. 단순한 SPA에 적합.
- Data 모드:
createBrowserRouter + RouterProvider. Loader/Action/Fetcher 등 데이터 기능 추가. 좀 더 복잡한 CSR 프로젝트에 적합.
- Framework 모드: Remix와 통합. SSR, Type-Safe href 등 추가 기능. 풀 스택 프로젝트에 적합.
@react-router/dev가 필요하므로 이 스킬의 범위 밖.
자세한 모드별 기능 비교는 React Router 공식 문서의 API & Mode availability table을 참고한다.
레이아웃과 ScrollRestoration
기본 골격(4단계)이 이미 <DefaultLayout>과 <ScrollRestoration>을 포함한다. 추가 레이아웃이 필요한 경우(예: 인증 후 영역 전용 레이아웃)는 src/routes/layouts/<이름>Layout.tsx 파일에 export default function <이름>Layout() 컴포넌트를 만들고, 라우트 트리에서 해당 영역의 부모 라우트 element로 둔다.
<ScrollRestoration>은 최상위 레이아웃에 한 번만 추가한다. 페이지 이동 시 스크롤 위치를 복원하거나 새 페이지의 스크롤을 최상단으로 이동시킨다.
그 밖의 기능
아래 기능은 references/로 분리돼 있다. 5단계 매핑 표에서 해당 항목이 필요할 때만 그 파일을 읽는다.
| 파일 |
다루는 내용 |
| [references/navigation.md](references/navigation.md) |
Link 활용, NavLink 활성 스타일, useNavigate / Navigate / redirect |
| [references/routing-patterns.md](references/routing-patterns.md) |
동적 세그먼트, 중첩 라우팅, 찾을 수 없는 페이지(404), 누적 적용 완성 예시 |
| [references/protected-routes.md](references/protected-routes.md) |
보호된 경로 (loader + redirect) |
| [references/lazy-loading.md](references/lazy-loading.md) |
페이지 지연 로딩 (dynamic 헬퍼: lazy + Suspense + ErrorBoundary) |
| [references/animation-and-deploy.md](references/animation-and-deploy.md) |
페이지 전환 애니메이션, SPA 호스팅 리라이트 설정 |
주의사항
- 프로젝트에
CLAUDE.md나 .claude/rules/react-router.md가 있으면 그 내용이 이 스킬보다 우선한다. 기존 코드가 있으면 파일 위치, 이름, 선언 형식을 먼저 확인하고 같은 스타일로 만든다.
- 이미 존재하는 설정/소스 파일은 덮어쓰지 않는다. 사용자가 명시적으로 요청한 부분만 수정한다.
- 상대 경로는 같은 폴더 안의 파일을 가져올 때만 쓴다. 다른 폴더의 파일은
@/ 별칭으로 가져온다.
- Declarative Mode(
<BrowserRouter>) 또는 Framework Mode(@react-router/dev) 기반 코드를 작성해 달라는 요청은 이 스킬의 범위가 아니다. 사용자에게 모드 선택을 한 번 더 확인한 뒤, 필요하면 이 스킬을 사용하지 않는다는 사실을 알린다.
- React Router v7부터
react-router-dom은 사용하지 않고 v8에서는 패키지가 제거됐다. 1단계에서 react-router-dom 사용이 감지되면 2단계의 마이그레이션 모드로 동작한다(사용자 동의 없이 임의로 임포트를 바꾸지 않는다).
RouterProvider는 react-router에서 가져온다. navigate/submit에 flushSync: true 옵션을 쓸 때만 react-router/dom의 RouterProvider가 필요하다.
- Loader 함수는 페이지 컴포넌트 렌더링 전에 실행되므로, 그 안에서 동기적으로 무거운 작업을 수행하지 않는다. 외부 요청은
await로 처리하되 사용자 경험을 해치지 않는 최소한의 작업으로 제한한다.
<ScrollRestoration>은 라우터 트리에 한 번만 둔다. 여러 레이아웃에 중복으로 두면 동작이 예측 불가능해진다.
dynamic 헬퍼 적용 시 라우트 정의의 element가 아니라 Component 속성을 사용해야 한다. element는 React 엘리먼트(<Foo />)를, Component는 컴포넌트 타입(Foo)을 받는다.
함께 보는 스킬
| 필요한 것 |
스킬 |
| 프로젝트 기반 설정 (Tailwind, 경로 별칭, ESLint + Prettier) |
react-vite-scaffold |
| 서버 데이터 fetching / 캐싱 |
tanstack-react-query-use |
| 전역 상태 (스토어) |
zustand-use |
| Next.js App Router로 전환 |
react-vite-to-next-migration |
| 성능, 접근성, SEO 측정 |
lighthouse |