Back to Blog
5 min read

Scaling React Applications in NX Monorepos for 70,000+ Users

Lessons learned managing multiple React apps serving 70K+ users across 120 sites in a monorepo architecture with shared libraries

Scaling React Applications in NX Monorepos for 70,000+ Users

At Terros, I built and maintained multiple React applications serving 70,000+ users across 120 sites. This article shares the architecture decisions, performance optimizations, and lessons learned from managing large-scale applications in an NX monorepo.

The Scale Challenge

Managing multiple applications with shared code isn't just about organizing files. When you're serving:

  • 70,000+ active users
  • 120 different sites/locations
  • 5,000+ internal staff members
  • 28 physical locations (for Marcel by Hiptown)
  • Multiple client portals

Every decision about architecture, bundling, and deployment matters.

Why NX Monorepo?

We chose NX for several reasons:

  1. Shared Code: Common UI components, utilities, and business logic
  2. Consistent DX: Same build tools, linting, and testing across all apps
  3. Incremental Builds: Only rebuild what changed
  4. Type Safety: Shared TypeScript types across frontend and backend
  5. Dependency Graph: Visual understanding of project relationships

Monorepo Structure

Here's our production architecture:

workspace/
├── apps/
│   ├── serenest/              # 5000+ staff management
│   ├── marcel/                # 28 location management
│   ├── weekin/                # 70K users, 120 sites
│   └── amis-du-louvre/        # Member portal
├── packages/
│   ├── ui-components/         # Shared React components
│   ├── api-client/            # API integration layer
│   ├── auth/                  # Authentication logic
│   ├── utils/                 # Shared utilities
│   └── types/                 # TypeScript definitions
└── nx.json

Setting Up the Monorepo

1. Initial Setup

# Install NX CLI
npm install -g nx
 
# Create workspace
npx create-nx-workspace@latest myorg --preset=react-monorepo
 
# Navigate to workspace
cd myorg

2. Package Configuration

pnpm-workspace.yaml:

packages:
  - 'apps/*'
  - 'packages/*'

package.json (workspace root):

{
  "name": "terros-monorepo",
  "version": "1.0.0",
  "private": true,
  "workspaces": [
    "apps/*",
    "packages/*"
  ],
  "scripts": {
    "dev": "nx run-many --target=serve --projects=serenest,marcel --parallel=2",
    "build:all": "nx run-many --target=build --all",
    "test:all": "nx run-many --target=test --all",
    "lint:all": "nx run-many --target=lint --all"
  },
  "devDependencies": {
    "@nx/react": "^17.0.0",
    "@nx/workspace": "^17.0.0",
    "nx": "^17.0.0"
  }
}

Shared UI Component Library

Creating reusable components was crucial for consistency:

// packages/ui-components/src/Button/Button.tsx
import React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '../utils';
 
const buttonVariants = cva(
  'inline-flex items-center justify-center rounded-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50',
  {
    variants: {
      variant: {
        default: 'bg-primary text-primary-foreground hover:bg-primary/90',
        destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
        outline: 'border border-input bg-background hover:bg-accent',
        ghost: 'hover:bg-accent hover:text-accent-foreground',
      },
      size: {
        default: 'h-10 px-4 py-2',
        sm: 'h-9 rounded-md px-3',
        lg: 'h-11 rounded-md px-8',
        icon: 'h-10 w-10',
      },
    },
    defaultVariants: {
      variant: 'default',
      size: 'default',
    },
  }
);
 
export interface ButtonProps
  extends React.ButtonHTMLAttributes<HTMLButtonElement>,
    VariantProps<typeof buttonVariants> {
  asChild?: boolean;
}
 
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
  ({ className, variant, size, ...props }, ref) => {
    return (
      <button
        className={cn(buttonVariants({ variant, size, className }))}
        ref={ref}
        {...props}
      />
    );
  }
);
 
Button.displayName = 'Button';

Export from index:

// packages/ui-components/src/index.ts
export { Button, type ButtonProps } from './Button/Button';
export { Card, CardHeader, CardContent } from './Card';
export { Table, TableHeader, TableBody, TableRow } from './Table';
export { Modal, ModalHeader, ModalContent } from './Modal';
export { Form, FormField, FormLabel, FormMessage } from './Form';

API Client Package

Centralized API logic prevents code duplication:

// packages/api-client/src/client.ts
import axios, { AxiosInstance, AxiosRequestConfig } from 'axios';
 
