Frontend Integration#
The reference frontend (authhero-client) is built with Next.js 16 (App Router), React 19, TypeScript, Zustand, TanStack Query, and Tailwind CSS. This guide explains the architecture and how to use or replicate it.
Frontend Tech Stack#
| Library | Purpose |
|---|---|
Next.js 16 (App Router) | Framework with server/client components |
React 19 | UI rendering |
TypeScript | Type safety |
Zustand (persisted) | Client-side auth state (accessToken, user, mfaTempToken) |
TanStack Query | Server state management (mutations for auth ops) |
Axios | HTTP client with 401 refresh interceptor |
react-hook-form + Zod | Form state + client-side validation |
sonner | Toast notifications |
react-icons | Icon library |
Tailwind CSS v4 | Styling |
Auth Store (Zustand)#
The auth store holds only client-side state. Server data flows through TanStack Query mutations — the store is updated as a side-effect of those mutations succeeding.
interface AuthState {
user: PublicUser | null;
accessToken: string | null;
isAuthenticated: boolean;
mfaTempToken: string | null; // short-lived MFA challenge token
}
interface AuthActions {
setUser: (user: PublicUser) => void;
setToken: (token: string) => void;
setMFATempToken: (token: string) => void;
clearMFATempToken: () => void;
clearAuth: () => void;
}
export const useAuthStore = create<AuthState & AuthActions>()(
persist(
(set) => ({
user: null,
accessToken: null,
isAuthenticated: false,
mfaTempToken: null,
setUser: (user) => set({ user, isAuthenticated: true }),
setToken: (accessToken) => set({ accessToken }),
setMFATempToken: (mfaTempToken) => set({ mfaTempToken }),
clearMFATempToken: () => set({ mfaTempToken: null }),
clearAuth: () => set({
user: null, accessToken: null,
isAuthenticated: false, mfaTempToken: null,
}),
}),
{ name: "auth-storage" }, // persisted to localStorage
),
);The store is persisted to localStorage so the user stays logged in across page reloads. On refresh, the access token may have expired, but the Axios interceptor will automatically refresh it using the httpOnly cookie.
Axios Interceptor (Auto-Refresh)#
The Axios instance has two interceptors:
- Request interceptor — Attaches the
Authorization: Bearerheader from the Zustand store - Response interceptor — On 401, automatically refreshes the token and retries the original request
// Request: attach token
api.interceptors.request.use((config) => {
const token = useAuthStore.getState().accessToken;
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// Response: auto-refresh on 401
api.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
if (error.response?.status === 401
&& !originalRequest._retry
&& !originalRequest.url?.includes("/auth/refresh-token")) {
originalRequest._retry = true;
// Deduplicate concurrent refresh calls
if (!refreshPromise) {
refreshPromise = refreshClient
.post("/auth/refresh-token")
.finally(() => { refreshPromise = null; });
}
const response = await refreshPromise;
const newToken = response.data?.data?.accessToken;
if (newToken) useAuthStore.getState().setToken(newToken);
return api(originalRequest); // retry with new token
}
return Promise.reject(error);
},
);The refresh request uses a separate Axios instance (refreshClient) with no interceptors. This prevents infinite loops when the refresh token itself is invalid.
TanStack Query Mutation Hooks#
Every auth operation is wrapped in a custom hook. Pages just call mutate(data) — no manual try/catch needed:
| Hook | API Call | Side Effects |
|---|---|---|
useRegister() | POST /auth/register | Toast success, redirect to /verify-email |
useLogin() | POST /auth/login | Handle normal login or MFA challenge |
useVerifyEmail() | POST /auth/verify-email | Toast success |
useForgotPassword() | POST /auth/forgot-password | Toast success |
useResetPassword() | POST /auth/reset-password | Toast success, redirect to /login |
useLogout() | POST /auth/logout | Clear auth, redirect to /login |
useLogoutAll() | POST /auth/logout-all | Clear auth, redirect to /login |
useChangePassword() | POST /auth/change-password | Toast success |
useMFASetup() | POST /auth/mfa/setup | Return setup data |
useMFAVerify() | POST /auth/mfa/verify | Toast, refresh user profile |
useMFADisable() | POST /auth/mfa/disable | Toast, refresh user profile |
useMFAChallenge() | POST /auth/mfa/challenge | Login + redirect to / |
useMFARegenerateBackupCodes() | POST /auth/mfa/regenerate-backup-codes | Toast success |
Login Hook (MFA-Aware)#
export function useLogin() {
const router = useRouter();
const { setUser, setToken, setMFATempToken } = useAuthStore();
return useMutation({
mutationFn: authApi.login,
onSuccess: async (data) => {
if (data.mfaRequired) {
// MFA flow: store temp token, redirect to challenge page
setMFATempToken(data.tempToken);
toast.info("MFA verification required.");
router.push("/mfa");
return;
}
// Normal flow: save token, fetch profile, redirect
await handleLoginSuccess(data.accessToken, setToken, setUser);
toast.success("Logged in successfully!");
router.push("/");
},
onError: (error) => toast.error(getErrorMessage(error)),
});
}Page Examples#
Login Page#
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { loginSchema, LoginInput } from "@/lib/validators/auth.schema";
import { useLogin } from "@/hooks/useAuth";
export default function LoginPage() {
const { register, handleSubmit, formState: { errors } } = useForm<LoginInput>({
resolver: zodResolver(loginSchema),
});
const login = useLogin();
return (
<form onSubmit={handleSubmit((data) => login.mutate(data))}>
<input {...register("email")} placeholder="Email" />
{errors.email && <span>{errors.email.message}</span>}
<input {...register("password")} type="password" placeholder="Password" />
{errors.password && <span>{errors.password.message}</span>}
<button type="submit" disabled={login.isPending}>
{login.isPending ? "Signing in..." : "Sign in"}
</button>
{/* OAuth buttons */}
<button onClick={() => window.location.href = `${API_URL}/auth/oauth/google`}>
Sign in with Google
</button>
</form>
);
}Protected Page Pattern#
"use client";
import { useAuthStore } from "@/stores/auth.store";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
export default function SettingsPage() {
const { isAuthenticated, user } = useAuthStore();
const router = useRouter();
useEffect(() => {
if (!isAuthenticated) router.push("/login");
}, [isAuthenticated, router]);
if (!isAuthenticated || !user) return null;
return <div>Welcome, {user.fullname}!</div>;
}API Client Layer#
The lib/auth.api.ts file provides typed API methods that the mutation hooks call:
export const authApi = {
register: async (data: RegisterInput) => {
const res = await api.post("/auth/register", data);
return res.data;
},
login: async (data: LoginInput) => {
const res = await api.post<{ data: LoginResponse }>("/auth/login", data);
return res.data.data;
},
getMe: async (): Promise<PublicUser> => {
const res = await api.get("/auth/me");
return res.data.data;
},
exchangeOAuthCode: async (code: string) => {
const res = await api.post("/auth/oauth/exchange", { code });
return res.data.data;
},
mfaChallenge: async (data: { tempToken: string; code: string }) => {
const res = await api.post("/auth/mfa/challenge", data);
return res.data.data;
},
// ... logout, forgotPassword, resetPassword, changePassword,
// mfaSetup, mfaVerify, mfaDisable, mfaRegenerateBackupCodes
};Frontend Environment Variables#
| Variable | Example | Description |
|---|---|---|
NEXT_PUBLIC_BACKEND_URL | http://localhost:5000 | Backend API base URL (used by Axios) |
NEXT_PUBLIC_BACKEND_URL=http://localhost:5000OAuth Callback Page#
The /auth/callback page handles the OAuth redirect:
"use client";
import { useSearchParams, useRouter } from "next/navigation";
import { useOAuthExchange } from "@/hooks/useAuth";
import { useEffect, useRef } from "react";
export default function AuthCallback() {
const searchParams = useSearchParams();
const exchange = useOAuthExchange();
const called = useRef(false);
useEffect(() => {
const code = searchParams.get("code");
const error = searchParams.get("error");
if (error) {
toast.error(decodeURIComponent(error));
router.push("/login");
return;
}
if (code && !called.current) {
called.current = true;
exchange.mutate(code);
}
}, [searchParams]);
return <div>Completing login...</div>;
}The called ref prevents double-execution in React Strict Mode. The one-time code from the URL is consumed by the backend on first use — a second call would fail.