Building a Production-Grade React Auth Starter
Authentication is one of those things every frontend developer rebuilds from scratch on every new project. I got tired of that — so I built authforge-client: a clean, scalable React + TypeScript auth starter that handles all the hard parts properly.
What's in the stack
- React + TypeScript (Vite + SWC)
- TanStack Query — server state and mutations
- Zustand — global auth state
- Zod — schema validation and TypeScript types
- React Hook Form — form management
- Framer Motion — animations
- Axios — HTTP client with interceptors
- Tailwind CSS v4
The architecture
The folder structure is feature-based, not type-based:
src/
├─ api/ # axios instance + endpoints
├─ features/
│ ├─ auth/ # login, register, schemas, hooks, services
│ └─ user/ # change password
├─ components/
│ ├─ ui/ # Button, Input, Alert, Spinner
│ └─ layout/ # Navbar, ProtectedRoute, PublicRoute
├─ store/ # Zustand auth store
├─ context/ # AuthContext (session restore)
├─ hooks/ # useAuth, useLogout
└─ utils/ # token helpers, error handler
Each feature owns its own components, hooks, services, schemas, and types. Adding a new feature never touches existing ones.
The token refresh flow
This is where most auth implementations cut corners. Here's the full flow:
On login/register:
- Backend returns
accessTokenin the response body - Backend sets
refreshTokenas anhttpOnlycookie - Frontend stores
accessTokeninsessionStorage - User object goes into Zustand
On every API request:
// Axios request interceptor
config.headers.Authorization = `Bearer ${getToken()}`;
On 401 (expired access token):
// Axios response interceptor
if (error.response?.status === 401) {
// Queue all failed requests
// Call POST /api/token once
// Retry all queued requests with new token
}
The failed queue pattern is important — without it, multiple simultaneous 401s would trigger multiple refresh calls, causing race conditions.
On page refresh:
// ProtectedRoute calls restoreSession()
// restoreSession calls POST /api/token
// Gets fresh accessToken + user from refresh token cookie
// Repopulates Zustand
The session restore problem
One subtle bug I ran into: isInitializing starts as false, so on page refresh at /dashboard, the ProtectedRoute would see isAuthenticated: false and immediately redirect to /login — before restoreSession even ran.
The fix was a module-level flag:
let sessionChecked = false;
const ProtectedRoute = () => {
useEffect(() => {
if (sessionChecked) return;
sessionChecked = true;
void restoreSession();
}, [restoreSession]);
// Never redirect until we've actually checked
if (!sessionChecked || isInitializing) return <Spinner />;
if (!isAuthenticated) return <Navigate to="/login" />;
return <Outlet />;
};
Unlike useRef, a module-level variable persists across route changes — so navigating between protected routes doesn't trigger restoreSession again.
Error handling done right
Most tutorials just catch errors and show "Something went wrong." This starter parses errors properly:
// mutationFn catches raw AxiosError and throws ParsedError
mutationFn: async (payload) => {
try {
const result = await registerService(payload);
setToken(result.accessToken);
setUser(result.user);
} catch (rawError) {
throw parseApiError(rawError); // "Email already registered"
}
}
TanStack Query stores the parsed error, so the UI always shows human-readable messages from the backend.
Multi-step registration form
The register form is split into two steps with Framer Motion slide animations. The key challenge was cross-field validation (password === confirmPassword) on Step 1 before allowing navigation to Step 2.
trigger(["confirmPassword"]) alone doesn't work for cross-field Zod refinements — the fix was using getValues() + setError() manually in the goNext handler:
const goNext = async () => {
const valid = await trigger([...step1Fields]);
if (!valid) return;
const values = getValues();
if (values.password !== values.confirmPassword) {
setError("confirmPassword", {
type: "manual",
message: "Passwords do not match",
});
return;
}
setStep(2);
};
What's next
This starter is the auth foundation for Yummy — a full-stack food ordering app I'm building. The backend is an ExpressJS + TypeScript server (also open source) with JWT, MongoDB sessions, role-based access control, and rate limiting.
- authforge-express → github.com/hamidukarimi/authforge-express
- authforge-client → github.com/hamidukarimi/authforge-client
If this saves you time, drop a ⭐ on GitHub!
Top comments (0)