export interface ApiConfig {
  baseURL: string;
  timeout?: number;
  headers?: Record<string, string>;
}
 
export class ApiClient {
  private client: AxiosInstance;
  
  constructor(config: ApiConfig) {
    this.client = axios.create({
      baseURL: config.baseURL,
      timeout: config.timeout || 30000,
      headers: {
        'Content-Type': 'application/json',
        ...config.headers,
      },
    });
    
    this.setupInterceptors();
  }
  
  private setupInterceptors() {
    // Request interceptor
    this.client.interceptors.request.use(
      (config) => {
        const token = localStorage.getItem('auth_token');
        if (token) {
          config.headers.Authorization = `Bearer ${token}`;
        }
        return config;
      },
      (error) => Promise.reject(error)
    );
    
    // Response interceptor
    this.client.interceptors.response.use(
      (response) => response,
      async (error) => {
        if (error.response?.status === 401) {
          // Handle token refresh
          await this.refreshToken();
          return this.client.request(error.config);
        }
        return Promise.reject(error);
      }
    );
  }
  
  async get<T>(url: string, config?: AxiosRequestConfig): Promise<T> {
    const response = await this.client.get<T>(url, config);
    return response.data;
  }
  
  async post<T>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
    const response = await this.client.post<T>(url, data, config);
    return response.data;
  }
  
  async put<T>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
    const response = await this.client.put<T>(url, data, config);
    return response.data;
  }
  
  async delete<T>(url: string, config?: AxiosRequestConfig): Promise<T> {
    const response = await this.client.delete<T>(url, config);
    return response.data;
  }
  
  private async refreshToken(): Promise<void> {
    // Implement token refresh logic
  }
}
 
// Service factories
export const createUserService = (client: ApiClient) => ({
  getUsers: () => client.get<User[]>('/users'),
  getUser: (id: string) => client.get<User>(`/users/${id}`),
  createUser: (data: CreateUserDto) => client.post<User>('/users', data),
  updateUser: (id: string, data: UpdateUserDto) => 
    client.put<User>(`/users/${id}`, data),
  deleteUser: (id: string) => client.delete(`/users/${id}`),
});
 
export const createSiteService = (client: ApiClient) => ({
  getSites: () => client.get<Site[]>('/sites'),
  getSite: (id: string) => client.get<Site>(`/sites/${id}`),
  createSite: (data: CreateSiteDto) => client.post<Site>('/sites', data),
});

Shared Types Package

Type safety across all apps:

// packages/types/src/user.ts
export interface User {
  id: string;
  email: string;
  firstName: string;
  lastName: string;
  role: UserRole;
  sites: string[];
  permissions: Permission[];
  createdAt: Date;
  updatedAt: Date;
}
 
export enum UserRole {
  ADMIN = 'admin',
  MANAGER = 'manager',
  EMPLOYEE = 'employee',
  CLIENT = 'client',
}
 
export interface Permission {
  resource: string;
  actions: PermissionAction[];
}
 
export enum PermissionAction {
  CREATE = 'create',
  READ = 'read',
  UPDATE = 'update',
  DELETE = 'delete',
}
 
// packages/types/src/site.ts
export interface Site {
  id: string;
  name: string;
  address: Address;
  managerId: string;
  employees: string[];
  capacity: number;
  amenities: Amenity[];
}
 
export interface Address {
  street: string;
  city: string;
  postalCode: string;
  country: string;
}
 
// packages/types/src/booking.ts
export interface Booking {
  id: string;
  userId: string;
  siteId: string;
  roomId: string;
  startTime: Date;
  endTime: Date;
  status: BookingStatus;
  attendees: string[];
}
 
export enum BookingStatus {
  PENDING = 'pending',
  CONFIRMED = 'confirmed',
  CANCELLED = 'cancelled',
  COMPLETED = 'completed',
}

Performance Optimization

1. Code Splitting

Each app uses dynamic imports:

// apps/weekin/src/App.tsx
import React, { Suspense, lazy } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { LoadingSpinner } from '@myorg/ui-components';
 
// Lazy load routes
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Sites = lazy(() => import('./pages/Sites'));
const Bookings = lazy(() => import('./pages/Bookings'));
const Profile = lazy(() => import('./pages/Profile'));
 
