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 /login or /register are 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