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.
This guide covers deploying the React SaaS Template to Vercel, Netlify, and Docker.
Vercel auto-detects Vite projects and deploys them with zero configuration.
1. Push your code to a Git repository (GitHub, GitLab, or Bitbucket).
2. Go to vercel.com and import the repository.
3. Vercel will detect the Vite framework and set the correct build settings.
4. Add your environment variables in the Vercel dashboard under Settings → Environment Variables:
VITE_API_BASE_URL → your backend URLVITE_STRIPE_PUBLISHABLE_KEY → your Stripe publishable key5. Click Deploy.
1. Go to your project's Settings → Domains.
2. Add your domain and follow the DNS instructions.
3. Vercel automatically provisions an SSL certificate.
Every pull request gets its own preview URL. Use this to test changes before merging. The preview uses the same environment variables as production unless you override them in Vercel's environment variable settings with the "Preview" scope.
1. Push your code to Git.
2. Go to netlify.com and import the repository.
3. Set the build command to npm run build and the publish directory to dist.
4. Add environment variables under Site settings → Build & deploy → Environment.
5. Deploy.
Netlify needs a redirect rule for client-side routing. Create a public/_redirects file:
/* /index.html 200
Without this, direct navigation to /dashboard or /settings will return a 404.
# Build stage
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Serve stage
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]server {
listen 80;
root /usr/share/nginx/html;
index index.html;
# Handle client-side routing — serve index.html for all routes
location / {
try_files $uri $uri/ /index.html;
}
# Cache static assets aggressively (Vite hashes filenames)
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
}docker build -t my-saas-app .
docker run -p 3000:80 my-saas-appVite inlines VITE_* variables at build time (they become string literals in the JS bundle). This means you need to pass them during docker build, not docker run:
docker build \
--build-arg VITE_API_BASE_URL=https://api.example.com \
--build-arg VITE_STRIPE_PUBLISHABLE_KEY=pk_live_YOUR_KEY \
-t my-saas-app .Update your Dockerfile's build stage to accept these:
ARG VITE_API_BASE_URL
ARG VITE_STRIPE_PUBLISHABLE_KEY
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
ENV VITE_STRIPE_PUBLISHABLE_KEY=$VITE_STRIPE_PUBLISHABLE_KEYIf you need to change environment variables without rebuilding, use the window-based injection pattern:
1. Create public/env-config.js:
window.__ENV__ = {
VITE_API_BASE_URL: "__VITE_API_BASE_URL__",
VITE_STRIPE_PUBLISHABLE_KEY: "__VITE_STRIPE_PUBLISHABLE_KEY__",
};2. Add a script tag to index.html before your app:
<script src="/env-config.js"></script>3. In your Docker entrypoint, replace the placeholders with actual values:
envsubst < /usr/share/nginx/html/env-config.js > /tmp/env-config.js
mv /tmp/env-config.js /usr/share/nginx/html/env-config.js4. Update src/config/env.ts to read from window.__ENV__ first, falling back to import.meta.env.
Before going live, verify these optimizations are in place:
vite.config.ts)routes.tsx)public/ directory if you need SEO