원클릭으로
tanstack-router-patterns
TanStack Router type-safe routing with file-based conventions, loaders, search params validation, and protected routes
Codex 또는 Claude로 설치 이 Prompt를 복사해 Codex, Claude 또는 다른 어시스턴트에 붙여 넣으면 Skill 페이지를 검토하고 설치를 진행할 수 있습니다.
메뉴
TanStack Router type-safe routing with file-based conventions, loaders, search params validation, and protected routes
Codex 또는 Claude로 설치 이 Prompt를 복사해 Codex, Claude 또는 다른 어시스턴트에 붙여 넣으면 Skill 페이지를 검토하고 설치를 진행할 수 있습니다.
SOC 직업 분류 기준
Backend testing patterns — API request construction, response verification, database state checks, error handling testing, and adaptive tool detection.
Frontend testing patterns using Playwright — navigation, interaction, assertions, screenshots on failure, and common UI testing scenarios.
Test report format with QA-XXX issue IDs compatible with code-review plugin. Defines report structure, severity levels, issue format with canonical fields, and detailed results.
Test plan structure, naming conventions, edge case generation rules, and file saving conventions for QA test plans.
Enforces AppVerk Swift coding standards across all code.
Structured concurrency and thread safety patterns in modern Swift.
| name | tanstack-router-patterns |
| description | TanStack Router type-safe routing with file-based conventions, loaders, search params validation, and protected routes |
TanStack Router patterns for type-safe React SPAs:
beforeLoadRoute.useParams() for type-safe route params — NEVER parse window.locationvalidateSearchloader + TanStack Query ensureQueryDatauseSuspenseQuery in components that have a loader — data is never undefinedbeforeLoad — NEVER use JSX wrapper components for auth guardsuseEffect for data fetching in routed components — use loadersreact-router-dom in new code — TanStack Router is the standard| Pattern | Meaning | Example |
|---|---|---|
__root.tsx | Root layout route | routes/__root.tsx |
index.tsx | Index route for directory | routes/index.tsx → / |
$param.tsx | Dynamic segment | routes/users/$userId.tsx → /users/:userId |
_layout.tsx | Pathless layout (no URL segment) | routes/_authenticated.tsx |
_layout/ | Directory for layout children | routes/_authenticated/dashboard.tsx |
. (dot) | Nested path separator | routes/settings.profile.tsx → /settings/profile |
$.tsx | Splat/catch-all route | routes/$.tsx → /* |
src/
routes/
__root.tsx # Root layout (nav, footer, providers)
index.tsx # / (home page)
about.tsx # /about
_authenticated.tsx # Layout: auth guard (no URL segment)
_authenticated/
dashboard.tsx # /dashboard (protected)
settings.tsx # /settings (protected)
settings.profile.tsx # /settings/profile (protected)
settings.notifications.tsx # /settings/notifications (protected)
users/
index.tsx # /users (list)
$userId.tsx # /users/:userId (detail)
$userId.edit.tsx # /users/:userId/edit
$.tsx # Catch-all / 404
routeTree.gen.ts # Auto-generated — NEVER edit manually
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { TanStackRouterVite } from '@tanstack/router-plugin/vite';
export default defineConfig({
plugins: [
TanStackRouterVite(), // Must be before react()
react(),
],
});
Rule: Never manually edit routeTree.gen.ts. It is auto-generated by the TanStack Router plugin.
// src/routes/__root.tsx
import { createRootRouteWithContext, Outlet } from '@tanstack/react-router';
import type { QueryClient } from '@tanstack/react-query';
interface RouterContext {
queryClient: QueryClient;
}
export const Route = createRootRouteWithContext<RouterContext>()({
component: RootLayout,
notFoundComponent: NotFound,
});
function RootLayout(): React.ReactElement {
return (
<div className="min-h-screen flex flex-col">
<header>
<nav>{/* Navigation links */}</nav>
</header>
<main className="flex-1">
<Outlet />
</main>
<footer>{/* Footer */}</footer>
</div>
);
}
function NotFound(): React.ReactElement {
return (
<div className="flex items-center justify-center min-h-[50vh]">
<h1>404 — Page Not Found</h1>
</div>
);
}
// src/app/router.ts
import { createRouter } from '@tanstack/react-router';
import { routeTree } from '@/routeTree.gen';
import type { QueryClient } from '@tanstack/react-query';
export function createAppRouter(queryClient: QueryClient): ReturnType<typeof createRouter> {
return createRouter({
routeTree,
context: { queryClient },
defaultPreloadDelay: 0,
defaultPreload: 'intent', // Preload on hover
});
}
// Register router for type safety
declare module '@tanstack/react-router' {
interface Register {
router: ReturnType<typeof createAppRouter>;
}
}
// src/app/App.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { RouterProvider } from '@tanstack/react-router';
import { useState } from 'react';
import { createAppRouter } from '@/app/router';
function App(): React.ReactElement {
const [queryClient] = useState(() => new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60, // 1 minute
retry: 1,
},
},
}));
const [router] = useState(() => createAppRouter(queryClient));
return (
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
);
}
export default App;
Route loaders ensureQueryData (preload), then components use useSuspenseQuery (guaranteed data).
// src/features/users/api/queries.ts
import { queryOptions } from '@tanstack/react-query';
import { fetchUser, fetchUsers } from './usersApi';
export const usersQueries = {
all: () =>
queryOptions({
queryKey: ['users'],
queryFn: fetchUsers,
}),
detail: (userId: string) =>
queryOptions({
queryKey: ['users', userId],
queryFn: () => fetchUser(userId),
}),
};
// src/routes/users/$userId.tsx
import { createFileRoute } from '@tanstack/react-router';
import { useSuspenseQuery } from '@tanstack/react-query';
import { usersQueries } from '@/features/users/api/queries';
export const Route = createFileRoute('/users/$userId')({
loader: ({ context: { queryClient }, params: { userId } }) =>
queryClient.ensureQueryData(usersQueries.detail(userId)),
component: UserDetailPage,
});
function UserDetailPage(): React.ReactElement {
const { userId } = Route.useParams();
const { data: user } = useSuspenseQuery(usersQueries.detail(userId));
// `user` is never undefined — loader guarantees data exists
return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
</div>
);
}
// src/routes/users/index.tsx
import { createFileRoute } from '@tanstack/react-router';
import { useSuspenseQuery } from '@tanstack/react-query';
import { usersQueries } from '@/features/users/api/queries';
export const Route = createFileRoute('/users/')({
loader: ({ context: { queryClient } }) =>
queryClient.ensureQueryData(usersQueries.all()),
component: UsersListPage,
});
function UsersListPage(): React.ReactElement {
const { data: users } = useSuspenseQuery(usersQueries.all());
return (
<ul>
{users.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}
// src/routes/users/index.tsx
import { createFileRoute } from '@tanstack/react-router';
import { z } from 'zod';
const usersSearchSchema = z.object({
page: z.coerce.number().int().positive().default(1).catch(1),
pageSize: z.coerce.number().int().min(10).max(100).default(20).catch(20),
search: z.string().optional().catch(undefined),
sortBy: z.enum(['name', 'email', 'createdAt']).default('name').catch('name'),
sortOrder: z.enum(['asc', 'desc']).default('asc').catch('asc'),
});
type UsersSearch = z.infer<typeof usersSearchSchema>;
export const Route = createFileRoute('/users/')({
validateSearch: usersSearchSchema,
loader: ({ context: { queryClient }, search }) =>
queryClient.ensureQueryData(usersQueries.list(search)),
component: UsersListPage,
});
function UsersListPage(): React.ReactElement {
const search = Route.useSearch();
const navigate = Route.useNavigate();
const { data: users } = useSuspenseQuery(usersQueries.list(search));
const setPage = (page: number): void => {
navigate({ search: (prev) => ({ ...prev, page }) });
};
const setSearch = (searchTerm: string): void => {
navigate({
search: (prev) => ({
...prev,
search: searchTerm || undefined, // Remove empty string from URL
page: 1, // Reset to page 1 on new search
}),
});
};
const setSortBy = (sortBy: UsersSearch['sortBy']): void => {
navigate({ search: (prev) => ({ ...prev, sortBy }) });
};
return (
<div>
<input
type="search"
defaultValue={search.search ?? ''}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search users..."
/>
<table>
<thead>
<tr>
<th>
<button onClick={() => setSortBy('name')}>Name</button>
</th>
<th>
<button onClick={() => setSortBy('email')}>Email</button>
</th>
</tr>
</thead>
<tbody>
{users.items.map((user) => (
<tr key={user.id}>
<td>{user.name}</td>
<td>{user.email}</td>
</tr>
))}
</tbody>
</table>
<Pagination
currentPage={search.page}
totalPages={users.totalPages}
onPageChange={setPage}
/>
</div>
);
}
import { Link } from '@tanstack/react-router';
// ✅ Type-safe — TypeScript validates search params
<Link
to="/users"
search={{ page: 2, sortBy: 'name', sortOrder: 'desc' }}
>
View Users
</Link>
// ✅ Preserve existing search params
<Link
to="/users"
search={(prev) => ({ ...prev, page: prev.page + 1 })}
>
Next Page
</Link>
// src/routes/_authenticated.tsx
import { createFileRoute, Outlet, redirect } from '@tanstack/react-router';
export const Route = createFileRoute('/_authenticated')({
beforeLoad: ({ context }) => {
// Check auth state — redirect if not authenticated
const isAuthenticated = checkAuthState(); // your auth check
if (!isAuthenticated) {
throw redirect({
to: '/login',
search: {
redirect: location.href, // Remember where user was going
},
});
}
},
component: AuthenticatedLayout,
});
function AuthenticatedLayout(): React.ReactElement {
return (
<div className="flex">
<aside>{/* Sidebar for authenticated users */}</aside>
<div className="flex-1">
<Outlet />
</div>
</div>
);
}
// src/routes/_authenticated/dashboard.tsx
import { createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute('/_authenticated/dashboard')({
loader: ({ context: { queryClient } }) =>
queryClient.ensureQueryData(dashboardQueries.summary()),
component: DashboardPage,
});
function DashboardPage(): React.ReactElement {
// This component only renders if auth guard passes
const { data } = useSuspenseQuery(dashboardQueries.summary());
return <div>{/* Dashboard content */}</div>;
}
// ❌ BAD: JSX wrapper for auth — causes flash of protected content
function ProtectedRoute({ children }: { children: React.ReactNode }): React.ReactElement {
const { isAuthenticated } = useAuth();
if (!isAuthenticated) return <Navigate to="/login" />;
return <>{children}</>;
}
// ✅ GOOD: beforeLoad — redirect happens BEFORE any component renders
beforeLoad: ({ context }) => {
if (!context.auth.isAuthenticated) {
throw redirect({ to: '/login' });
}
}
import { useRouterState } from '@tanstack/react-router';
function GlobalPendingIndicator(): React.ReactElement | null {
const isLoading = useRouterState({ select: (s) => s.isLoading });
if (!isLoading) return null;
return (
<div className="fixed top-0 left-0 right-0 z-50">
<div className="h-1 bg-primary animate-pulse" />
</div>
);
}
export const Route = createFileRoute('/users/$userId')({
loader: ({ context: { queryClient }, params: { userId } }) =>
queryClient.ensureQueryData(usersQueries.detail(userId)),
pendingComponent: UserDetailPending,
component: UserDetailPage,
});
function UserDetailPending(): React.ReactElement {
return (
<div className="animate-pulse space-y-4">
<div className="h-8 bg-muted rounded w-1/3" />
<div className="h-4 bg-muted rounded w-2/3" />
<div className="h-4 bg-muted rounded w-1/2" />
</div>
);
}
// src/routes/users/$userId.tsx
import { createFileRoute, notFound } from '@tanstack/react-router';
export const Route = createFileRoute('/users/$userId')({
loader: async ({ context: { queryClient }, params: { userId } }) => {
const user = await queryClient.ensureQueryData(usersQueries.detail(userId));
if (!user) {
throw notFound();
}
return user;
},
notFoundComponent: UserNotFound,
component: UserDetailPage,
});
function UserNotFound(): React.ReactElement {
return (
<div className="text-center py-12">
<h2>User not found</h2>
<p>The user you're looking for doesn't exist or has been removed.</p>
<Link to="/users">Back to Users</Link>
</div>
);
}
// src/routes/$.tsx
import { createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute('/$')({
component: CatchAllNotFound,
});
function CatchAllNotFound(): React.ReactElement {
return (
<div className="flex flex-col items-center justify-center min-h-[60vh]">
<h1 className="text-4xl font-bold">404</h1>
<p className="text-muted-foreground mt-2">Page not found</p>
<Link to="/" className="mt-4 underline">
Go home
</Link>
</div>
);
}
By default, TanStack Router with file-based routing code-splits every route. The component, loader, and other route options are lazy-loaded when the route is navigated to.
// src/routes/users/$userId.tsx
// This entire file is lazy-loaded when /users/:userId is navigated to
import { createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute('/users/$userId')({
loader: ({ context: { queryClient }, params: { userId } }) =>
queryClient.ensureQueryData(usersQueries.detail(userId)),
component: UserDetailPage,
});
function UserDetailPage(): React.ReactElement {
// Component code is not in the main bundle
return <div>{/* ... */}</div>;
}
// Router config enables preloading
const router = createRouter({
routeTree,
context: { queryClient },
defaultPreload: 'intent', // Preload on hover/focus
defaultPreloadDelay: 0, // No delay
});
// Links automatically preload on hover
<Link to="/users/$userId" params={{ userId: '123' }}>
View User {/* Preloads route + data on hover */}
</Link>
// WRONG: useEffect + fetch in routed components
function UserPage(): React.ReactElement {
const [user, setUser] = useState<User | null>(null);
const { userId } = Route.useParams();
useEffect(() => {
fetchUser(userId).then(setUser);
}, [userId]);
if (!user) return <div>Loading...</div>;
return <div>{user.name}</div>;
}
// CORRECT: Loader + useSuspenseQuery
export const Route = createFileRoute('/users/$userId')({
loader: ({ context: { queryClient }, params: { userId } }) =>
queryClient.ensureQueryData(usersQueries.detail(userId)),
component: UserPage,
});
function UserPage(): React.ReactElement {
const { userId } = Route.useParams();
const { data: user } = useSuspenseQuery(usersQueries.detail(userId));
return <div>{user.name}</div>;
}
// WRONG: Causes flash of protected content
<Route element={<ProtectedRoute><Dashboard /></ProtectedRoute>} />
// CORRECT: Auth check before any component renders
beforeLoad: ({ context }) => {
if (!context.auth.isAuthenticated) {
throw redirect({ to: '/login' });
}
}
// WRONG: Direct import — hard to test, couples route to singleton
import { queryClient } from '@/lib/query-client';
loader: () => queryClient.ensureQueryData(...)
// CORRECT: QueryClient from router context — injectable, testable
loader: ({ context: { queryClient } }) =>
queryClient.ensureQueryData(...)
Route.useParams()validateSearchensureQueryData in loader + useSuspenseQuery in componentbeforeLoad + throw redirect()pendingComponent and useRouterStatenotFound() + notFoundComponent