Routing in React with React Router and TypeScript – Part 4: Auth and Protected Routes
Add authentication and protected routes to your React Router + TypeScript app. We’ll build a simple auth flow with login, logout, and role-based access inside the Vite-powered dashboard.
By now, our React Router + TypeScript app has:
- A clean layout and nested routes
- A dashboard section with projects and tasks
- Data loading and mutations via loaders and actions
In Part 4, we'll address one of the most common real-world needs: authentication and protected routes.
We'll implement:
- A minimal auth context with
login/logout - A login page
- A
RequireAuthwrapper for protected routes - A simple form of role-based access for an
/dashboard/adminpage
Again, we'll keep everything in memory for clarity, but mirror patterns you can use with real backends.
1. Create an auth context
We'll model a simple authenticated user with a role and expose it via React context.
Create src/features/auth/authTypes.ts:
export type UserRole = "user" | "admin";
export interface AuthUser {
id: string;
name: string;
role: UserRole;
}
Create src/features/auth/AuthContext.tsx:
import React, { createContext, useContext, useState } from "react";
import type { AuthUser, UserRole } from "./authTypes";
interface AuthContextValue {
user: AuthUser | null;
login: (name: string, role?: UserRole) => void;
logout: () => void;
}
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [user, setUser] = useState<AuthUser | null>(null);
const login = (name: string, role: UserRole = "user") => {
setUser({
id: "u1",
name,
role,
});
};
const logout = () => {
setUser(null);
};
return (
<AuthContext.Provider value={{ user, login, logout }}>
{children}
</AuthContext.Provider>
);
};
export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext);
if (!ctx) {
throw new Error("useAuth must be used within an AuthProvider");
}
return ctx;
}
This keeps auth state in memory. In a real app, you'd persist tokens or session info and rehydrate from local storage or cookies.
2. Wrap the app with AuthProvider
Update src/main.tsx to wrap RouterProvider with AuthProvider:
import React from "react";
import ReactDOM from "react-dom/client";
import { RouterProvider } from "react-router-dom";
import { router } from "./router";
import { AuthProvider } from "./features/auth/AuthContext";
import "./index.css";
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<AuthProvider>
<RouterProvider router={router} />
</AuthProvider>
</React.StrictMode>
);
Now any component can call useAuth().
3. Create a login page
We'll add a simple login page under /login.
Create src/routes/LoginPage.tsx:
import { FormEvent, useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { useAuth } from "../features/auth/AuthContext";
import type { UserRole } from "../features/auth/authTypes";
interface LocationState {
from?: string;
}
export const LoginPage: React.FC = () => {
const [name, setName] = useState("Demo User");
const [role, setRole] = useState<UserRole>("user");
const navigate = useNavigate();
const location = useLocation();
const { login } = useAuth();
const state = (location.state as LocationState | null) || {};
const from = state.from || "/dashboard";
const handleSubmit = (event: FormEvent) => {
event.preventDefault();
login(name, role);
navigate(from, { replace: true });
};
return (
<section>
<h1>Login</h1>
<p>Sign in to access your dashboard.</p>
<form onSubmit={handleSubmit}>
<div>
<label>
Name
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</label>
</div>
<div>
<label>
Role
<select
value={role}
onChange={(e) => setRole(e.target.value as UserRole)}
>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
</label>
</div>
<button type="submit">Login</button>
</form>
</section>
);
};
We read location.state.from to know where to redirect the user after login.
4. Build a RequireAuth wrapper for protected routes
We'll create a component that checks whether a user is logged in and, optionally, has the right role.
Create src/routes/RequireAuth.tsx:
import { Navigate, Outlet, useLocation } from "react-router-dom";
import { useAuth } from "../features/auth/AuthContext";
import type { UserRole } from "../features/auth/authTypes";
interface RequireAuthProps {
allowedRoles?: UserRole[];
}
export const RequireAuth: React.FC<RequireAuthProps> = ({ allowedRoles }) => {
const { user } = useAuth();
const location = useLocation();
if (!user) {
return (
<Navigate
to="/login"
replace
state={{ from: location.pathname + location.search }}
/>
);
}
if (allowedRoles && !allowedRoles.includes(user.role)) {
return (
<section>
<h1>Access Denied</h1>
<p>You do not have permission to view this page.</p>
</section>
);
}
return <Outlet />;
};
This component:
- Redirects unauthenticated users to
/loginand passes along the original URL. - Optionally restricts access to certain roles.
5. Add an admin-only route
We'll add a simple /dashboard/admin page that is only visible to users with the admin role.
Create src/routes/dashboard/AdminPage.tsx:
import { useAuth } from "../../features/auth/AuthContext";
export const AdminPage: React.FC = () => {
const { user } = useAuth();
return (
<section>
<h1>Admin Area</h1>
<p>
Hello {user?.name}, you have admin access. In a real
application, this page would expose higher-privilege actions
like managing users, system settings, or billing.
</p>
</section>
);
};
6. Wire auth and protected routes into the router
We'll:
- Add a
/loginroute - Wrap the entire
/dashboardbranch inRequireAuth - Protect
/dashboard/adminwithallowedRoles={["admin"]}
Update src/router.tsx:
import { createBrowserRouter } from "react-router-dom";
import { AppLayout } from "./routes/AppLayout";
import { HomePage } from "./routes/HomePage";
import { AboutPage } from "./routes/AboutPage";
import { ContactPage } from "./routes/ContactPage";
import { NotFoundPage } from "./routes/NotFoundPage";
import { DashboardLayout } from "./routes/dashboard/DashboardLayout";
import { DashboardOverviewPage } from "./routes/dashboard/DashboardOverviewPage";
import { ProjectsPage } from "./routes/dashboard/ProjectsPage";
import { ProjectDetailsPage } from "./routes/dashboard/ProjectDetailsPage";
import { LoginPage } from "./routes/LoginPage";
import { RequireAuth } from "./routes/RequireAuth";
import { AdminPage } from "./routes/dashboard/AdminPage";
// ... imports from Part 3 for tasks, if present
export const router = createBrowserRouter([
{
path: "/",
element: <AppLayout />,
errorElement: <NotFoundPage />,
children: [
{ index: true, element: <HomePage /> },
{ path: "about", element: <AboutPage /> },
{ path: "contact", element: <ContactPage /> },
{ path: "login", element: <LoginPage /> },
{
element: <RequireAuth />, // protects everything below
children: [
{
path: "dashboard",
element: <DashboardLayout />,
children: [
{ index: true, element: <DashboardOverviewPage /> },
{ path: "projects", element: <ProjectsPage /> },
{ path: "projects/:projectId", element: <ProjectDetailsPage /> },
// tasks routes from Part 3 can appear here
{
element: <RequireAuth allowedRoles={["admin"]} />, // nested guard
children: [
{ path: "admin", element: <AdminPage /> },
],
},
],
},
],
},
],
},
]);
Now /dashboard and all its children require authentication, while AdminPage additionally requires the admin role.
7. Show auth state in the main nav
To make the experience clearer, we'll show the logged-in user and a logout button in the main navigation.
Update src/shared/MainNav.tsx:
import { NavLink } from "react-router-dom";
import { useAuth } from "../features/auth/AuthContext";
import "./MainNav.css";
export const MainNav: React.FC = () => {
const { user, logout } = useAuth();
return (
<header className="main-nav">
<div className="main-nav__brand">My React Router App</div>
<nav className="main-nav__links">
<NavLink
to="/"
end
className={({ isActive }) =>
isActive ? "main-nav__link main-nav__link--active" : "main-nav__link"
}
>
Home
</NavLink>
<NavLink
to="/about"
className={({ isActive }) =>
isActive ? "main-nav__link main-nav__link--active" : "main-nav__link"
}
>
About
</NavLink>
<NavLink
to="/contact"
className={({ isActive }) =>
isActive ? "main-nav__link main-nav__link--active" : "main-nav__link"
}
>
Contact
</NavLink>
<NavLink
to="/dashboard"
className={({ isActive }) =>
isActive ? "main-nav__link main-nav__link--active" : "main-nav__link"
}
>
Dashboard
</NavLink>
</nav>
<div className="main-nav__auth">
{user ? (
<>
<span>
Signed in as {user.name} ({user.role})
</span>
<button onClick={logout}>Logout</button>
</>
) : (
<NavLink
to="/login"
className={({ isActive }) =>
isActive
? "main-nav__link main-nav__link--active"
: "main-nav__link"
}
>
Login
</NavLink>
)}
</div>
</header>
);
};
And in src/shared/MainNav.css, add styles for the auth area if needed.
8. Best practices for auth + routing
Key guidelines when implementing auth in React Router apps:
- Keep auth state in a dedicated context or state manager; don't sprinkle user state across components.
- Use wrapper routes (
RequireAuth) to guard route branches, not individual components. - Preserve the intended destination when redirecting to login using
location.state.from. - Use role-based checks in routing for coarse-grained access control, and further validate permissions on the backend.
9. Recap and what’s next
You now have:
- A basic auth system with login/logout and roles
- Protected routes that require auth to access
/dashboardand its children - An admin-only section within the dashboard
In Part 5, we'll focus on real-world structure and performance:
- Feature-first folder organization
- Code-splitting and lazy loading routes
- Testing navigational flows
- Common pitfalls and production-ready best practices
Related Articles
Routing in React with React Router and TypeScript – Part 5: Architecture and Best Practices
Bring everything together with a production-ready architecture for React Router + TypeScript apps built with Vite. Learn about feature-first structure, lazy routes, testing, and common pitfalls.
Routing in React with React Router and TypeScript – Part 3: Data Loading and CRUD Flows
Learn how to use React Router’s data APIs (loaders and actions) with TypeScript in a Vite React app. We’ll build a simple CRUD flow inside the dashboard section using route-based data fetching and mutations.
Full Stack Insights
Software Engineer
Passionate about software development, architecture, and sharing knowledge.
Quick Links
Full Stack Insights
Software Engineer
Passionate about software development, architecture, and sharing knowledge with the community.