Tanstack Router의 indexToken, routeToken, routeFileIgnorePattern 옵션으로 Next.js App Router처럼 폴더마다 page.tsx와 layout.tsx를 두는 방법과 routes 폴더를 app 폴더로 바꾸는 방법을 정리합니다.
최근 Next.js로 만든 화면을 React(Vite) + Tanstack Router로 옮기는 작업을 했습니다.
Tanstack Router도 File-based Routing이라 금방 옮길 줄 알았는데, 막상 파일을 옮기다 보니 계속 손이 멈췄습니다.
Next.js에서는 폴더가 주소이고 그 안의 page.tsx가 화면, layout.tsx가 감싸는 틀입니다.
Tanstack Router는 기본 규칙이 조금 다릅니다. 같은 /about 화면을 about.tsx로도, about/index.tsx로도 만들 수 있고 레이아웃은 about/route.tsx로 만듭니다.
규칙 자체가 어려운 것은 아닌데, Next.js에 익숙하다 보니 파일을 만들 때마다 "여기는 index였나, route였나" 하고 다시 확인하게 됐습니다.
그래서 Tanstack Router 설정을 바꿔서 Next.js와 똑같이 page.tsx, layout.tsx로 라우트를 만드는 방법을 정리했습니다.
결론부터 말하면 Vite 플러그인 옵션 몇 줄이면 됩니다.
vite.config.ts의 tanstackRouter 플러그인에 옵션 세 개를 추가합니다.
import { tanstackRouter } from "@tanstack/router-plugin/vite";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [
// react() 보다 앞에 둡니다.
tanstackRouter({
target: "react",
autoCodeSplitting: true,
indexToken: "page",
routeToken: "layout",
// page, layout, __root 외의 파일은 라우트로 읽지 않습니다.
routeFileIgnorePattern: String.raw`^(?!(?:page|layout|__root)\.tsx$).+\.tsx?$`,
}),
react(),
],
});이렇게 설정하면 폴더 구조가 다음과 같아집니다.
Next.js와 비교하면 다음과 같습니다.
| Next.js (App Router) | Tanstack Router 기본 | 이 글의 설정 |
|---|---|---|
app/layout.tsx | routes/__root.tsx | routes/__root.tsx (그대로) |
app/page.tsx | routes/index.tsx | routes/page.tsx |
app/about/page.tsx | routes/about.tsx 또는 about/index.tsx | routes/about/page.tsx |
app/about/layout.tsx | routes/about/route.tsx | routes/about/layout.tsx |
app/posts/[postId]/page.tsx | routes/posts/$postId.tsx | routes/posts/$postId/page.tsx |
app/(marketing)/pricing/page.tsx | routes/(marketing)/pricing.tsx | routes/(marketing)/pricing/page.tsx |
app/about/loading.tsx | 라우트 옵션 pendingComponent | loading.tsx + pendingComponent |
__root.tsx와 동적 경로의 $ 정도만 다르고 나머지는 Next.js와 같은 모양으로 쓸 수 있습니다.
하나씩 살펴보겠습니다.
먼저 Tanstack Router의 기본 규칙부터 다시 보겠습니다.
Tanstack Router에서는 파일 이름에 특별한 의미가 있는 단어가 두 개 있습니다.
index - 그 폴더 경로의 화면입니다. Next.js의 page.tsx 역할입니다.route - 그 폴더 하위를 감싸는 라우트입니다. <Outlet />을 두면 Next.js의 layout.tsx 역할을 합니다.그리고 이 두 단어를 각각 index token, route token이라고 부릅니다.
다행히 이 두 단어는 설정으로 바꿀 수 있습니다.
index 대신 page, route 대신 layout으로 바꾸면 Next.js와 같은 이름이 됩니다.
vite.config.ts에 indexToken과 routeToken을 추가합니다.
tanstackRouter({
target: "react",
autoCodeSplitting: true,
indexToken: "page",
routeToken: "layout",
}),이제 about 폴더에 page.tsx와 layout.tsx를 만들어 보겠습니다.
빈 파일을 만들면 이전 글에서 본 것처럼 코드가 자동으로 생성됩니다.
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/about/")({
component: RouteComponent,
});
function RouteComponent() {
return <div>Hello "/about/"!</div>;
}import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/about")({
component: RouteComponent,
});
function RouteComponent() {
return <div>Hello "/about"!</div>;
}경로를 잘 보면 page.tsx는 "/about/", layout.tsx는 "/about"입니다.
page.tsx는 폴더의 기본 화면(index)이라서 뒤에 /가 하나 더 붙습니다. 이전 글에서 about/index.tsx의 경로가 /about/였던 것과 같습니다.
자동으로 만들어진 layout.tsx에는 <Outlet />이 없습니다.
이대로 두면 /about으로 접속했을 때 page.tsx가 아니라 Hello "/about"!만 보입니다.
Next.js의 layout.tsx가 children을 받아서 그리는 것처럼, Tanstack Router에서는 <Outlet />이 그 자리입니다.
import { Outlet, createFileRoute } from "@tanstack/react-router";
const AboutLayout = () => {
return (
<section>
<h2>About Layout</h2>
{/* Next.js layout.tsx의 children 자리 */}
<Outlet />
</section>
);
};
export const Route = createFileRoute("/about")({
component: AboutLayout,
});이제 /about과 /about/news 모두 About Layout 아래에 각 page.tsx가 그려집니다.
라우트 파일의 경로는 "/about/"이지만 실제 주소와 Link의 to는 그대로 /about입니다.
<Link to="/about">About</Link> // 정상
<Link to="/about/">About</Link> // 타입 에러to="/about/"로 적으면 타입 에러가 납니다.
error TS2820: Type '"/about/"' is not assignable to type '"." | ".." | "/" | "/about" | "/about/news" | ...헷갈릴 수 있는데, 끝의 /는 라우트 파일 안에서만 붙는다고 기억하면 됩니다.
여기까지만 해도 이름은 Next.js와 같아졌습니다.
그런데 Next.js와 다른 점이 하나 더 있습니다.
Next.js는 app 폴더 안에서 page.tsx, layout.tsx 같은 정해진 이름만 라우트로 읽습니다. 그래서 같은 폴더에 다른 파일을 같이 둬도 주소가 생기지 않습니다.
Tanstack Router는 반대입니다. routes 폴더 안의 파일은 이름과 상관없이 전부 라우트 후보입니다.
예를 들어 about 폴더에 빈 파일 empty-thing.tsx를 만들면 다음 코드가 자동으로 채워집니다.
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/about/empty-thing")({
component: RouteComponent,
});
function RouteComponent() {
return <div>Hello "/about/empty-thing"!</div>;
}의도하지 않았는데 /about/empty-thing 주소가 생겨버립니다.
내용이 있는 파일이라면 라우트로 만들지는 않지만 빌드할 때마다 경고가 나옵니다.
Warning: Route file "src/routes/about/loading.tsx" does not export a Route. This file will not be included in the route tree.그래서 routeFileIgnorePattern 옵션으로 page, layout, __root 외의 파일은 아예 읽지 않게 했습니다.
tanstackRouter({
target: "react",
autoCodeSplitting: true,
indexToken: "page",
routeToken: "layout",
routeFileIgnorePattern: String.raw`^(?!(?:page|layout|__root)\.tsx$).+\.tsx?$`,
}),정규식이 조금 복잡해 보이는데 뜻은 단순합니다.
.+\.tsx?$ - .ts, .tsx 파일 중에서(?!(?:page|layout|__root)\.tsx$) - 이름이 page.tsx, layout.tsx, __root.tsx가 아닌 것은 무시합니다.폴더 이름은 .tsx로 끝나지 않으니 그대로 읽힙니다. about, news 같은 폴더는 지금처럼 경로가 됩니다.
이제 about 폴더에 빈 파일을 만들어도 코드가 자동으로 생성되지 않고, 빌드 경고도 사라집니다.
덤으로 about.tsx, $postId.tsx처럼 파일 이름으로 만드는 라우트도 더 이상 읽지 않습니다. 화면은 항상 폴더/page.tsx로만 만들게 되니 Next.js와 규칙이 완전히 같아집니다.
정규식을 쓰고 싶지 않다면 Tanstack Router에 원래 있는 방법도 있습니다.
파일이나 폴더 이름 앞에 -를 붙이면 라우트에서 빠집니다.
다만 이 방법은 파일을 만들 때마다 -를 붙여야 하고, 깜빡하면 위처럼 라우트가 생깁니다.
저는 규칙을 설정 한 곳에 두는 routeFileIgnorePattern 쪽이 더 편했습니다.
routeFileIgnorePattern을 설정하면 좋은 점이 하나 더 있습니다.
Next.js의 loading.tsx처럼 화면에 딸린 파일을 page.tsx 옆에 둘 수 있습니다.
const Loading = () => {
return <p>loading...</p>;
};
export default Loading;Tanstack Router에는 loading.tsx 같은 파일 규칙이 없습니다. 대신 라우트 옵션의 pendingComponent에 직접 연결해 줍니다.
import { createFileRoute } from "@tanstack/react-router";
import Loading from "./loading";
export const Route = createFileRoute("/about/")({
loader: async () => {
// 데이터 불러오기
},
pendingComponent: Loading,
component: AboutPage,
});
const AboutPage = () => {
return <h1>About</h1>;
};loader가 끝나기 전까지 loading...이 보이고, 끝나면 About 화면으로 바뀝니다.
/about/loading 주소로 접속해 보면 Not Found가 나옵니다. loading.tsx는 라우트가 아니라 그냥 컴포넌트 파일이기 때문입니다.
같은 방법으로 Next.js의 error.tsx, not-found.tsx도 각각 errorComponent, notFoundComponent 옵션에 연결해서 쓸 수 있습니다.
Next.js의 라우트 그룹 (폴더이름)은 Tanstack Router에서도 똑같이 동작합니다.
괄호로 감싼 폴더 이름은 주소에서 빠지기 때문에 /pricing으로 접속하면 됩니다.
동적 경로는 Next.js와 다릅니다.
Next.js는 [postId]처럼 대괄호를 쓰고, Tanstack Router는 $postId처럼 $를 씁니다. 이 부분은 설정으로 바꿀 수 없습니다.
import { createFileRoute } from "@tanstack/react-router";
const PostPage = () => {
const { postId } = Route.useParams();
return <h1>post {postId}</h1>;
};
export const Route = createFileRoute("/posts/$postId/")({
component: PostPage,
});$ 대신 폴더로 만든다는 점만 빼면 이전 글의 동적 라우팅과 같습니다.
여기까지 하고 나니 __root.tsx도 layout.tsx로 바꾸고 싶어졌습니다.
Next.js는 app/layout.tsx가 최상위 레이아웃이니까요.
routes 폴더 바로 아래에 layout.tsx를 두면 될 것 같지만, 그러면 다음 에러가 납니다.
Error: Invalid route path "" was found. Root routes must be defined via __root.tsx (createRootRoute), not createFileRoute('') or a route file that resolves to an empty path.
Conflicting files:
src/routes/layout.tsxvirtual file routes(virtualRouteConfig)로 루트 파일 이름을 layout.tsx로 지정하는 방법도 시도해 봤는데 결과는 같았습니다.
에러 메시지 그대로 루트는 __root.tsx로만 정의할 수 있습니다.
이 부분은 Tanstack Router가 정한 이름이라 그대로 두고, __root.tsx가 Next.js의 app/layout.tsx 자리라고 생각하면 됩니다.
import { Outlet, createRootRoute } from "@tanstack/react-router";
const RootLayout = () => {
return (
<>
<Header />
<main>
<Outlet />
</main>
</>
);
};
export const Route = createRootRoute({
component: RootLayout,
});폴더 이름까지 Next.js처럼 app으로 바꾸고 싶다면 routesDirectory 옵션을 추가합니다.
tanstackRouter({
target: "react",
autoCodeSplitting: true,
routesDirectory: "./src/app",
indexToken: "page",
routeToken: "layout",
routeFileIgnorePattern: String.raw`^(?!(?:page|layout|__root)\.tsx$).+\.tsx?$`,
}),routesDirectory의 기본값은 ./src/routes입니다. 이 값만 바꾸면 src/app 폴더를 읽습니다.
routeTree.gen.ts는 generatedRouteTree 옵션으로 위치가 따로 정해져 있어서 src/routeTree.gen.ts에 그대로 만들어집니다. 안의 import 경로만 ./app/...으로 바뀝니다.
import { Route as rootRouteImport } from "./app/__root";
import { Route as PageRouteImport } from "./app/page";
import { Route as AboutLayoutRouteImport } from "./app/about/layout";
// ...main.tsx에서는 ./routeTree.gen을 그대로 가져오기 때문에 따로 고칠 곳이 없습니다.
저도 처음에는 폴더 이름까지 app으로 바꿨다가 결국 routes로 되돌렸습니다.
Tanstack Router 문서와 예제가 전부 routes 기준이라, 문서를 보면서 작업할 때 routes가 덜 헷갈렸습니다.
Next.js처럼 느껴지게 만드는 것은 폴더 이름보다 안의 규칙(page.tsx, layout.tsx)이라고 생각합니다. 폴더 이름은 팀이 편한 쪽을 고르면 됩니다.
이미 about.tsx처럼 파일 이름으로 라우트를 만든 프로젝트라면 파일만 옮기면 됩니다.
createFileRoute("/about")의 경로를 "/about/"로 직접 고치지 않아도 됩니다.
dev 서버나 빌드를 한 번 돌리면 Tanstack Router가 파일 위치에 맞게 경로를 자동으로 고쳐 줍니다.
// 옮긴 직후
export const Route = createFileRoute("/about")({ ... });
// 빌드 후 자동으로 바뀜
export const Route = createFileRoute("/about/")({ ... });주소와 Link의 to는 바뀌지 않으니 화면 쪽 코드는 손댈 필요가 없습니다.
정리하면 Tanstack Router를 Next.js 폴더 구조처럼 쓰는 방법은 다음과 같습니다.
indexToken: "page", routeToken: "layout"으로 파일 이름을 맞춥니다.routeFileIgnorePattern으로 page, layout, __root 외의 파일은 라우트에서 뺍니다.routesDirectory: "./src/app"을 추가합니다.__root.tsx와 동적 경로의 $만 Tanstack Router 방식으로 남고 나머지는 Next.js와 같은 모양이 됩니다.
Next.js와 React 프로젝트를 같이 다루다 보면 프로젝트마다 라우트 규칙이 달라서 은근히 피곤했는데, 이렇게 맞춰 두니 어느 쪽을 열어도 화면 파일을 바로 찾을 수 있어서 편했습니다.