| name | frontend-routing |
| description | Use when creating or modifying routes, route guards, or navigation |
Frontend: TanStack Router
All routing uses TanStack Router with file-based routing. Follow these patterns for route naming, guards, params, and navigation.
File-Based Routing Structure
src/routes/
├── __root.tsx # Root layout (double underscore)
├── _protected.tsx # Layout route (single underscore)
├── _protected/
│ ├── index.tsx # Index route
│ └── $organizationId.tsx # Dynamic param route
├── _auth.tsx # Layout route
├── _auth/
│ ├── login.tsx # Public route under layout
│ └── signup.tsx # Public route under layout
└── not-whitelisted.tsx # Standalone public route
Naming rules:
- Root route:
__root.tsx (double underscore)
- Layout routes: Prefix with
_ (e.g., _protected.tsx) — see frontend-route-layout
- Dynamic params: Use
$paramName syntax (e.g., $organizationId.tsx)
- Index routes: Use
index.tsx within folder
Route Guards with beforeLoad
Pattern: Use beforeLoad for authentication and authorization checks.
export const Route = createFileRoute("/_protected")({
beforeLoad: async ({ location }) => {
const { data: { session } } = await supabase.auth.getSession();
if (!session) {
const redirectPath = `${location.pathname}${location.search ? `?${new URLSearchParams(location.search).toString()}` : ""}`;
throw redirect({ to: "/login", search: { redirect: redirectPath } });
}
const { data: profile } = await supabase
.from("profiles")
.select("whitelisted")
.eq("id", session.user.id)
.single();
if (!profile?.whitelisted) {
throw redirect({ to: "/not-whitelisted" });
}
},
component: RouteComponent,
});
Key rules:
- Always use
beforeLoad for security checks (never component-level)
- Preserve redirect path for post-login UX
- Direct database queries for security-critical checks (no cache dependency)
Dynamic Param Routes with Access Control
export const Route = createFileRoute("/_protected/$organizationId")({
beforeLoad: async ({ params }) => {
const { organizationId } = params;
const { data: { session } } = await supabase.auth.getSession();
if (!session) throw redirect({ to: "/login" });
const { data: membership } = await supabase
.from("organization_members")
.select("*")
.eq("user_id", session.user.id)
.eq("organization_id", organizationId)
.maybeSingle();
if (!membership) throw redirect({ to: "/access-denied" });
},
component: Page_Organization,
});
- Use
maybeSingle() to handle no-match gracefully
- Immediate redirect on unauthorized access
Public Routes with Redirect Logic
const LoginSearchSchema = z.object({
redirect: z.string().optional().catch(undefined),
});
export const Route = createFileRoute("/_auth/login")({
validateSearch: LoginSearchSchema.parse,
beforeLoad: async ({ search }) => {
const { data: { session } } = await supabase.auth.getSession();
if (session) throw redirect({ to: search.redirect || "/" });
},
component: Page_Login,
});
- Validate search schema with Zod
- Redirect authenticated users away from login
Extracting Route Params (useParams)
ALWAYS use the from option for full TypeScript type safety:
const { organizationId, projectId } = useParams({
from: "/_protected/$organizationId/$projectId",
});
const { organizationId } = useParams({ strict: false }) as { organizationId: string };
Anti-Pattern Detection
- ❌
_root.tsx (single underscore) → ✅ __root.tsx (double)
- ❌
:organizationId or {organizationId} → ✅ $organizationId
- ❌ Component-level auth checks → ✅ Use
beforeLoad
- ❌ Cache-based security → ✅ Direct database query
- ❌ Redirect to
/login without preserving path → ✅ Pass location.pathname as search param
- ❌
useParams({ strict: false }) → ✅ useParams({ from: '/_protected/$organizationId' })
- ❌ Wrapper components for shared layouts → ✅ Layout routes (see frontend-route-layout)
Related Skills
- frontend-route-layout — Layout route patterns,
Outlet, _prefix convention
- frontend-page-layout — Page-level height calculations
- frontend-supabase-auth — Auth session management