How to create an auth hook to work with zustand and protect your React Router SPA

One of the most common problems when working with a React single-page application is knowing how to protect your routes.

Today, I want to share a very useful Zustand hook that can be very useful for this problem.

Assumptions and how it works

The front end will log in to a backend that will return a JSON web token, which will then be stored in the browser's local storage.

Each time the user accesses the web application, a call is made to the backend with that JWT. Then,using Zustand, we can check whether the user is authorized to use our app.

useAuthStore.ts

import { create } from "zustand";
import { devtools } from "zustand/middleware";

type AuthState = {
  isAuthenticated: boolean;
  isAdmin: boolean;
  user: AuthUser | null;
  isLoading: boolean;
  checkAuth: () => Promise<void>;
  logout: () => void;
};

export type AuthUser = {
  email: string;
  name: string;
  roles: string[];
};

const DEFAULT_USER_STATE = {
  isAuthenticated: false,
  user: null,
  isAdmin: false,
  isLoading: false,
};
export const useAuthStore = create<AuthState>()(
  devtools((set) => ({
    isAuthenticated: false,
    user: null,
    isLoading: true,

    checkAuth: async () => {
      try {
        // Verify the session with the backend
        const token = localStorage.getItem("myJWT");
        const response = await fetch("https://api.example.com/check-auth", {
          method: "GET",
          headers: {
            Authorization: `Bearer ${token}`,
            "Content-Type": "application/json",
          },
        });

        if (!response.ok) {
          throw new Error(`HTTP error! status: ${response.status}`);
        }

        const data = await response.json();

        const { email, name, roles } = data;

        set({
          isAuthenticated: true,
          user: {
            email,
            name,
            roles,
          },
          isAdmin: roles.includes("admin"),
          isLoading: false,
        });
      } catch (error) {
        console.error(error);
        window.location.href = "YOUR_LOGIN_URL";
      }
    },
    logout: () => {
      set({ ...DEFAULT_USER_STATE, isLoading: true });
      localStorage.removeItem("myJWT");
      window.location.href = "YOUR_LOGIN_URL";
    },
  }))
);

PrivateRoute.tsx

import { useAuthStore } from "@/store/useAuthStore";
import { useEffect } from "react";
import { Outlet } from "react-router-dom";

const LoadingScreen = () => (
  <div className="flex flex-col gap-2 items-center justify-center min-h-screen">
    <span>Loading...</span>
  </div>
);
export function PrivateRoute() {
  const { isAuthenticated, isLoading, checkAuth } = useAuthStore();

  useEffect(() => {
    checkAuth();
  }, [checkAuth]);

  // Show loading spinner while checking authentication
  if (isLoading) {
    return <LoadingScreen />;
  }

  if (!isAuthenticated) {
    window.location.href = "YOUR_LOGIN_URL";
    // Show loading while redirect happens
    return <LoadingScreen />;
  }

  return <Outlet />;
}

Happy coding!