System Architecture Patterns for React/Node Applications: Building for Scale from Day One
9 February 2025
System Architecture Patterns for React/Node Applications: Building for Scale from Day One
How proper architectural decisions early on can 3x developer velocity and prevent technical debt
By Muhammad Zia | Full Stack Engineer
Introduction
I've integrated into dozens of codebases over my career—some with 10,000 lines, others with 100,000+. The difference between codebases that scale smoothly and those that collapse under their own weight isn't technology choice or team size. It's architecture.
Good architecture isn't about over-engineering. It's about making deliberate choices that keep your team moving fast as the application grows. In this post, I'll share the patterns I've used across projects at Novacom, Convert, Instantly, and Pocketnote—patterns that have enabled teams to ship faster, onboard engineers quicker, and maintain code quality at scale.
These aren't academic patterns. They're battle-tested in production systems serving 200+ teams and processing millions of operations.
The Cost of Poor Architecture
Before diving into solutions, let's establish why this matters. Poor architecture manifests in predictable ways:
- Feature velocity decreases as the codebase grows (what took 1 day now takes 1 week)
- Bug rates increase because changes have unexpected side effects
- Onboarding takes weeks instead of days
- Technical debt compounds until rewrites become necessary
- Developer morale drops as simple tasks become frustrating
I've seen teams 3x their velocity by implementing the patterns below. The ROI is immediate and compounds over time.
Core Architectural Principles
1. Separation of Concerns
Every piece of code should have one clear responsibility.
2. Single Source of Truth
Data and logic should live in exactly one place.
3. Explicit Dependencies
Make dependencies visible and manageable.
4. Composability
Build small, reusable pieces that combine into complex features.
5. Testability
If it's hard to test, the architecture is wrong.
Now let's see these principles in action.
Pattern 1: Feature-Based Folder Structure
The Problem: Most React projects start with a technical folder structure (components/, utils/, hooks/). This breaks down quickly as features multiply.
The Solution: Organize by feature, not by technical role.
src/
├── features/
│ ├── campaigns/
│ │ ├── components/
│ │ │ ├── CampaignCard.tsx
│ │ │ ├── CampaignForm.tsx
│ │ │ └── CampaignList.tsx
│ │ ├── hooks/
│ │ │ ├── useCampaigns.ts
│ │ │ └── useCampaignMutations.ts
│ │ ├── services/
│ │ │ └── campaignService.ts
│ │ ├── types/
│ │ │ └── campaign.types.ts
│ │ ├── utils/
│ │ │ └── campaignHelpers.ts
│ │ └── index.ts
│ │
│ ├── analytics/
│ │ ├── components/
│ │ ├── hooks/
│ │ ├── services/
│ │ └── index.ts
│ │
│ └── auth/
│ ├── components/
│ ├── hooks/
│ ├── services/
│ └── index.ts
│
├── shared/
│ ├── components/ # Truly shared UI components
│ ├── hooks/ # Shared custom hooks
│ ├── utils/ # Shared utilities
│ ├── types/ # Global types
│ └── constants/
│
├── core/
│ ├── api/ # API client configuration
│ ├── config/ # App configuration
│ ├── store/ # Global state management
│ └── routing/ # Route definitions
│
└── App.tsx
Benefits:
- Features are self-contained and portable
- New engineers can find everything related to a feature in one place
- Easier to delete features cleanly
- Prevents "junk drawer" utils folders
Implementation Rule: If it's used by 2+ features, it goes in shared/. If it's used by 3+ features and is truly generic, it might belong in a shared package.
Pattern 2: Service Layer Pattern
The Problem: API calls scattered throughout components make testing hard and create tight coupling.
The Solution: Centralize all API interactions in service modules.
// features/campaigns/services/campaignService.ts
import { apiClient } from '@/core/api';
import type { Campaign, CreateCampaignDTO, UpdateCampaignDTO } from '../types/campaign.types';
export const campaignService = {
// Fetch all campaigns
async getCampaigns(filters?: CampaignFilters): Promise<Campaign[]> {
const { data } = await apiClient.get<Campaign[]>('/campaigns', {
params: filters,
});
return data;
},
// Fetch single campaign
async getCampaignById(id: string): Promise<Campaign> {
const { data } = await apiClient.get<Campaign>(`/campaigns/${id}`);
return data;
},
// Create campaign
async createCampaign(dto: CreateCampaignDTO): Promise<Campaign> {
const { data } = await apiClient.post<Campaign>('/campaigns', dto);
return data;
},
// Update campaign
async updateCampaign(id: string, dto: UpdateCampaignDTO): Promise<Campaign> {
const { data } = await apiClient.patch<Campaign>(`/campaigns/${id}`, dto);
return data;
},
// Delete campaign
async deleteCampaign(id: string): Promise<void> {
await apiClient.delete(`/campaigns/${id}`);
},
// Bulk operations
async bulkUpdateCampaigns(ids: string[], updates: Partial<Campaign>): Promise<Campaign[]> {
const { data } = await apiClient.post<Campaign[]>('/campaigns/bulk-update', {
ids,
updates,
});
return data;
},
};
Benefits:
- Single source of truth for API contracts
- Easy to mock for testing
- Clear separation between data fetching and UI
- Simple to add caching, retries, or other cross-cutting concerns
Pattern 3: Custom Hooks for Data Management
The Problem: Components mixing UI logic with data fetching creates complexity and reduces reusability.
The Solution: Encapsulate data operations in custom hooks using React Query or SWR.
// features/campaigns/hooks/useCampaigns.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { campaignService } from '../services/campaignService';
import type { CreateCampaignDTO, UpdateCampaignDTO } from '../types/campaign.types';
// Query keys factory
export const campaignKeys = {
all: ['campaigns'] as const,
lists: () => [...campaignKeys.all, 'list'] as const,
list: (filters?: CampaignFilters) => [...campaignKeys.lists(), filters] as const,
details: () => [...campaignKeys.all, 'detail'] as const,
detail: (id: string) => [...campaignKeys.details(), id] as const,
};
// Fetch all campaigns
export function useCampaigns(filters?: CampaignFilters) {
return useQuery({
queryKey: campaignKeys.list(filters),
queryFn: () => campaignService.getCampaigns(filters),
staleTime: 5 * 60 * 1000, // 5 minutes
});
}
// Fetch single campaign
export function useCampaign(id: string) {
return useQuery({
queryKey: campaignKeys.detail(id),
queryFn: () => campaignService.getCampaignById(id),
enabled: !!id,
});
}
// Create campaign
export function useCreateCampaign() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (dto: CreateCampaignDTO) => campaignService.createCampaign(dto),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: campaignKeys.lists() });
},
});
}
// Update campaign
export function useUpdateCampaign() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, dto }: { id: string; dto: UpdateCampaignDTO }) =>
campaignService.updateCampaign(id, dto),
onSuccess: (updatedCampaign) => {
queryClient.setQueryData(
campaignKeys.detail(updatedCampaign.id),
updatedCampaign
);
queryClient.invalidateQueries({ queryKey: campaignKeys.lists() });
},
});
}
// Delete campaign
export function useDeleteCampaign() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => campaignService.deleteCampaign(id),
onSuccess: (_, deletedId) => {
queryClient.removeQueries({ queryKey: campaignKeys.detail(deletedId) });
queryClient.invalidateQueries({ queryKey: campaignKeys.lists() });
},
});
}
Usage in Components:
// features/campaigns/components/CampaignList.tsx
import { useCampaigns, useDeleteCampaign } from '../hooks/useCampaigns';
export function CampaignList() {
const { data: campaigns, isLoading, error } = useCampaigns();
const deleteMutation = useDeleteCampaign();
if (isLoading) return <LoadingSpinner />;
if (error) return <ErrorMessage error={error} />;
return (
<div>
{campaigns?.map((campaign) => (
<CampaignCard
key={campaign.id}
campaign={campaign}
onDelete={() => deleteMutation.mutate(campaign.id)}
/>
))}
</div>
);
}
Benefits:
- Components stay focused on UI
- Automatic caching, refetching, and synchronization
- Easy to test hooks independently
- Consistent data management patterns
Pattern 4: Typed API Contracts
The Problem: Runtime errors from API response mismatches.
The Solution: Strict TypeScript contracts between frontend and backend.
// shared/types/api.types.ts
export interface ApiResponse<T> {
data: T;
message?: string;
meta?: {
page: number;
pageSize: number;
total: number;
};
}
export interface ApiError {
code: string;
message: string;
details?: Record<string, string[]>;
}
// features/campaigns/types/campaign.types.ts
export interface Campaign {
id: string;
name: string;
status: CampaignStatus;
targetAudience: string[];
budget: number;
startDate: string;
endDate: string | null;
metrics: CampaignMetrics;
createdAt: string;
updatedAt: string;
}
export enum CampaignStatus {
DRAFT = 'draft',
SCHEDULED = 'scheduled',
ACTIVE = 'active',
PAUSED = 'paused',
COMPLETED = 'completed',
}
export interface CampaignMetrics {
impressions: number;
clicks: number;
conversions: number;
spend: number;
}
export interface CreateCampaignDTO {
name: string;
targetAudience: string[];
budget: number;
startDate: string;
endDate?: string;
}
export interface UpdateCampaignDTO {
name?: string;
status?: CampaignStatus;
targetAudience?: string[];
budget?: number;
endDate?: string;
}
export interface CampaignFilters {
status?: CampaignStatus[];
search?: string;
sortBy?: 'name' | 'createdAt' | 'budget';
sortOrder?: 'asc' | 'desc';
page?: number;
pageSize?: number;
}
Runtime Validation (Optional but Recommended):
// features/campaigns/utils/campaignValidation.ts
import { z } from 'zod';
export const campaignSchema = z.object({
id: z.string().uuid(),
name: z.string().min(1).max(255),
status: z.enum(['draft', 'scheduled', 'active', 'paused', 'completed']),
targetAudience: z.array(z.string()),
budget: z.number().positive(),
startDate: z.string().datetime(),
endDate: z.string().datetime().nullable(),
metrics: z.object({
impressions: z.number().int().nonnegative(),
clicks: z.number().int().nonnegative(),
conversions: z.number().int().nonnegative(),
spend: z.number().nonnegative(),
}),
createdAt: z.string().datetime(),
updatedAt: z.string().datetime(),
});
export function validateCampaign(data: unknown): Campaign {
return campaignSchema.parse(data);
}
Pattern 5: Component Composition Over Configuration
The Problem: Components with 20+ props become unmaintainable.
The Solution: Use composition and compound components.
Bad (Configuration):
<DataTable
columns={columns}
data={data}
sortable
filterable
paginated
pageSize={20}
onRowClick={handleRowClick}
showCheckboxes
bulkActions={['delete', 'export']}
loading={isLoading}
emptyState="No data"
rowActions={['edit', 'delete', 'duplicate']}
/>
Good (Composition):
<DataTable data={data} isLoading={isLoading}>
<DataTable.Toolbar>
<DataTable.Search placeholder="Search campaigns..." />
<DataTable.Filters>
<StatusFilter />
<DateRangeFilter />
</DataTable.Filters>
<DataTable.BulkActions>
<BulkDeleteButton />
<BulkExportButton />
</DataTable.BulkActions>
</DataTable.Toolbar>
<DataTable.Columns>
<DataTable.Column accessor="name" sortable>
Name
</DataTable.Column>
<DataTable.Column accessor="status">
Status
</DataTable.Column>
<DataTable.Column accessor="budget" sortable>
Budget
</DataTable.Column>
<DataTable.Column accessor="actions">
<RowActions />
</DataTable.Column>
</DataTable.Columns>
<DataTable.EmptyState>
<EmptyStateMessage />
</DataTable.EmptyState>
<DataTable.Pagination pageSize={20} />
</DataTable>
Implementation:
// shared/components/DataTable/DataTable.tsx
interface DataTableProps<T> {
data: T[];
isLoading?: boolean;
children: React.ReactNode;
}
interface DataTableComposition {
Toolbar: typeof Toolbar;
Columns: typeof Columns;
Column: typeof Column;
EmptyState: typeof EmptyState;
Pagination: typeof Pagination;
}
function DataTableComponent<T>({ data, isLoading, children }: DataTableProps<T>) {
const [selectedRows, setSelectedRows] = useState<Set<string>>(new Set());
const [sortConfig, setSortConfig] = useState<SortConfig | null>(null);
return (
<DataTableContext.Provider
value={{
data,
isLoading,
selectedRows,
setSelectedRows,
sortConfig,
setSortConfig,
}}
>
<div className="data-table">{children}</div>
</DataTableContext.Provider>
);
}
export const DataTable = DataTableComponent as typeof DataTableComponent & DataTableComposition;
DataTable.Toolbar = Toolbar;
DataTable.Columns = Columns;
DataTable.Column = Column;
DataTable.EmptyState = EmptyState;
DataTable.Pagination = Pagination;
Benefits:
- Flexible and readable
- Each sub-component is focused and testable
- Easy to add/remove features
- Better TypeScript inference
Pattern 6: Backend Architecture with Layered Design
The Problem: Business logic mixed with route handlers makes testing impossible and creates tight coupling.
The Solution: Separate routes, controllers, services, and data access.
src/
├── routes/
│ └── campaign.routes.ts
├── controllers/
│ └── campaign.controller.ts
├── services/
│ └── campaign.service.ts
├── repositories/
│ └── campaign.repository.ts
├── models/
│ └── campaign.model.ts
├── middleware/
│ ├── auth.middleware.ts
│ ├── validation.middleware.ts
│ └── errorHandler.middleware.ts
└── utils/
Routes Layer (Routing only):
// routes/campaign.routes.ts
import { Router } from 'express';
import { campaignController } from '../controllers/campaign.controller';
import { authenticate } from '../middleware/auth.middleware';
import { validate } from '../middleware/validation.middleware';
import { createCampaignSchema, updateCampaignSchema } from '../schemas/campaign.schema';
const router = Router();
router.use(authenticate);
router.get('/', campaignController.getCampaigns);
router.get('/:id', campaignController.getCampaignById);
router.post('/', validate(createCampaignSchema), campaignController.createCampaign);
router.patch('/:id', validate(updateCampaignSchema), campaignController.updateCampaign);
router.delete('/:id', campaignController.deleteCampaign);
router.post('/bulk-update', campaignController.bulkUpdateCampaigns);
export default router;
Controller Layer (Request/Response handling):
// controllers/campaign.controller.ts
import { Request, Response, NextFunction } from 'express';
import { campaignService } from '../services/campaign.service';
import { ApiResponse } from '../types/api.types';
export const campaignController = {
async getCampaigns(req: Request, res: Response, next: NextFunction) {
try {
const filters = req.query;
const campaigns = await campaignService.getCampaigns(filters, req.user.id);
const response: ApiResponse = {
data: campaigns,
message: 'Campaigns retrieved successfully',
};
res.json(response);
} catch (error) {
next(error);
}
},
async getCampaignById(req: Request, res: Response, next: NextFunction) {
try {
const { id } = req.params;
const campaign = await campaignService.getCampaignById(id, req.user.id);
res.json({ data: campaign });
} catch (error) {
next(error);
}
},
async createCampaign(req: Request, res: Response, next: NextFunction) {
try {
const campaign = await campaignService.createCampaign(req.body, req.user.id);
res.status(201).json({
data: campaign,
message: 'Campaign created successfully',
});
} catch (error) {
next(error);
}
},
async updateCampaign(req: Request, res: Response, next: NextFunction) {
try {
const { id } = req.params;
const campaign = await campaignService.updateCampaign(id, req.body, req.user.id);
res.json({
data: campaign,
message: 'Campaign updated successfully',
});
} catch (error) {
next(error);
}
},
async deleteCampaign(req: Request, res: Response, next: NextFunction) {
try {
const { id } = req.params;
await campaignService.deleteCampaign(id, req.user.id);
res.status(204).send();
} catch (error) {
next(error);
}
},
async bulkUpdateCampaigns(req: Request, res: Response, next: NextFunction) {
try {
const { ids, updates } = req.body;
const campaigns = await campaignService.bulkUpdateCampaigns(ids, updates, req.user.id);
res.json({
data: campaigns,
message: `${campaigns.length} campaigns updated successfully`,
});
} catch (error) {
next(error);
}
},
};
Service Layer (Business logic):
// services/campaign.service.ts
import { campaignRepository } from '../repositories/campaign.repository';
import { NotFoundError, ForbiddenError } from '../utils/errors';
import type { CreateCampaignDTO, UpdateCampaignDTO } from '../types/campaign.types';
export const campaignService = {
async getCampaigns(filters: any, userId: string) {
return campaignRepository.findByUserId(userId, filters);
},
async getCampaignById(id: string, userId: string) {
const campaign = await campaignRepository.findById(id);
if (!campaign) {
throw new NotFoundError('Campaign not found');
}
if (campaign.userId !== userId) {
throw new ForbiddenError('Access denied');
}
return campaign;
},
async createCampaign(dto: CreateCampaignDTO, userId: string) {
const userBudgetLimit = await this.getUserBudgetLimit(userId);
if (dto.budget > userBudgetLimit) {
throw new Error(`Budget exceeds limit of ${userBudgetLimit}`);
}
const campaign = {
...dto,
userId,
status: 'draft',
metrics: {
impressions: 0,
clicks: 0,
conversions: 0,
spend: 0,
},
};
return campaignRepository.create(campaign);
},
async updateCampaign(id: string, dto: UpdateCampaignDTO, userId: string) {
const campaign = await this.getCampaignById(id, userId);
if (campaign.status === 'completed') {
throw new Error('Cannot update completed campaigns');
}
return campaignRepository.update(id, dto);
},
async deleteCampaign(id: string, userId: string) {
const campaign = await this.getCampaignById(id, userId);
if (campaign.status === 'active') {
throw new Error('Cannot delete active campaigns. Pause first.');
}
return campaignRepository.delete(id);
},
async bulkUpdateCampaigns(ids: string[], updates: Partial<Campaign>, userId: string) {
const campaigns = await campaignRepository.findByIds(ids);
const unauthorizedCampaigns = campaigns.filter((c) => c.userId !== userId);
if (unauthorizedCampaigns.length > 0) {
throw new ForbiddenError('Access denied to some campaigns');
}
return campaignRepository.bulkUpdate(ids, updates);
},
private async getUserBudgetLimit(userId: string): Promise<number> {
return 10000;
},
};
Repository Layer (Data access):
// repositories/campaign.repository.ts
import { Campaign } from '../models/campaign.model';
import type { CreateCampaignDTO, UpdateCampaignDTO } from '../types/campaign.types';
export const campaignRepository = {
async findByUserId(userId: string, filters: any) {
const query = Campaign.find({ userId });
if (filters.status) {
query.where('status').in(filters.status);
}
if (filters.search) {
query.where('name').regex(new RegExp(filters.search, 'i'));
}
if (filters.sortBy) {
const sortOrder = filters.sortOrder === 'desc' ? -1 : 1;
query.sort({ [filters.sortBy]: sortOrder });
}
if (filters.page && filters.pageSize) {
const skip = (filters.page - 1) * filters.pageSize;
query.skip(skip).limit(filters.pageSize);
}
return query.exec();
},
async findById(id: string) {
return Campaign.findById(id).exec();
},
async findByIds(ids: string[]) {
return Campaign.find({ _id: { $in: ids } }).exec();
},
async create(data: CreateCampaignDTO & { userId: string }) {
const campaign = new Campaign(data);
return campaign.save();
},
async update(id: string, updates: UpdateCampaignDTO) {
return Campaign.findByIdAndUpdate(id, updates, { new: true }).exec();
},
async delete(id: string) {
return Campaign.findByIdAndDelete(id).exec();
},
async bulkUpdate(ids: string[], updates: Partial<Campaign>) {
await Campaign.updateMany({ _id: { $in: ids } }, updates).exec();
return this.findByIds(ids);
},
};
Benefits:
- Each layer has a single responsibility
- Business logic is isolated and testable
- Easy to swap database implementations
- Controllers stay thin and focused
Pattern 7: Configuration Management
The Problem: Hard-coded values scattered throughout the codebase.
The Solution: Centralized, typed configuration.
// core/config/index.ts
const requiredEnvVars = [
'REACT_APP_API_URL',
'REACT_APP_AUTH_DOMAIN',
] as const;
requiredEnvVars.forEach((envVar) => {
if (!process.env[envVar]) {
throw new Error(`Missing required environment variable: ${envVar}`);
}
});
export const config = {
api: {
baseUrl: process.env.REACT_APP_API_URL!,
timeout: 30000,
},
auth: {
domain: process.env.REACT_APP_AUTH_DOMAIN!,
clientId: process.env.REACT_APP_AUTH_CLIENT_ID!,
},
features: {
enableAnalytics: process.env.REACT_APP_ENABLE_ANALYTICS === 'true',
enableBetaFeatures: process.env.REACT_APP_ENABLE_BETA === 'true',
},
limits: {
maxFileSize: 10 * 1024 * 1024, // 10MB
maxCampaignsPerUser: 100,
requestsPerMinute: 60,
},
ui: {
itemsPerPage: 20,
debounceDelay: 300,
toastDuration: 5000,
},
} as const;
export type Config = typeof config;
Results: Impact on Developer Velocity
When I implemented these patterns at Pocketnote during the design system overhaul:
- Developer velocity increased 3x — features that took 2 weeks now took 3-4 days
- Onboarding time reduced from 2 weeks to 3 days
- Bug rate decreased by 41% — proper boundaries prevent cross-feature contamination
- CSS bundle size reduced 62% — better code organization enabled tree-shaking
- Test coverage increased from 23% to 78% — testable architecture makes testing easier
Implementation Roadmap
Phase 1: Foundation (Week 1-2)
- Set up folder structure
- Implement service layer
- Create type definitions
- Set up configuration management
Phase 2: Data Layer (Week 3-4)
- Implement custom hooks
- Add React Query/SWR
- Set up error handling
- Add loading states
Phase 3: UI Layer (Week 5-6)
- Build shared components with composition
- Implement design system
- Create feature modules
- Add storybook documentation
Phase 4: Backend (Week 7-8)
- Separate routes, controllers, services
- Add validation middleware
- Implement repository pattern
- Set up comprehensive error handling
Common Pitfalls to Avoid
1. Over-abstraction
Don't create abstractions until you've seen the pattern 3+ times.
2. Premature Optimization
Start simple, refactor when you feel pain.
3. Inconsistent Patterns
Pick patterns and enforce them across the team.
4. Ignoring TypeScript
Types are documentation and prevent bugs. Use them.
5. Skipping Tests
Architecture means nothing if you can't verify it works.
Conclusion
Good architecture isn't about following rules dogmatically—it's about making deliberate choices that keep your team productive as the codebase scales.
The patterns I've outlined here are the result of integrating into 50+ codebases and leading architectural decisions across multiple organizations. They work because they're simple, testable, and composable.
Start small. Pick one pattern, implement it well, and expand from there. Your future self (and your team) will thank you.
Want to discuss architecture for your specific use case? Connect with me on LinkedIn or check out my work at mozia.dev.
Tags: #React #NodeJS #SystemArchitecture #SoftwareEngineering #WebDevelopment #TypeScript #BestPractices