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.

FSI
Full Stack Insights
7 min read...

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 RequireAuth wrapper for protected routes
  • A simple form of role-based access for an /dashboard/admin page

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 /login and 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 /login route
  • Wrap the entire /dashboard branch in RequireAuth
  • Protect /dashboard/admin with allowedRoles={["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 /dashboard and 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
Share this article:

Related Articles

FSI

Full Stack Insights

Software Engineer

Passionate about software development, architecture, and sharing knowledge with the community.