Tanstack router with auth and roles
Hooking up React + TanStack Router to an authenticated Hono backend with role-based guards.
October 13, 2025
You can see the full source code on GitHub.
For context, this tutorial extends the previous article Hono, Swagger, Pino.
pnpm add @tanstack/react-router @tanstack/react-router-devtools
pnpm add -D @tanstack/router-plugin
At the time of writing, Rolldown is not compatible with @tanstack/router-plugin.
Use standard Vite version instead, for example "vite": "^7.1.9", then run pnpm install.
Setup auth hooks
Delete src/App.tsx and src/App.css, They are no longer needed.
Create a fetch wrapper for RPC errors. On 401 we disconnect. On 403 we refresh the profile to re-evaluate roles.
We will define useAuth later.
src/hooks/useRpcClient.ts
import { useRouter } from '@tanstack/react-router';
import type { AppType } from 'backend/hc';
import { hc } from 'hono/client';
import { useEffect } from 'react';
import { useAuth } from '../context/auth.context';
export async function toError(res: Response) {
let msg = `${res.status} ${res.statusText}`;
try {
const ct = res.headers.get('content-type') || '';
if (ct.includes('application/json')) {
const body = await res.json();
if (Array.isArray(body?.error) && body.error.length > 0) {
const messages = body.error.map((e: { message?: string }) => e?.message).filter(Boolean);
if (messages.length > 0) {
msg = messages.join(', ');
}
} else if (typeof body?.message === 'string' && body.message.length > 0) {
msg = body.message;
}
} else {
const text = await res.text();
if (text) msg = text;
}
} catch {}
const err = new Error(msg) as Error & { status: number; response: Response };
err.status = res.status;
err.response = res;
throw err;
}
export function useRpcClient() {
const { disconnect, refreshProfile, user } = useAuth();
const router = useRouter();
useEffect(() => {
if (user === null || user) {
router.invalidate();
}
}, [user, router.invalidate]);
const request: typeof fetch = async (input, init) => {
const res = await fetch(input, init);
if (res.ok) return res;
if (res.status === 401) {
disconnect();
}
if (res.status === 403) {
await refreshProfile();
}
const error = await toError(res);
throw error;
};
return hc<AppType>('/api', { fetch: request });
}
Now we can use our hook to handle login errors.
// src/api/fetchLogin.ts
import { toError } from '../hooks/useRpcClient';
import { client } from '../lib/hc.ts';
export type PostLoginParams = {
email: string;
password: string;
};
export async function postLogin({ email, password }: PostLoginParams) {
const res = await client.auth.login.$post({ json: { email, password } });
if (res.ok) return await res.json();
const error = await toError(res);
throw error;
}
export async function postLogout() {
const res = await client.auth.logout.$post();
const data = await res.json();
return data;
}
// src/api/fetchLogin.ts
import { useMutation } from '@tanstack/react-query';
import { type PostLoginParams, postLogin, postLogout } from '../api/fetchLogin';
export const useLogin = () =>
useMutation({
mutationFn: (json: PostLoginParams) => postLogin(json),
});
export const useLogout = () =>
useMutation({
mutationFn: () => postLogout(),
});
We expose:
isAuthenticated:boolean;user:User|null;hasRole:(role: string) => boolean;hasAnyRole:(roles: string[]) => boolean;login:(params: PostLoginParams) => Promise<void>;refreshProfile:() => Promise<void>;logout:() => Promise<void>;disconnect:() => void;
The useEffect fetches the profile when an auth. cookie is present. If it succeeds, the user is considered logged in.
src/context/auth.context.ts
import { useQueryClient } from '@tanstack/react-query';
import { createContext, type ReactNode, useCallback, useContext, useEffect, useState } from 'react';
import { COOKIE_AUTH_NAME } from '../config/cookies.constant';
// export const COOKIE_AUTH_NAME = import.meta.env.VITE_COOKIE_AUTH_NAME || 'auth_token';
import { useLogin, useLogout } from '../hooks/useAuth';
import type { User } from '../hooks/useProfile';
import { client } from '../lib/hc';
type PostLoginParams = { email: string; password: string };
export type AuthState = {
isAuthenticated: boolean;
user: User | null;
hasRole: (role: string) => boolean;
hasAnyRole: (roles: string[]) => boolean;
login: (params: PostLoginParams) => Promise<void>;
refreshProfile: () => Promise<void>;
logout: () => Promise<void>;
disconnect: () => void;
};
const AuthContext = createContext<AuthState | undefined>(undefined);
function hasSession() {
return document.cookie.includes(`${COOKIE_AUTH_NAME}=`);
}
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [isAuthenticated, setIsAuthenticated] = useState(hasSession());
const [isLoading, setIsLoading] = useState(isAuthenticated);
const loginMutation = useLogin();
const logoutMutation = useLogout();
const queryClient = useQueryClient();
useEffect(() => {
if (!isAuthenticated) return;
(async () => {
setIsLoading(true);
try {
const data = await queryClient.fetchQuery({
queryKey: ['profile'],
queryFn: async () => {
const res = await client.profile.me.$get();
if (!res.ok) throw new Error('Failed to fetch profile');
return (await res.json()) as User;
},
});
setUser(data);
} catch (error) {
setIsAuthenticated(false);
setUser(null);
queryClient.removeQueries({ queryKey: ['profile'] });
console.error('Error fetching profile:', error);
} finally {
setIsLoading(false);
}
})();
}, [isAuthenticated, queryClient]);
const hasRole = useCallback(
(role: string) => {
return user?.role === role;
},
[user?.role],
);
const hasAnyRole = useCallback(
(roles: string[]) => {
return !!user?.role && roles.includes(user.role);
},
[user?.role],
);
const refreshProfile = useCallback(async () => {
if (!isAuthenticated) return;
try {
const data = await queryClient.fetchQuery({
queryKey: ['profile'],
queryFn: async () => {
const res = await client.profile.me.$get();
if (!res.ok) throw new Error('Failed to fetch profile');
return await res.json();
},
});
setUser(data);
} catch (error) {
setIsAuthenticated(false);
setUser(null);
queryClient.removeQueries({ queryKey: ['profile'] });
console.error('Error fetching profile:', error);
}
}, [isAuthenticated, queryClient]);
const login = useCallback(
async ({ email, password }: PostLoginParams) => {
await loginMutation.mutateAsync({ email, password });
const data = await queryClient.fetchQuery({
queryKey: ['profile'],
queryFn: async () => {
const res = await client.profile.me.$get();
if (!res.ok) throw new Error('Failed to fetch profile');
return await res.json();
},
});
queryClient.setQueryData(['profile'], data);
setUser(data);
setIsAuthenticated(true);
},
[loginMutation, queryClient],
);
const logout = useCallback(async () => {
await logoutMutation.mutateAsync();
setUser(null);
setIsAuthenticated(false);
queryClient.removeQueries({ queryKey: ['profile'] });
}, [logoutMutation, queryClient]);
const disconnect = useCallback(() => {
setUser(null);
setIsAuthenticated(false);
queryClient.removeQueries({ queryKey: ['profile'] });
}, [queryClient]);
if (isLoading) {
return <div>Loading...</div>;
}
return (
<AuthContext.Provider
value={{
isAuthenticated,
user,
hasRole,
hasAnyRole,
login,
refreshProfile,
disconnect,
logout,
}}
>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}
Setup tanstack-router
Use the provider in main.tsx.
src/main.tsx
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import './index.css';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { RouterProvider } from '@tanstack/react-router';
import { AuthProvider, useAuth } from './context/auth.context.tsx';
import { router } from './lib/router.lib.ts';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
declare module '@tanstack/react-router' {
interface Register {
router: typeof router;
}
}
function InnerApp() {
const auth = useAuth();
return <RouterProvider router={router} context={{ auth }} />;
}
function App() {
return (
<AuthProvider>
<InnerApp />
</AuthProvider>
);
}
const rootElement = document.getElementById('root')!;
if (!rootElement.innerHTML) {
const root = createRoot(rootElement);
root.render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</StrictMode>,
);
}
Route directory:
- authenticated users can access
/home - admins can access
/admin/users/* - unauthenticated users are redirected to
/ - authenticated users visiting
/loginor/registerare redirected to/home
apps/frontend/src/routes
├── _auth
│ ├── admin
│ │ └── users
│ │ ├── $userId.tsx
│ │ ├── index.css
│ │ ├── index.tsx
│ │ └── new.tsx
│ ├── route.tsx
│ └── _user
│ └── home.tsx
├── index.tsx
├── login.css
├── login.tsx
├── register.tsx
└── __root.tsx
The declaration of our route, plugin.
src/utils/router.lib.ts
import { createRouter } from '@tanstack/react-router';
import { routeTree } from '../routeTree.gen';
export const router = createRouter({
routeTree,
defaultPreload: 'intent',
defaultPreloadStaleTime: 0,
scrollRestoration: true,
context: {
auth: undefined!,
},
});
Create the root route.
src/pages/__root.tsx
import { createRootRouteWithContext, HeadContent, Outlet } from '@tanstack/react-router';
import { TanStackRouterDevtools } from '@tanstack/react-router-devtools';
import type { AuthState } from '../context/auth.context';
interface RouterContext {
auth: AuthState;
}
export const Route = createRootRouteWithContext<RouterContext>()({
component: () => (
<>
<HeadContent />
<Outlet />
<TanStackRouterDevtools />
</>
),
});
login.tsx and register.tsx are simple forms with guards that redirect authenticated users.
src/pages/login.tsx
import { createFileRoute, Link, redirect, useNavigate } from '@tanstack/react-router';
import { useEffect, useState } from 'react';
import { ErrorMessage } from '../components/error.alert';
import { useAuth } from '../context/auth.context';
export const Route = createFileRoute('/login')({
component: RouteComponent,
beforeLoad: ({ context }) => {
if (context.auth.isAuthenticated) {
throw redirect({ to: '/home' });
}
},
});
function RouteComponent() {
const { login, isAuthenticated } = useAuth();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState<Error | null>(null);
const navigate = useNavigate();
useEffect(() => {
if (isAuthenticated) navigate({ to: '/home' });
}, [isAuthenticated, navigate]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
await login({ email, password });
} catch (error) {
setError(error as Error);
}
};
return (
<>
<Link to="/register">Go to Register</Link>
<div className="form-container">
<form className="form" onSubmit={handleSubmit}>
<h3 className="form-title">Login</h3>
<label>
Email
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} required />
</label>
<label>
Password
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} required />
</label>
<button type="submit">Sign in</button>
<ErrorMessage error={error} className="error" />
</form>
</div>
</>
);
}
src/pages/register.tsx
import { createFileRoute, Link, redirect, useNavigate } from '@tanstack/react-router';
import { useState } from 'react';
import { ErrorMessage } from '../components/error.alert';
import { usePostUser } from '../hooks/useUsers';
export const Route = createFileRoute('/register')({
component: RouteComponent,
beforeLoad: ({ context }) => {
if (context.auth.isAuthenticated) {
throw redirect({ to: '/home' });
}
},
});
function RouteComponent() {
const navigate = useNavigate();
const {
mutate: register,
isPending,
error,
} = usePostUser({
onSuccess: () => navigate({ to: '/login' }),
});
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
register({ email, password });
};
return (
<>
<Link to="/login">Go to Login</Link>
<div className="form-container">
<form className="form" onSubmit={handleSubmit}>
<h3 className="form-title">Register</h3>
<label>
Email
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} required />
</label>
<label>
Password
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} required />
</label>
<button type="submit" disabled={isPending}>
{isPending ? 'Loading…' : 'Create account'}
</button>
<ErrorMessage error={error} className="error" />
</form>
</div>
</>
);
}
_auth/route.tsx protects authenticated sections.
src/pages/_auth/route.tsx
import { createFileRoute, Outlet, redirect } from '@tanstack/react-router';
export const Route = createFileRoute('/_auth')({
component: RouteComponent,
beforeLoad: async ({ context }) => {
if (!context.auth.isAuthenticated) {
throw redirect({
to: '/',
search: {
redirect: location.href,
reason: 'not_logged',
},
});
}
},
});
function RouteComponent() {
return <Outlet />;
}
Display the user home with a link to admin pages.
src/pages/_auth/_user/home.tsx
import { createFileRoute, Link, redirect } from '@tanstack/react-router';
import { ROLE } from 'common/constants';
import { useAuth } from '../../../context/auth.context';
export const Route = createFileRoute('/_auth/_user/home')({
component: RouteComponent,
beforeLoad: async ({ context }) => {
const allowedRoles = [ROLE.USER, ROLE.ADMIN];
if (!context.auth.hasAnyRole(allowedRoles)) {
throw redirect({
to: '/admin/users',
});
}
},
});
function RouteComponent() {
const { user, hasRole } = useAuth();
return (
<>
<div className="p-2">Hello {user?.email || 'User'}</div>
<br />
<Link to="/">Go to Public Home</Link>
{hasRole(ROLE.ADMIN) && (
<>
<br />
<Link to="/admin/users">Go to Admin Users</Link>
</>
)}
</>
);
}
/admin/users lists users for admins.
src/features/users/users.controller.ts
import { createFileRoute, Link, redirect } from '@tanstack/react-router';
import { ROLE } from 'common/constants';
import { useUsers } from '../../../../hooks/useUsers.ts';
import './index.css';
export const Route = createFileRoute('/_auth/admin/users/')({
component: RouteComponent,
beforeLoad: async ({ context }) => {
const allowedRoles = [ROLE.ADMIN];
if (!context.auth.hasAnyRole(allowedRoles)) {
throw redirect({ to: '/home' });
}
},
});
function RouteComponent() {
const { data: users, isLoading, error } = useUsers();
if (isLoading) return <p>Loading users…</p>;
if (error) return <p className="error">Error loading users</p>;
if (!users?.length) return <p>No users found.</p>;
return (
<div className="admin-container">
<h1>User Management</h1>
<Link to="/admin/users/new">Create New User</Link>
<br />
<table className="user-table">
<thead>
<tr>
<th>ID</th>
<th>Email</th>
<th>Role</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{users.map((u) => (
<tr key={u.id}>
<td>{u.id}</td>
<td>{u.email}</td>
<td>{u.role}</td>
<td>
<Link to="/admin/users/$userId" params={{ userId: u.id }}>
Edit
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
I added the other admin pages in the repo. See GitHub