export function App() {
  return (
    <BrowserRouter>
      <Suspense fallback={<LoadingSpinner />}>
        <Routes>
          <Route path="/" element={<Dashboard />} />
          <Route path="/sites" element={<Sites />} />
          <Route path="/bookings" element={<Bookings />} />
          <Route path="/profile" element={<Profile />} />
        </Routes>
      </Suspense>
    </BrowserRouter>
  );
}

2. Bundle Analysis

NX configuration for optimization:

// apps/weekin/project.json
{
  "name": "weekin",
  "targets": {
    "build": {
      "executor": "@nx/webpack:webpack",
      "options": {
        "optimization": true,
        "outputPath": "dist/apps/weekin",
        "index": "apps/weekin/src/index.html",
        "main": "apps/weekin/src/main.tsx",
        "tsConfig": "apps/weekin/tsconfig.app.json",
        "webpackConfig": "apps/weekin/webpack.config.js",
        "assets": ["apps/weekin/src/assets"],
        "styles": ["apps/weekin/src/styles.css"]
      },
      "configurations": {
        "production": {
          "optimization": true,
          "sourceMap": false,
          "extractLicenses": true,
          "vendorChunk": true,
          "budgets": [
            {
              "type": "initial",
              "maximumWarning": "500kb",
              "maximumError": "1mb"
            }
          ]
        }
      }
    }
  }
}

3. React Query for Data Management

Efficient data fetching and caching:

// apps/weekin/src/hooks/useSites.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '@myorg/api-client';
import { Site, CreateSiteDto } from '@myorg/types';
 
export function useSites() {
  return useQuery({
    queryKey: ['sites'],
    queryFn: () => apiClient.sites.getSites(),
    staleTime: 5 * 60 * 1000, // 5 minutes
  });
}
 
export function useSite(id: string) {
  return useQuery({
    queryKey: ['sites', id],
    queryFn: () => apiClient.sites.getSite(id),
    enabled: !!id,
  });
}
 
export function useCreateSite() {
  const queryClient = useQueryClient();
  
  return useMutation({
    mutationFn: (data: CreateSiteDto) => apiClient.sites.createSite(data),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['sites'] });
    },
  });
}
 
// Usage in component
function SiteList() {
  const { data: sites, isLoading, error } = useSites();
  const createSite = useCreateSite();
  
  if (isLoading) return <LoadingSpinner />;
  if (error) return <ErrorMessage error={error} />;
  
  return (
    <div>
      {sites?.map(site => (
        <SiteCard key={site.id} site={site} />
      ))}
    </div>
  );
}

4. Virtual Scrolling for Large Lists

For the 120 sites display:

// apps/weekin/src/components/SiteList.tsx
import { useVirtualizer } from '@tanstack/react-virtual';
import { useRef } from 'react';
 
interface SiteListProps {
  sites: Site[];
}
 
export function SiteList({ sites }: SiteListProps) {
  const parentRef = useRef<HTMLDivElement>(null);
  
  const virtualizer = useVirtualizer({
    count: sites.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 100,
    overscan: 5,
  });
  
  return (
    <div
      ref={parentRef}
      style={{
        height: '600px',
        overflow: 'auto',
      }}
    >
      <div
        style={{
          height: `${virtualizer.getTotalSize()}px`,
          width: '100%',
          position: 'relative',
        }}
      >
        {virtualizer.getVirtualItems().map((virtualItem) => {
          const site = sites[virtualItem.index];
          return (
            <div
              key={virtualItem.key}
              style={{
                position: 'absolute',
                top: 0,
                left: 0,
                width: '100%',
                height: `${virtualItem.size}px`,
                transform: `translateY(${virtualItem.start}px)`,
              }}
            >
              <SiteCard site={site} />
            </div>
          );
        })}
      </div>
    </div>
  );
}

State Management Strategy

We used Zustand for global state:

// packages/state/src/authStore.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { User } from '@myorg/types';
 
interface AuthState {
  user: User | null;
  token: string | null;
  isAuthenticated: boolean;
  login: (user: User, token: string) => void;
  logout: () => void;
  updateUser: (user: Partial<User>) => void;
}
 
export const useAuthStore = create<AuthState>()(
  persist(
    (set) => ({
      user: null,
      token: null,
      isAuthenticated: false,
      login: (user, token) =>
        set({ user, token, isAuthenticated: true }),
      logout: () =>
        set({ user: null, token: null, isAuthenticated: false }),
      updateUser: (userData) =>
        set((state) => ({
          user: state.user ? { ...state.user, ...userData } : null,
        })),
    }),
    {
      name: 'auth-storage',
    }
  )
);

