TanStack Router로 타입 안전한 React 라우팅 구현하기
React 앱을 만들다 보면 라우팅은 빠질 수 없는 주제입니다. 대부분 React Router를 쓰고 있을 텐데요. 잘 동작하긴 하지만, 경로를 오타 내도 런타임까지 가야 알 수 있고, URL 검색 파라미터를 다루려면 직접 파싱하고 타입을 붙여야 하는 번거로움이 있죠.
TanStack Router는 이런 불편함을 정면으로 해결하려는 라이브러리입니다. TanStack Query와 TanStack Form으로 유명한 TanStack 생태계의 라우팅 솔루션인데요. 라우트 경로, 파라미터, 검색 파라미터, 로더 데이터까지 전부 TypeScript 타입으로 잡아주는 게 가장 큰 특징입니다. 풀스택 프레임워크인 TanStack Start도 바로 이 TanStack Router 위에 만들어졌고요.
이번 글에서는 TanStack Router의 핵심 기능을 하나씩 살펴보겠습니다. 파일 기반 라우팅으로 라우트를 구성하고, 타입 안전한 링크와 검색 파라미터를 다루고, 데이터 로딩과 코드 스플리팅까지 실전 예제와 함께 다뤄볼게요.
패키지 설치
React 프로젝트에 TanStack Router를 설치합니다.
bun add @tanstack/react-router
npm install @tanstack/react-router
파일 기반 라우팅을 쓰려면 Vite 플러그인도 함께 설치합니다.
bun add -d @tanstack/router-plugin
npm install -D @tanstack/router-plugin
Vite 설정에서 플러그인을 등록하면 src/routes/ 디렉토리 구조를 분석해서 라우트 트리를 자동 생성해 줍니다.
import { defineConfig } from "vite";
import { tanstackRouter } from "@tanstack/router-plugin/vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [tanstackRouter(), react()],
});
코드 기반 라우팅
파일 기반 라우팅을 살펴보기 전에, 코드로 직접 라우트를 정의하는 방식부터 짚고 넘어가겠습니다. TanStack Router가 어떻게 동작하는지 이해하는 데 도움이 됩니다.
import {
createRouter,
createRootRoute,
createRoute,
RouterProvider,
Outlet,
Link,
} from "@tanstack/react-router";
const rootRoute = createRootRoute({
component: () => (
<>
<nav>
<Link to="/">홈</Link>
<Link to="/about">소개</Link>
</nav>
<Outlet />
</>
),
});
const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/",
component: () => <h1>홈페이지</h1>,
});
const aboutRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/about",
component: () => <h1>소개 페이지</h1>,
});
const routeTree = rootRoute.addChildren([indexRoute, aboutRoute]);
const router = createRouter({ routeTree });
declare module "@tanstack/react-router" {
interface Register {
router: typeof router;
}
}
function App() {
return <RouterProvider router={router} />;
}
createRootRoute로 최상위 라우트를 만들고, createRoute로 각 페이지를 정의합니다.
Outlet은 자식 라우트가 렌더링되는 자리이고, Link는 페이지 간 이동을 담당합니다.
마지막의 declare module 부분이 중요한데요.
이 타입 선언을 해두면 앱 전체에서 라우터 타입 정보를 사용할 수 있습니다.
<Link to="/abuot">처럼 경로를 잘못 쓰면 TypeScript가 바로 에러를 띄워주죠.
코드 기반 라우팅은 라우트가 적을 때는 괜찮지만, 페이지가 늘어나면 getParentRoute을 일일이 연결하고 addChildren으로 트리를 조립하는 게 상당히 번거로워집니다.
그래서 실제 프로젝트에서는 파일 기반 라우팅을 쓰는 경우가 훨씬 많습니다.
파일 기반 라우팅
파일 기반 라우팅을 사용하면 src/routes/ 디렉토리에 파일을 만드는 것만으로 라우트가 자동 등록됩니다.
Vite 플러그인이 파일 구조를 분석해서 routeTree.gen.ts라는 타입 정보가 담긴 파일을 자동으로 생성해 주거든요.
기본적인 파일 구조를 살펴볼까요.
src/routes/
├── __root.tsx → 루트 레이아웃 (모든 페이지에 적용)
├── index.tsx → / 경로
├── about.tsx → /about 경로
└── posts/
├── index.tsx → /posts 경로
└── $postId.tsx → /posts/:postId 동적 경로
루트 라우트 파일(__root.tsx)은 모든 페이지의 공통 레이아웃을 정의합니다.
import { createRootRoute, Outlet, Link } from "@tanstack/react-router";
export const Route = createRootRoute({
component: RootLayout,
});
function RootLayout() {
return (
<>
<header>
<nav>
<Link to="/">홈</Link>
<Link to="/about">소개</Link>
<Link to="/posts">글 목록</Link>
</nav>
</header>
<main>
<Outlet />
</main>
</>
);
}
개별 페이지는 createFileRoute로 만듭니다.
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/")({
component: HomePage,
});
function HomePage() {
return <h1>안녕하세요!</h1>;
}
createFileRoute("/")의 경로 문자열은 파일 위치와 일치해야 합니다.
맞지 않으면 개발 서버에서 경고가 뜨니까 실수할 걱정은 없어요.
동적 라우트
파일 이름에 $를 붙이면 동적 세그먼트가 됩니다.
$postId.tsx라는 파일을 만들면 /posts/123, /posts/hello-world 같은 URL에 매칭되고, params.postId로 값을 읽을 수 있습니다.
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/posts/$postId")({
component: PostPage,
});
function PostPage() {
const { postId } = Route.useParams();
return <h1>포스트 #{postId}</h1>;
}
Route.useParams()의 반환 타입이 { postId: string }으로 자동 추론됩니다.
존재하지 않는 파라미터를 읽으려고 하면 타입 에러가 나고요.
여러 동적 세그먼트도 가능합니다.
posts/$postId/revisions/$revisionId.tsx처럼 파일을 만들면 params에서 두 값을 모두 꺼낼 수 있죠.
나머지 경로를 전부 캡처하는 스플랫(splat) 라우트도 지원합니다.
$.tsx라는 파일을 만들면 /files/documents/report.pdf 같은 URL에서 params._splat으로 documents/report.pdf를 통째로 받아올 수 있습니다.
레이아웃 라우트
여러 페이지가 같은 레이아웃을 공유해야 할 때가 있습니다. 예를 들어 대시보드의 여러 하위 페이지가 사이드바를 공유하는 경우인데요. TanStack Router에서는 부모 라우트가 자연스럽게 레이아웃 역할을 합니다.
src/routes/
├── dashboard.tsx → /dashboard 레이아웃 (사이드바 포함)
├── dashboard.index.tsx → /dashboard 인덱스
├── dashboard.settings.tsx → /dashboard/settings
└── dashboard.analytics.tsx → /dashboard/analytics
import { createFileRoute, Outlet, Link } from "@tanstack/react-router";
export const Route = createFileRoute("/dashboard")({
component: DashboardLayout,
});
function DashboardLayout() {
return (
<div style={{ display: "flex" }}>
<aside>
<Link to="/dashboard">대시보드</Link>
<Link to="/dashboard/settings">설정</Link>
<Link to="/dashboard/analytics">분석</Link>
</aside>
<div>
<Outlet />
</div>
</div>
);
}
dashboard.tsx에 정의한 레이아웃이 하위 라우트를 감싸고, <Outlet />에 자식 페이지가 렌더링됩니다.
URL 경로와 관계없이 순수하게 레이아웃만 씌우고 싶다면 언더스코어(_) 접두사를 사용합니다.
_auth.tsx라는 파일을 만들면 URL에는 /auth가 포함되지 않지만 하위 라우트에 레이아웃을 적용할 수 있어요.
타입 안전한 네비게이션
TanStack Router를 쓰는 가장 큰 이유 중 하나가 네비게이션의 타입 안전성입니다.
Link 컴포넌트의 to 프롭에 존재하지 않는 경로를 넣으면 TypeScript가 바로 에러를 띄웁니다.
// ✅ 타입 체크 통과
<Link to="/posts">글 목록</Link>
<Link to="/posts/$postId" params={{ postId: "123" }}>포스트</Link>
// ❌ 타입 에러: "/posst"는 유효한 경로가 아닙니다
<Link to="/posst">글 목록</Link>
// ❌ 타입 에러: postId 파라미터가 빠졌습니다
<Link to="/posts/$postId">포스트</Link>
동적 라우트로 이동할 때 params를 빼먹어도 컴파일 타임에 잡힙니다.
React Router에서는 이런 실수가 런타임에 undefined 에러로 터졌는데, TanStack Router에서는 코드를 작성하는 시점에 바로 알 수 있어요.
프로그래밍 방식의 네비게이션도 마찬가지입니다.
import { useNavigate } from "@tanstack/react-router";
function PostActions({ postId }: { postId: string }) {
const navigate = useNavigate();
return (
<button
onClick={() =>
navigate({
to: "/posts/$postId",
params: { postId },
})
}
>
포스트 보기
</button>
);
}
useNavigate가 반환하는 navigate 함수도 동일한 타입 검사를 거칩니다.
to에 넣은 경로에 따라 params에 어떤 값이 필요한지 TypeScript가 알려주니까 자동 완성이 바로 뜨고, 빠뜨린 파라미터도 즉시 잡아줍니다.
검색 파라미터 관리
TanStack Router에서 가장 인상적인 기능이 검색 파라미터(search params) 관리입니다. URL 쿼리 문자열을 마치 상태 관리자처럼 다룰 수 있게 해주거든요.
보통 React에서 검색 파라미터를 다루려면 useSearchParams()로 문자열을 꺼내서 직접 파싱하고, 타입 변환을 하고, 기본값을 처리해야 합니다.
TanStack Router는 이걸 validateSearch 한 곳에서 깔끔하게 해결합니다.
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { z } from "zod";
const productSearchSchema = z.object({
page: z.number().default(1),
category: z.string().default("all"),
sort: z.enum(["name", "price", "date"]).default("date"),
inStock: z.boolean().default(false),
});
export const Route = createFileRoute("/products")({
validateSearch: productSearchSchema,
component: ProductsPage,
});
Zod 스키마를 validateSearch에 넘기면 검색 파라미터의 타입 추론, 런타임 검증, 기본값 처리가 한 번에 됩니다.
URL에 ?page=abc 같은 잘못된 값이 들어와도 기본값으로 떨어지니까 안전하고요.
컴포넌트에서는 Route.useSearch()로 파라미터를 읽습니다.
function ProductsPage() {
const { page, category, sort, inStock } = Route.useSearch();
const navigate = useNavigate({ from: Route.fullPath });
return (
<div>
<p>
{category} 카테고리 / {sort}순 / {page}페이지
</p>
<button
onClick={() =>
navigate({
search: (prev) => ({ ...prev, page: prev.page + 1 }),
})
}
>
다음 페이지
</button>
<button
onClick={() =>
navigate({
search: (prev) => ({ ...prev, inStock: !prev.inStock }),
})
}
>
재고 있는 상품만 {inStock ? "끄기" : "보기"}
</button>
</div>
);
}
navigate의 search 옵션에 함수를 넘기면 이전 값을 기반으로 파라미터를 업데이트할 수 있습니다.
page가 number 타입인 걸 TypeScript가 알고 있으니까 prev.page + 1을 쓸 때 자동 완성이 뜨고, 잘못된 타입을 넣으면 에러가 나죠.
Link 컴포넌트에서도 검색 파라미터를 넘길 수 있습니다.
<Link
to="/products"
search={{ page: 1, category: "shoes", sort: "price", inStock: true }}
>
신발 (가격순)
</Link>
검색 파라미터를 URL에 두면 사용자가 페이지를 새로고침해도 필터 상태가 유지되고, 링크를 공유하면 상대방이 같은 화면을 볼 수 있습니다. 별도의 상태 관리 라이브러리 없이 URL만으로 이런 동작을 만들 수 있는 게 TanStack Router의 강점입니다.
데이터 로딩
TanStack Router는 라우트 수준에서 데이터를 미리 로딩하는 기능을 기본 제공합니다.
loader 함수를 정의하면 해당 라우트로 이동할 때 컴포넌트가 렌더링되기 전에 데이터를 가져옵니다.
import { createFileRoute } from "@tanstack/react-router";
interface Post {
id: number;
title: string;
}
async function fetchPosts(): Promise<Post[]> {
const response = await fetch("https://jsonplaceholder.typicode.com/posts");
return response.json();
}
export const Route = createFileRoute("/posts/")({
loader: () => fetchPosts(),
component: PostListPage,
});
function PostListPage() {
const posts = Route.useLoaderData();
return (
<ul>
{posts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}
Route.useLoaderData()의 반환 타입이 loader의 반환 타입에서 자동으로 추론됩니다.
위 예제에서 posts는 Post[] 타입이 되는 거죠.
별도의 타입 어노테이션이 필요 없습니다.
loader는 라우트 파라미터와 검색 파라미터에도 접근할 수 있습니다.
export const Route = createFileRoute("/posts/$postId")({
loader: ({ params }) => fetchPost(params.postId),
component: PostPage,
});
function PostPage() {
const post = Route.useLoaderData();
return <h1>{post.title}</h1>;
}
loader의 params.postId도 타입이 string으로 추론됩니다.
존재하지 않는 파라미터에 접근하면 타입 에러가 나니까 경로 변경으로 인한 버그를 미리 잡을 수 있어요.
라우트 컨텍스트
여러 라우트에서 공통으로 사용하는 데이터나 의존성이 있다면 라우트 컨텍스트를 활용할 수 있습니다. 예를 들어 인증 정보나 API 클라이언트를 모든 라우트에서 쓸 수 있게 해주는 거죠.
import { createRouter } from "@tanstack/react-router";
import { routeTree } from "./routeTree.gen";
import { queryClient } from "./queryClient";
export function getRouter() {
return createRouter({
routeTree,
context: {
queryClient,
},
});
}
createRouter에 context를 넘기면 모든 라우트의 loader와 beforeLoad에서 이 값을 받아 쓸 수 있습니다.
export const Route = createFileRoute("/posts/")({
loader: ({ context }) => {
// context.queryClient로 TanStack Query 사용
return context.queryClient.ensureQueryData({
queryKey: ["posts"],
queryFn: fetchPosts,
});
},
component: PostListPage,
});
이렇게 하면 TanStack Query와 연동해서 캐싱, 리페칭, stale-while-revalidate 같은 고급 기능을 라우트 로더에서 바로 사용할 수 있습니다. TanStack 생태계끼리 찰떡궁합인 셈이죠.
인증과 라우트 가드
특정 라우트에 접근하기 전에 인증 여부를 확인하고 싶을 때는 beforeLoad를 씁니다.
loader보다 먼저 실행되기 때문에 데이터를 불필요하게 가져오는 걸 방지할 수 있습니다.
import { createFileRoute, redirect } from "@tanstack/react-router";
export const Route = createFileRoute("/dashboard")({
beforeLoad: async ({ context }) => {
if (!context.auth.isLoggedIn) {
throw redirect({ to: "/login" });
}
},
component: DashboardPage,
});
로그인하지 않은 사용자가 /dashboard에 접근하면 redirect가 던져져서 /login으로 이동합니다.
redirect도 타입 안전해서 존재하지 않는 경로로 리디렉트하려고 하면 에러가 나고요.
데이터가 없는 경우에는 notFound를 던질 수 있습니다.
import { createFileRoute, notFound } from "@tanstack/react-router";
export const Route = createFileRoute("/posts/$postId")({
loader: async ({ params }) => {
const post = await fetchPost(params.postId);
if (!post) throw notFound();
return post;
},
notFoundComponent: () => <p>포스트를 찾을 수 없습니다.</p>,
component: PostPage,
});
notFoundComponent를 라우트별로 지정할 수 있어서 “찾을 수 없음” 페이지를 맥락에 맞게 커스터마이징하기 좋습니다.
코드 스플리팅
앱이 커지면 모든 라우트의 코드를 한 번에 로딩하는 건 비효율적입니다. TanStack Router는 라우트 단위로 코드를 분리해서 필요할 때만 불러오는 코드 스플리팅을 지원합니다.
가장 쉬운 방법은 Vite 플러그인의 autoCodeSplitting 옵션을 켜는 겁니다.
import { defineConfig } from "vite";
import { tanstackRouter } from "@tanstack/router-plugin/vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [
tanstackRouter({
autoCodeSplitting: true,
}),
react(),
],
});
이 옵션을 켜면 TanStack Router가 각 라우트의 설정을 두 가지로 분류합니다.
우선 경로 파싱, 검색 파라미터 검증, loader, beforeLoad 같은 라우팅 핵심 로직은 중요 설정으로 분류되어 즉시 로딩됩니다.
반면 component, errorComponent, pendingComponent 같은 UI 관련 코드는 비중요 설정으로 분류되어 해당 라우트에 접근할 때 비동기로 로딩되고요.
라우트를 매칭하고 데이터를 가져오는 작업은 항상 빠르게 시작되면서, 무거운 UI 코드는 필요할 때만 내려받는 구조입니다.
수동으로 코드를 분리하고 싶다면 .lazy.tsx 파일을 사용합니다.
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/posts/")({
loader: () => fetchPosts(),
});
import { createLazyFileRoute } from "@tanstack/react-router";
export const Route = createLazyFileRoute("/posts/")({
component: PostListPage,
});
function PostListPage() {
const posts = Route.useLoaderData();
// ...
}
loader는 기존 파일에 남겨두고, 컴포넌트만 .lazy.tsx로 분리하는 패턴입니다.
이렇게 하면 loader는 바로 실행되어 데이터를 가져오는 동안 컴포넌트 코드를 비동기로 불러올 수 있어서 로딩이 동시에 진행됩니다.
펜딩 상태와 에러 처리
데이터를 로딩하는 동안 보여줄 UI와 에러가 발생했을 때 보여줄 UI를 라우트 수준에서 정의할 수 있습니다.
export const Route = createFileRoute("/posts/")({
loader: () => fetchPosts(),
pendingComponent: () => <p>로딩 중...</p>,
errorComponent: ({ error }) => (
<div>
<h2>오류가 발생했습니다</h2>
<p>{error.message}</p>
</div>
),
component: PostListPage,
});
pendingComponent는 loader가 실행되는 동안 보여주고, errorComponent는 loader나 컴포넌트에서 에러가 던져졌을 때 보여줍니다.
React의 Suspense나 Error Boundary를 직접 구성할 필요 없이 라우트 옵션 하나로 끝나니까 간편합니다.
전역 기본값을 설정해둘 수도 있습니다.
const router = createRouter({
routeTree,
defaultPendingComponent: () => <div>페이지 로딩 중...</div>,
defaultErrorComponent: ({ error }) => <div>오류: {error.message}</div>,
});
개별 라우트에서 재정의하지 않는 한 이 기본 컴포넌트가 사용됩니다.
스크롤 복원
SPA에서 흔히 겪는 문제 중 하나가 뒤로 가기를 했을 때 스크롤 위치가 초기화되는 겁니다. TanStack Router는 스크롤 복원을 기본 지원합니다.
const router = createRouter({
routeTree,
scrollRestoration: true,
});
scrollRestoration: true만 설정하면 이전 페이지의 스크롤 위치를 기억했다가 돌아갈 때 복원해 줍니다.
긴 목록에서 항목을 클릭하고 뒤로 가기를 했을 때 목록 맨 위로 튕기는 짜증나는 경험을 방지할 수 있죠.
개발자 도구
TanStack Router는 디버깅을 위한 전용 개발자 도구를 제공합니다. 라우트 트리, 현재 매칭된 라우트, 파라미터, 검색 파라미터, 로더 데이터를 실시간으로 확인할 수 있습니다.
bun add -d @tanstack/router-devtools
npm install -D @tanstack/router-devtools
루트 레이아웃에 추가하면 됩니다.
import { TanStackRouterDevtools } from "@tanstack/router-devtools";
function RootLayout() {
return (
<>
<Outlet />
<TanStackRouterDevtools />
</>
);
}
개발 서버에서 화면 하단에 작은 패널이 나타나는데, 라우트 매칭 과정이나 검색 파라미터 변화를 눈으로 추적할 수 있어서 디버깅할 때 아주 유용합니다. 프로덕션 빌드에서는 자동으로 제거되니까 별도로 조건 분기를 할 필요도 없고요.
React Router와 비교
React 라우팅의 사실상 표준이었던 React Router와 비교하면 설계 철학 차이가 뚜렷합니다.
가장 큰 차이는 타입 안전성입니다. React Router는 경로를 문자열로 다루기 때문에 오타나 누락된 파라미터를 런타임에야 발견할 수 있습니다. TanStack Router는 라우트 트리 전체를 TypeScript 타입으로 관리해서 잘못된 경로, 빠진 파라미터, 잘못된 검색 파라미터를 모두 컴파일 타임에 잡아줍니다.
검색 파라미터 처리도 크게 다릅니다.
React Router의 useSearchParams()는 URLSearchParams 객체를 반환해서 모든 값이 문자열입니다.
숫자나 불리언 같은 타입 변환을 직접 해야 하고, 기본값 처리도 수동이에요.
TanStack Router는 validateSearch에 스키마를 정의하면 파싱, 검증, 타입 추론, 기본값 처리가 한 번에 됩니다.
데이터 로딩 방식도 다릅니다.
React Router v6.4부터 loader 개념이 도입되긴 했지만 타입 추론이 약하고 사용법이 다소 복잡합니다.
TanStack Router의 loader는 반환 타입이 useLoaderData()까지 자동으로 흘러가서 타입 단절 없이 사용할 수 있습니다.
코드 스플리팅도 TanStack Router가 더 세밀합니다.
React Router에서 라우트별 코드 분리를 하려면 React.lazy와 Suspense를 직접 조합해야 하는데, TanStack Router는 .lazy.tsx 파일 분리나 자동 코드 스플리팅으로 더 간편하게 처리할 수 있습니다.
물론 React Router도 장점이 있습니다. 훨씬 오래된 생태계와 방대한 커뮤니티 자료가 있고, React Native 지원이나 Remix와의 통합 같은 강점도 있어요. TanStack Router는 상대적으로 새로운 라이브러리라 학습 자료가 적고 서드파티 통합이 제한적인 편입니다.
마치며
TanStack Router는 React 라우팅에 “타입 안전성”을 본격적으로 도입한 라이브러리입니다. 경로, 파라미터, 검색 파라미터, 로더 데이터까지 앱 전체의 라우팅 관련 데이터가 타입으로 연결되니까 코드를 작성하는 시점에 실수를 잡을 수 있고 자동 완성의 도움도 받을 수 있습니다.
검색 파라미터를 스키마로 정의해서 URL을 상태 관리자처럼 활용하는 패턴도 깔끔하고, 코드 스플리팅이나 스크롤 복원 같은 실용적인 기능도 잘 갖추고 있습니다. TanStack Start를 포함한 TanStack 생태계와 자연스럽게 이어진다는 것도 큰 매력이고요.
이미 React Router로 잘 동작하는 프로젝트를 급하게 옮길 필요는 없지만, 새 프로젝트를 시작하거나 TypeScript를 적극 활용하는 팀이라면 한번 써보시길 추천합니다.
더 자세한 내용은 TanStack Router 공식 문서를 참고하세요.
This work is licensed under
CC BY 4.0