Building a Modern Authentication System with React, TypeScript, Zustand, and React Router
Erik Nguyen / December 31, 2024
Authentication is a crucial aspect of modern web applications, but implementing it correctly can be challenging. In this guide, we'll build a complete authentication system using React, TypeScript, Zustand for state management, and React Router for handling protected routes. We'll create a solution that's both type-safe and maintainable.
Setting Up Our Project
First, let's create a new React project with TypeScript and install our dependencies. If you're starting from scratch:
npm create vite@latest my-auth-app -- --template react-ts
cd my-auth-app
npm install zustand react-router-dom axios
Defining Our Authentication Types
Let's start by defining our authentication types. Create a new file called types/auth.ts
:
export interface User {
id: string;
email: string;
name: string;
}
export interface AuthState {
user: User | null;
token: string | null;
loading: boolean;
login: (email: string, password: string) => Promise<void>;
logout: () => void;
checkAuth: () => Promise<void>;
}
Creating Our Authentication Store
Now, let's implement our authentication store using Zustand. Create a new file called stores/authStore.ts
:
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import axios from 'axios';
import { AuthState } from '../types/auth';
// We'll create an axios instance with a base URL
const api = axios.create({
baseURL: 'https://api.your-backend.com',
});
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
user: null,
token: null,
loading: true,
login: async (email: string, password: string) => {
try {
// Replace with your actual API endpoint
const response = await api.post('/auth/login', {
email,
password,
});
const { user, token } = response.data;
// Update our store with the authentication data
set({ user, token, loading: false });
// Set the token in axios defaults for subsequent requests
api.defaults.headers.common['Authorization'] = `Bearer ${token}`;
} catch (error) {
console.error('Login failed:', error);
throw error;
}
},
logout: () => {
// Clear the authentication state
set({ user: null, token: null });
// Remove the token from axios defaults
delete api.defaults.headers.common['Authorization'];
},
checkAuth: async () => {
try {
set({ loading: true });
// Replace with your actual API endpoint
const response = await api.get('/auth/me');
const user = response.data;
set({ user, loading: false });
} catch (error) {
set({ user: null, token: null, loading: false });
}
},
}),
{
name: 'auth-storage', // Name for the persisted store
partialize: (state) => ({ token: state.token }), // Only persist the token
}
)
);
Creating Protected Route Components
Let's create a higher-order component to protect our routes. Create a new file called components/ProtectedRoute.tsx
:
import { ReactNode } from 'react';
import { Navigate, useLocation } from 'react-router-dom';
import { useAuthStore } from '../stores/authStore';
interface ProtectedRouteProps {
children: ReactNode;
}
export const ProtectedRoute = ({ children }: ProtectedRouteProps) => {
const { user, loading } = useAuthStore();
const location = useLocation();
// Show loading state while checking authentication
if (loading) {
return <div>Loading...</div>;
}
// Redirect to login if user is not authenticated
if (!user) {
return <Navigate to="/login" state={{ from: location }} replace />;
}
return <>{children}</>;
};
Implementing the Login Component
Create a new file called components/Login.tsx
:
import { FormEvent, useState } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { useAuthStore } from '../stores/authStore';
export const Login = () => {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const navigate = useNavigate();
const location = useLocation();
const login = useAuthStore((state) => state.login);
// Get the page we were trying to visit
const from = location.state?.from?.pathname || '/';
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setError('');
try {
await login(email, password);
navigate(from, { replace: true });
} catch (err) {
setError('Login failed. Please check your credentials.');
}
};
return (
<div className="login-container">
<h1>Login</h1>
{error && <div className="error">{error}</div>}
<form onSubmit={handleSubmit}>
<div>
<label htmlFor="email">Email:</label>
<input
type="email"
id="email"
value={email}
onChange={(e)=> setEmail(e.target.value)}
required
/>
</div>
<div>
<label htmlFor="password">Password:</label>
<input
type="password"
id="password"
value={password}
onChange={(e)=> setPassword(e.target.value)}
required
/>
</div>
<button type="submit">Login</button>
</form>
</div>
);
};
Setting Up Routes
Now, let's set up our application routes in App.tsx
:
import { useEffect } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { useAuthStore } from './stores/authStore';
import { ProtectedRoute } from './components/ProtectedRoute';
import { Login } from './components/Login';
import { Dashboard } from './components/Dashboard';
const App = () => {
const checkAuth = useAuthStore((state) => state.checkAuth);
useEffect(() => {
checkAuth();
}, [checkAuth]);
return (
<BrowserRouter>
<Routes>
<Route path='/login' element={<Login />} />
<Route
path='/dashboard'
element={
<ProtectedRoute>
<Dashboard />
</ProtectedRoute>
}
/>
{/* Add more protected routes as needed */}
</Routes>
</BrowserRouter>
);
};
export default App;
Using Authentication in Components
Here's how you can use the authentication state in any component:
import { Link } from 'react-router-dom';
import { useAuthStore } from '../stores/authStore';
export const NavBar = () => {
const { user, logout } = useAuthStore();
return (
<nav>
{user ? (
<>
<span>Welcome, {user.name}!</span>
<button onClick={logout}>Logout</button>
</>
) : (
<Link to="/login">Login</Link>
)}
</nav>
);
};
Adding TypeScript Best Practices
To make our authentication system more robust, let's add some TypeScript utilities:
// utils/auth.ts
import { useAuthStore } from '../stores/authStore';
export const isAuthenticated = (): boolean => {
const user = useAuthStore.getState().user;
return user !== null;
};
export const getAuthToken = (): string | null => {
return useAuthStore.getState().token;
};
// Type guard for user object
export const isUser = (user: unknown): user is User => {
return (
typeof user === 'object' &&
user !== null &&
'id' in user &&
'email' in user &&
'name' in user
);
};
Handling API Requests
Let's create a utility for making authenticated API requests:
// utils/api.ts
import axios, { AxiosRequestConfig } from 'axios';
import { getAuthToken } from './auth';
export const api = axios.create({
baseURL: 'https://api.your-backend.com',
});
api.interceptors.request.use((config) => {
const token = getAuthToken();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
api.interceptors.response.use(
(response) => response,
async (error) => {
if (error.response?.status === 401) {
// Handle token expiration
useAuthStore.getState().logout();
window.location.href = '/login';
}
return Promise.reject(error);
}
);
Conclusion
We've built a complete authentication system that:
- Manages authentication state with Zustand
- Protects routes using React Router
- Persists authentication tokens
- Handles API requests with proper authentication headers
- Provides type safety with TypeScript
- Includes error handling and loading states
This implementation provides a solid foundation for building secure React applications. You can extend it further by adding features like:
- Refresh tokens
- Remember me functionality
- Password reset flows
- Social authentication
- Multi-factor authentication
Remember to always follow security best practices:
- Store sensitive data securely
- Use HTTPS for all API requests
- Implement proper CSRF protection
- Handle token expiration gracefully
- Validate user input
- Implement rate limiting
- Use secure password hashing on the backend
The complete code for this implementation is available in the repository linked below