Testing Strategy

1. Unit Tests for Shared Components

// packages/ui-components/src/Button/Button.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import { Button } from './Button';
 
describe('Button', () => {
  it('renders with default variant', () => {
    render(<Button>Click me</Button>);
    const button = screen.getByRole('button');
    expect(button).toBeInTheDocument();
  });
  
  it('handles click events', () => {
    const handleClick = jest.fn();
    render(<Button onClick={handleClick}>Click me</Button>);
    
    fireEvent.click(screen.getByRole('button'));
    expect(handleClick).toHaveBeenCalledTimes(1);
  });
  
  it('applies variant classes correctly', () => {
    render(<Button variant="destructive">Delete</Button>);
    const button = screen.getByRole('button');
    expect(button).toHaveClass('bg-destructive');
  });
});

2. Integration Tests

// apps/weekin/src/features/booking/__tests__/BookingFlow.test.tsx
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { BookingFlow } from '../BookingFlow';
 
const queryClient = new QueryClient({
  defaultOptions: {
    queries: { retry: false },
  },
});
 
const wrapper = ({ children }: { children: React.ReactNode }) => (
  <QueryClientProvider client={queryClient}>
    {children}
  </QueryClientProvider>
);
 
describe('BookingFlow', () => {
  it('completes booking successfully', async () => {
    const user = userEvent.setup();
    render(<BookingFlow />, { wrapper });
    
    // Select site
    await user.click(screen.getByText('Select Site'));
    await user.click(screen.getByText('Paris Office'));
    
    // Select room
    await user.click(screen.getByText('Meeting Room A'));
    
    // Select time
    await user.click(screen.getByLabelText('Start Time'));
    // ... more interactions
    
    // Submit booking
    await user.click(screen.getByText('Confirm Booking'));
    
    await waitFor(() => {
      expect(screen.getByText('Booking Confirmed')).toBeInTheDocument();
    });
  });
});

CI/CD Pipeline

# .github/workflows/ci.yml
name: CI
 
on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main, develop]
 
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: pnpm/action-setup@v2
        with:
          version: 8
      - uses: actions/setup-node@v3
        with:
          node-version: '18'
          cache: 'pnpm'
      
      - name: Install dependencies
        run: pnpm install
      
      - name: Lint
        run: pnpm nx run-many --target=lint --all
      
      - name: Test
        run: pnpm nx run-many --target=test --all --coverage
      
      - name: Build
        run: pnpm nx run-many --target=build --all
      
      - name: Upload coverage
        uses: codecov/codecov-action@v3
 
  deploy:
    needs: test
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: pnpm/action-setup@v2
      - uses: actions/setup-node@v3
      
      - name: Build for production
        run: |
          pnpm install
          pnpm nx run-many --target=build --all --configuration=production
      
      - name: Deploy to AWS
        run: |
          # Deploy each app to its respective S3 bucket
          aws s3 sync dist/apps/weekin s3://weekin-prod
          aws s3 sync dist/apps/marcel s3://marcel-prod

Results & Metrics

After implementing this architecture:

  • Build Time: Reduced by 60% with NX caching
  • Bundle Size: Reduced by 40% with code splitting
  • Load Time: First contentful paint < 1.5s
  • Development Experience: Onboarding time reduced from 2 days to 4 hours
  • Code Reuse: 30% of codebase is shared
  • Test Coverage: 85% across all packages

Key Takeaways

  1. Monorepo isn't for everyone - Only use if you have shared code
  2. Invest in tooling - NX pays for itself quickly
  3. Type safety is crucial - Shared types prevent many bugs
  4. Performance matters - Code splitting and lazy loading are essential
  5. Good DX = faster development - Consistent tooling helps team velocity

Tech Stack Summary

  • Monorepo: NX + PNPM workspaces
  • Frontend: React, TypeScript, Tailwind CSS
  • State: Zustand, React Query
  • Backend: Node.js, Adonis.js
  • Testing: Jest, React Testing Library, Playwright
  • CI/CD: GitHub Actions

Conclusion

Managing large-scale React applications in a monorepo requires careful planning, but the benefits are significant. The key is to establish good patterns early and invest in shared tooling.

With NX, shared packages, and proper performance optimization, we successfully served 70,000+ users while maintaining developer productivity and code quality.


Building a similar system? Feel free to reach out with questions!