This document explains how the React SaaS Template is structured, how data flows through the application, and the reasoning behind each architectural decision.
ββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Browser β
β β
β βββββββββββββββ βββββββββββββββ β
β β AuthProviderβ β AppRoutes β β
β β (Context) ββββΆβ (Router) β β
β ββββββββ¬βββββββ ββββββββ¬βββββββ β
β β β β
β βΌ βΌ β
β βββββββββββββββ βββββββββββββββ β
β β useAuth() β β Pages β β
β β hook β β (lazy) β β
β ββββββββ¬βββββββ ββββββββ¬βββββββ β
β β β β
β ββββββββββ¬ββββββββββ β
β βΌ β
β βββββββββββββββββββββββββββββββββββββ β
β β API Client β β
β β - Token injection β β
β β - 401 refresh flow β β
β β - Retry logic β β
β β - Error normalization β β
β ββββββββββββββββ¬βββββββββββββββββββββ β
β β β
βββββββββββββββββββΌββββββββββββββββββββββββββββββββββ
β HTTPS
βΌ
ββββββββββββββββββ
β Your Backend β
β REST API β
ββββββββββββββββββ
The app wraps everything in an AuthProvider at the top level. This context holds the current user, the access token, and login/logout/signup methods. Any component can call useAuth() to read or modify auth state.
Why a context instead of a state manager like Redux or Zustand?
Auth state is "global" but rarely changes β it's set once at login and cleared at logout. A React context is the simplest correct tool for this. Adding Redux for a single piece of global state would be over-engineering. If your app grows to need client-side caching, global UI state, or complex derived state, consider adding Zustand or TanStack Query at that point.
Routes are defined in src/routes.tsx using react-router-dom v6. Key decisions:
Every page component is wrapped in React.lazy(), so Vite creates a separate JavaScript chunk for each page. Users only download the code for the page they're visiting. The boundary shows a spinner while the chunk loads.
The component sits in the route tree as a layout route. It checks useAuth().isAuthenticated and either renders (the child routes) or redirects to /login. This means you never need to add auth checks inside individual page components.
/login β Public
/signup β Public
/forgot-password β Public
/pricing β Public
/dashboard β Protected β DashboardLayout
/settings β Protected β DashboardLayout
/billing β Protected β DashboardLayout
All protected routes share the DashboardLayout (sidebar + top bar). Adding a new authenticated page is just adding a inside the protected block.
src/lib/api-client.ts is the single gateway for all network requests. Here's why each feature exists:
Every request automatically includes Authorization: Bearer unless you pass skipAuth: true (used for login/signup where there's no token yet).
When the server returns 401, the client calls /auth/refresh before retrying. The refresh token lives in an httpOnly cookie β not in JavaScript β to limit XSS exposure. Multiple concurrent 401s are deduplicated so you don't send 5 refresh requests simultaneously.
Server errors (500, 502, 503, 504) and rate limits (429) are retried up to 2 times with increasing delays. Client errors (400, 403, 404, 422) are never retried because they indicate a bug or invalid input, not a transient failure.
Every error is parsed into an ApiError object with status, message, and optional details (field-level validation errors). This means your catch blocks can always assume the same shape regardless of whether the error came from a network failure, a server crash, or a validation rejection.
This template uses local component state (useState) for everything. Here's the decision framework for when to "upgrade":
| State Type | Where to Put It | Example |
|---|---|---|
| Form values | useState in the form component | Login form fields |
| UI state (open/closed) | useState in the owning component | Sidebar toggle, modal visibility |
| Server data | useState + useEffect fetch | Dashboard stats, invoice list |
| Auth state | React Context | Current user, access token |
| Cross-page state | URL search params or React Context | Filters, pagination |
When to add a data-fetching library:
If you find yourself writing the same useState + useEffect + isLoading + error pattern in more than 5 components, adopt TanStack Query (React Query). It handles caching, deduplication, background refetching, and optimistic updates.
Components are organized by domain (auth, billing, dashboard) rather than by type (buttons, forms, layouts). This means all the code for a feature lives in one folder.
The ui/ folder is the exception β it contains generic, reusable primitives that aren't tied to any business domain. These are the building blocks that domain components compose.
components/
βββ auth/ β Auth feature
β βββ LoginForm
β βββ SignupForm
β βββ ForgotPasswordForm
β βββ ProtectedRoute
βββ billing/ β Billing feature
β βββ PricingTable
β βββ SubscriptionManager
βββ dashboard/ β Dashboard feature
β βββ DashboardLayout
β βββ Sidebar
β βββ DashboardPage (with StatsCard, DataTable, ActivityFeed)
βββ settings/ β Settings feature
β βββ SettingsPanel
βββ ui/ β Shared primitives
βββ Button
βββ Input
βββ Modal
βββ Badge
βββ ...
The billing flow uses Stripe Checkout (redirect-based), not Stripe Elements (embedded). This is intentional:
1. PCI compliance: Stripe Checkout is the lowest-PCI-burden integration. Card numbers never touch your frontend or backend.
2. Maintenance: Stripe maintains the checkout UI, handles 3D Secure, Apple Pay, etc. You don't need to build or maintain payment forms.
3. Conversion: Stripe continuously A/B tests their checkout page for conversion optimization.
The flow:
1. User clicks "Get started" on a plan β calls your backend POST /billing/checkout-session
2. Backend creates a Stripe Checkout Session and returns the session ID
3. Frontend redirects to Stripe's hosted checkout page
4. After payment, Stripe redirects back to your successUrl
5. Stripe sends a webhook to your backend confirming the payment
6. Backend activates the subscription and updates the database
For subscription management (upgrade, cancel, update payment method), the template uses Stripe's Customer Portal β another hosted page that Stripe maintains.
src/config/env.ts validates environment variables at import time. This is a deliberate trade-off:
undefined deep in a fetch callThis is the right trade-off for a SaaS app. A blank screen with "Missing VITE_API_BASE_URL" in the console is far easier to debug than a screen that loads but silently sends requests to undefined/api/auth/me.
Get the full React SaaS Template and unlock everything.
Get the complete guide with every chapter unlocked, including code samples, diagrams, and best practices.
Access all interactive tools with complete data, all workload profiles, and the full scenario library.
Downloadable source code, configuration files, and working examples from every chapter.
Free updates for life. Every new chapter, tool, and improvement included.