A production-grade React SPA for an AI-driven writing platform. Built to showcase enterprise frontend engineering: secure token-based auth, layered architecture, runtime type safety, and a modular state management system.
Portfolio project. Every decision here was made deliberately — this document explains the why behind the architecture, not just the what.
| Concern | Technology |
|---|---|
| Framework | React 19 |
| Language | TypeScript 5.9 |
| Build | Vite 8 |
| Styling | Tailwind CSS 4 |
| State | Zustand 5 |
| Routing | React Router 7 |
| Validation | Zod 4 |
| HTTP | Axios |
| Auth | MSAL (Azure AD / @azure/msal-browser) |
The project enforces Clean Architecture with a strict inward dependency rule: outer layers depend on inner abstractions, never the reverse.
src/
├── presentation/ # UI Layer — pages only; no business logic
├── stores/ # Application Layer — state machines & use cases
├── server/ # Infrastructure Layer — API clients, auth services
├── schemas/ # Shared contracts — Zod schemas + inferred types
├── components/ # Shared UI primitives & layout
└── hooks/ # Thin abstractions over store selectors
Presentation → Application → Infrastructure
↑
(via interfaces / Zod contracts)
Presentation components never import from server/. The stores mediate all I/O.
Runtime validation with Zod ensures that the static types actually match what arrives from the API. Types are inferred from schemas — no manual DTO duplication.
// Schema is the source of truth
export const UserSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
name: z.string(),
is_email_verified: z.boolean(),
auth_provider_id: z.string(),
});
// Type is derived, not declared separately
export type User = z.infer<typeof UserSchema>;Axios is also type-augmented for custom request metadata:
declare module "axios" {
interface InternalAxiosRequestConfig {
metadata?: { startTime: Date };
}
}And return shapes are validated with satisfies to catch mismatches at compile time:
return { theme, isDark, setTheme, toggleTheme } satisfies {
theme: AppTheme;
isDark: boolean;
// ...
};Each store owns exactly one domain. This makes stores independently testable and prevents the "God Store" anti-pattern.
| Store | Responsibility |
|---|---|
useAuthStore |
Auth lifecycle: login, logout, hydration, token refresh |
useUserStore |
Profile mutations: update, password change, delete |
useAppStore |
App preferences: theme, locale, OS media query listener |
useToastStore |
Global notification queue and auto-dismiss logic |
Hydration pattern — On mount, the app silently validates the existing cookie session. A critical detail: hydration intentionally avoids toggling isLoading to prevent a double-render flash that would briefly unmount the protected route tree:
hydrate: async () => {
// Note: isLoading is NOT set here.
// Setting it would cause ProtectedRoute to briefly unmount
// and re-mount, creating a visible flash for returning users.
try {
await AuthServer.getInstance().refreshToken();
const user = await AuthServer.getInstance().fetchUser();
set({ user, isAuthenticated: true, isHydrated: true });
} catch {
set({ isHydrated: true }); // Unblock the route guard even on failure
}
}Race condition guard — Concurrent logouts are prevented with a simple flag:
logout: async () => {
if (get().isLoggingOut) return; // Guard against double-click
set({ isLoggingOut: true });
// ...
}Authentication uses Microsoft MSAL for Azure AD identity, with session continuity managed via HTTP-only cookies (mitigating XSS token theft). The client never handles raw tokens in JavaScript memory on the happy path.
AuthServer is a Singleton service injected into the store layer:
class AuthServer {
private static instance: AuthServer;
static getInstance(): AuthServer {
if (!AuthServer.instance) AuthServer.instance = new AuthServer();
return AuthServer.instance;
}
// login, logout, refreshToken, fetchUser, updateUser ...
}A single Axios instance in apiClient.ts centralises all cross-cutting concerns, keeping individual service methods clean.
Automatic token refresh loop — On a 401, the interceptor refreshes the token and retries the original request transparently. If the failing endpoint is the refresh endpoint, it calls clearAuthState() to break the retry loop:
if (status === 401) {
if (originalRequest.url?.includes("/hydrate")) {
useAuthStore.getState().clearAuthState();
return Promise.reject(error);
}
// Otherwise: refresh → retry
await useAuthStore.getState().refresh();
return apiClient(originalRequest);
}Type-safe error extraction — A type guard narrows the unknown error payload before extracting the message, avoiding unsafe any casts:
const isApiErrorResponse = (data: unknown): data is ApiErrorResponse =>
typeof data === "object" && data !== null;React Router 7 with nested routes share a persistent AppLayout (header + outlet + footer), so the shell never unmounts during navigation.
Code splitting — The Settings page is lazy-loaded to keep the initial bundle lean:
const SettingsPage = lazy(() => import("./presentation/settings/Page"));Multi-step route guard — ProtectedRoute handles three distinct states before rendering children, resolving them in the correct order to avoid redirect flashes:
1. isHydrated === false → show Loader (session check in progress)
2. isAuthenticated === false → redirect to /login
3. allowedGroups check → redirect to /dashboard with toast
4. ✓ Render children
Hooks are always called unconditionally (Rules of Hooks compliance), with side effects confined to useEffect:
// Derived BEFORE any early returns
const hasAccess = !allowedGroups?.length || userGroups.some(g => allowedGroups.includes(g));
// Side effect only — never triggers early return directly
useEffect(() => {
if (isHydrated && isAuthenticated && !hasAccess) {
showToast("Access denied.", "error");
}
}, [isHydrated, isAuthenticated, hasAccess]);UI components follow a composition-first approach. The button system demonstrates vertical layering:
BaseButton ← Primary action
GhostButton ← Low-emphasis secondary
IconButton ← Icon-only with isDelete variant
NavigateButton ← Navigation with a directional arrow
Modal supports positional variants (top-left, center, bottom-right, etc.), a blur backdrop, click-outside-to-close via a ref, body scroll lock, and a 300ms unmount animation — all composable via props.
Scoped styles use CSS Modules to prevent class name collisions across the component tree.
The useAppTheme hook abstracts the store selector and resolves the system theme against the OS preference at read time:
const isDark =
theme === "dark" ||
(theme === "system" && window.matchMedia("(prefers-color-scheme: dark)").matches);The system variant also registers a matchMedia change listener, so the UI reacts to the user switching their OS theme without a page reload.
bun installbun run devThe dev server proxies /api/* to http://localhost:8080 via Vite's built-in proxy, keeping CORS concerns out of the application code.
bun run buildAll architectural decisions in this project prioritise correctness and maintainability over brevity. The codebase is intentionally over-engineered for a showcase context — in a real product, some abstractions would be introduced incrementally as the need arose.