Asia/Kolkata
BlogSeptember 30, 2026

Role-Based Auth in React + TypeScript: Seeker vs Company vs Admin

Mahenoor Salat
Login is easy. Who gets to see what is where apps break. My Job Executive platform has three roles — Job Seeker, Company, Admin — each with its own screens and permissions. Here is the exact pattern, from the open-source Fullstack-Task2 repo. Everything flows from one union type. If a role can't be spelled here, it can't exist in the app:
Typescript
// types.ts
export type UserRole = "seeker" | "company" | "admin";

export interface SessionUser {
  id: string;
  name: string;
  email: string;
  role: UserRole;
}

export interface Session {
  user: SessionUser;
  token: string;
}
TypeScript now rejects invalid roles at compile time — in route guards, in API calls, in conditional UI. One source of truth. A ProtectedRoute that only checks "logged in" lets a seeker open /admin/users by typing the URL. Gate on the role:
Tsx
function RequireRole({ roles, children }: { roles: UserRole[]; children: ReactNode }) {
  const { session } = useAuth();

  if (!session) return <Navigate to="/login" replace />;
  if (!roles.includes(session.user.role)) return <Navigate to="/unauthorized" replace />;
  return <>{children}</>;
}

// Usage
<Route path="/jobs" element={
  <RequireRole roles={["seeker", "admin"]}><JobBoard /></RequireRole>
} />
<Route path="/post-job" element={
  <RequireRole roles={["company", "admin"]}><PostJob /></RequireRole>
} />
<Route path="/admin" element={
  <RequireRole roles={["admin"]}><AdminPanel /></RequireRole>
} />
Admin is included wherever oversight is needed; seekers can never reach company or admin screens, even by URL. Don't render buttons the user can't use — it teaches them the feature exists and invites tampering:
Tsx
function Dashboard() {
  const { session } = useAuth();
  return (
    <nav>
      <Link to="/jobs">Browse Jobs</Link>
      {(session.user.role === "company" || session.user.role === "admin") && (
        <Link to="/post-job">Post a Job</Link>
      )}
      {session.user.role === "admin" && (
        <Link to="/admin">Admin Panel</Link>
      )}
    </nav>
  );
}
UI gating is UX, not security — the route gate in step 2 and server-side checks are the real enforcement. All three layers together is the pattern. Hiring managers won't register to review your app. Ship one-click demo accounts per role:
Tsx
const DEMO_ACCOUNTS: Record<UserRole, { email: string; password: string }> = {
  seeker: { email: "seeker@demo.dev", password: "demo1234" },
  company: { email: "company@demo.dev", password: "demo1234" },
  admin: { email: "admin@demo.dev", password: "demo1234" },
};

function QuickLogin({ role }: { role: UserRole }) {
  const { login } = useAuth();
  return (
    <button onClick={() => login(DEMO_ACCOUNTS[role].email, DEMO_ACCOUNTS[role].password)}>
      Continue as {role}
    </button>
  );
}
This is why my live demo converts reviewers: three clicks show all three experiences. Seed these accounts in your dev database, never in production with real privileges. The frontend role is convenience. The backend re-verifies on every request — same middleware shape as my MERN JWT tutorial:
Typescript
// token carries { sub, role }; verified per request
app.post("/api/jobs",
  requireAuth,
  requireRole(["company", "admin"]),
  createJob
);
A user who edits localStorage to "role": "admin" gets a UI that looks admin and an API that still says 403. That gap is the whole point of the pattern. Fifteen-minute tokens cause mid-session logouts unless the client refreshes proactively. One hook at the app root handles it:
Tsx
function useTokenRefresh() {
  const { session } = useAuth();

  useEffect(() => {
    if (!session) return;
    const payload = JSON.parse(atob(session.token.split(".")[1]));
    const msUntilExpiry = payload.exp * 1000 - Date.now();
    const refreshIn = msUntilExpiry - 60_000; // 1 minute early
    if (refreshIn <= 0) return void refreshSession();
    const timer = setTimeout(refreshSession, refreshIn);
    return () => clearTimeout(timer);
  }, [session]);
}
Decode is UI logic only — the server still re-verifies every request, as in section 5. One nav component that adapts to the role keeps UI and routes consistent:
Tsx
const NAV: { to: string; label: string; roles: UserRole[] }[] = [
  { to: "/jobs", label: "Browse Jobs", roles: ["seeker", "company", "admin"] },
  { to: "/post-job", label: "Post a Job", roles: ["company", "admin"] },
  { to: "/admin", label: "Admin", roles: ["admin"] },
];

function RoleNav() {
  const { session } = useAuth();
  return (
    <nav>
      {NAV.filter((item) => item.roles.includes(session.user.role)).map((item) => (
        <Link key={item.to} to={item.to}>{item.label}</Link>
      ))}
    </nav>
  );
}
Same roles arrays as the route gates — define them once and share between nav and router so they can never drift apart. Working app with all three roles, demo logins, and live deploy: Fullstack-Task2 on GitHub · live demo · case study. Building a multi-role platform? Hire me — send the brief, get a fixed quote in 48 hours.
Share this post: