DEV Community

Cover image for Building a Production-Grade React Auth Starter (JWT, Refresh Tokens, Zustand, TanStack Query)
Hamid Karimi
Hamid Karimi

Posted on

Building a Production-Grade React Auth Starter (JWT, Refresh Tokens, Zustand, TanStack Query)

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.

GitHub →


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
Enter fullscreen mode Exit fullscreen mode

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:

  1. Backend returns accessToken in the response body
  2. Backend sets refreshToken as an httpOnly cookie
  3. Frontend stores accessToken in sessionStorage
  4. User object goes into Zustand

On every API request:

// Axios request interceptor
config.headers.Authorization = `Bearer ${getToken()}`;
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 />;
};
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
};
Enter fullscreen mode Exit fullscreen mode

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.

If this saves you time, drop a ⭐ on GitHub!

Top comments (0)