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
code
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
$paramNamesyntax (e.g.,$organizationId.tsx) - •Index routes: Use
index.tsxwithin folder
Route Guards with beforeLoad
Pattern: Use beforeLoad for authentication and authorization checks.
tsx
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 } });
}
// Authorization — direct DB query, no cache
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
beforeLoadfor 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
tsx
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
tsx
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:
typescript
// ✅ Correct - Full type safety
const { organizationId, projectId } = useParams({
from: "/_protected/$organizationId/$projectId",
});
// ❌ Wrong - Loses all type safety
const { organizationId } = useParams({ strict: false }) as { organizationId: string };
Anti-Pattern Detection
- •❌
_root.tsx(single underscore) → ✅__root.tsx(double) - •❌
:organizationIdor{organizationId}→ ✅$organizationId - •❌ Component-level auth checks → ✅ Use
beforeLoad - •❌ Cache-based security → ✅ Direct database query
- •❌ Redirect to
/loginwithout preserving path → ✅ Passlocation.pathnameas 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,_prefixconvention - •frontend-page-layout — Page-level height calculations
- •frontend-supabase-auth — Auth session management