Contents

Chapter 1

Architecture Guide

This document explains how the React SaaS Template is structured, how data flows through the application, and the reasoning behind each architectural decision.

High-Level Architecture

┌──────────────────────────────────────────────────┐
│                     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      │
         └────────────────┘

Provider Architecture

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.

Routing Strategy

Routes are defined in src/routes.tsx using react-router-dom v6. Key decisions:

Code Splitting

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.

Protected Routes

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.

Route Organization

/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.

API Client Design

src/lib/api-client.ts is the single gateway for all network requests. Here's why each feature exists:

Token Injection

Every request automatically includes Authorization: Bearer unless you pass skipAuth: true (used for login/signup where there's no token yet).

Automatic Token Refresh

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.

Retry with Backoff

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.

Error Normalization

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.

State Management Patterns

This template uses local component state (useState) for everything. Here's the decision framework for when to "upgrade":

State TypeWhere to Put ItExample
Form valuesuseState in the form componentLogin form fields
UI state (open/closed)useState in the owning componentSidebar toggle, modal visibility
Server datauseState + useEffect fetchDashboard stats, invoice list
Auth stateReact ContextCurrent user, access token
Cross-page stateURL search params or React ContextFilters, 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.

Component Organization

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
    └── ...

Stripe Integration Pattern

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.

Environment Configuration

src/config/env.ts validates environment variables at import time. This is a deliberate trade-off:

  • Pro: Missing variables fail immediately with a clear error instead of producing undefined deep in a fetch call
  • Con: The app won't render at all if a required variable is missing

This 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.

Chapter 2

Deployment Guide

This guide covers deploying the React SaaS Template to Vercel, Netlify, and Docker.

Vercel auto-detects Vite projects and deploys them with zero configuration.

Steps

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 URL
  • VITE_STRIPE_PUBLISHABLE_KEY → your Stripe publishable key

5. Click Deploy.

Custom Domain

1. Go to your project's Settings → Domains.

2. Add your domain and follow the DNS instructions.

3. Vercel automatically provisions an SSL certificate.

Preview Deployments

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.

Netlify

Steps

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.

SPA Routing Fix

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.

Docker

Dockerfile

dockerfile
# 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;"]

nginx.conf

nginx
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;
}

Build and Run

bash
docker build -t my-saas-app .
docker run -p 3000:80 my-saas-app

Environment Variables at Build Time

Vite 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:

bash
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:

dockerfile
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_KEY

Runtime Environment Variables (Alternative)

If you need to change environment variables without rebuilding, use the window-based injection pattern:

1. Create public/env-config.js:

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:

html
<script src="/env-config.js"></script>

3. In your Docker entrypoint, replace the placeholders with actual values:

bash
envsubst < /usr/share/nginx/html/env-config.js > /tmp/env-config.js
mv /tmp/env-config.js /usr/share/nginx/html/env-config.js

4. Update src/config/env.ts to read from window.__ENV__ first, falling back to import.meta.env.

Performance Checklist

Before going live, verify these optimizations are in place:

  • [ ] Vite build produces hashed filenames for cache-busting
  • [ ] Chunk splitting separates vendor libraries from app code (configured in vite.config.ts)
  • [ ] Lazy loading ensures pages are code-split (configured in routes.tsx)
  • [ ] Gzip/Brotli compression is enabled on your hosting provider
  • [ ] CDN is serving static assets (Vercel and Netlify do this automatically)
  • [ ] Error tracking (Sentry or similar) is configured for production
  • [ ] Analytics are set up if needed
  • [ ] robots.txt and sitemap.xml are in the public/ directory if you need SEO
React SaaS Template v1.0.0 — Free Preview