SaaS-приложение на React — это не просто «большой лендинг». Это продукт, который будет использоваться тысячами пользователей одновременно, обновляться каждую неделю и расти вместе с бизнесом. Архитектурные решения, принятые в начале, будут влиять на скорость разработки ещё долгие годы. Разберём, как выстроить проект правильно с самого старта.
Структура проекта: feature-based подход
Классическая «техническая» структура (components/, hooks/, utils/) хорошо работает до ~20 файлов. Дальше она превращается в хаос: чтобы понять, что делает одна фича, приходится прыгать по пяти папкам.
Feature-based структура группирует файлы по функциональному домену:
src/
├── features/
│ ├── auth/
│ │ ├── components/ LoginForm.tsx, SignupForm.tsx
│ │ ├── hooks/ useAuth.ts, usePermissions.ts
│ │ ├── api/ auth.api.ts
│ │ └── index.ts # публичный API фичи
│ ├── billing/
│ │ ├── components/ PricingTable.tsx, InvoiceList.tsx
│ │ ├── hooks/ useSubscription.ts
│ │ └── index.ts
│ └── workspace/
│ ├── components/
│ ├── store/ workspace.slice.ts
│ └── index.ts
├── shared/
│ ├── ui/ Button, Input, Modal — переиспользуемые компоненты
│ ├── lib/ axios instance, formatters, validators
│ └── types/ глобальные TypeScript-типы
└── app/ маршруты (React Router / Next.js)
Ключевое правило: фича не импортирует из другой фичи напрямую. Только через index.ts. Это создаёт чёткие границы и упрощает рефакторинг.
Управление состоянием
Zustand — для простых глобальных данных
Zustand минималистичен и не требует провайдеров. Идеален для UI-состояния: тема, sidebar, модальные окна.
import { create } from 'zustand'
import { devtools, persist } from 'zustand/middleware'
interface UIStore {
sidebarOpen: boolean
theme: 'light' | 'dark'
toggleSidebar: () => void
setTheme: (theme: 'light' | 'dark') => void
}
export const useUIStore = create<UIStore>()(
devtools(
persist(
(set) => ({
sidebarOpen: true,
theme: 'light',
toggleSidebar: () => set(s => ({ sidebarOpen: !s.sidebarOpen })),
setTheme: (theme) => set({ theme })
}),
{ name: 'ui-storage' }
)
)
)
Redux Toolkit — для сложной доменной логики
RTK оправдан, когда состояние сложное, мутации затрагивают несколько слайсов, или нужна мощная DevTools-отладка.
// features/workspace/store/workspace.slice.ts
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'
export const fetchWorkspaces = createAsyncThunk(
'workspace/fetchAll',
async (_, { rejectWithValue }) => {
try {
return await workspaceApi.getAll()
} catch (err) {
return rejectWithValue(err.message)
}
}
)
const workspaceSlice = createSlice({
name: 'workspace',
initialState: { items: [], status: 'idle' as 'idle' | 'loading' | 'succeeded' | 'failed' },
reducers: {
setActiveWorkspace: (state, action) => {
state.activeId = action.payload
}
},
extraReducers: (builder) => {
builder
.addCase(fetchWorkspaces.pending, (state) => { state.status = 'loading' })
.addCase(fetchWorkspaces.fulfilled, (state, action) => {
state.status = 'succeeded'
state.items = action.payload
})
}
})
React Query — для серверного состояния
Главная ошибка — хранить данные с сервера (список пользователей, транзакции, настройки) в Redux. Это ваш кеш, а не состояние приложения. React Query (TanStack Query) управляет этим намного лучше:
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
// Запрос данных
export function useInvoices() {
return useQuery({
queryKey: ['invoices'],
queryFn: billingApi.getInvoices,
staleTime: 5 * 60 * 1000 // данные актуальны 5 минут
})
}
// Мутация с оптимистичным обновлением
export function useUpdateInvoice() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: billingApi.updateInvoice,
onMutate: async (updatedInvoice) => {
await queryClient.cancelQueries({ queryKey: ['invoices'] })
const previous = queryClient.getQueryData(['invoices'])
queryClient.setQueryData(['invoices'], (old) =>
old.map(inv => inv.id === updatedInvoice.id ? updatedInvoice : inv)
)
return { previous }
},
onError: (_, __, context) => {
queryClient.setQueryData(['invoices'], context.previous)
},
onSettled: () => queryClient.invalidateQueries({ queryKey: ['invoices'] })
})
}
Code Splitting и производительность
Каждая загружаемая страница должна содержать только тот JS, который нужен именно для неё. React.lazy + Suspense — базовый инструмент:
import { lazy, Suspense } from 'react'
const BillingPage = lazy(() => import('./features/billing/pages/BillingPage'))
const AnalyticsPage = lazy(() => import('./features/analytics/pages/AnalyticsPage'))
function App() {
return (
<Suspense fallback={<PageSkeleton />}>
<Routes>
<Route path="/billing" element={<BillingPage />} />
<Route path="/analytics" element={<AnalyticsPage />} />
</Routes>
</Suspense>
)
}
Анализ бандла
Используйте rollup-plugin-visualizer (для Vite) или @next/bundle-analyzer (для Next.js) чтобы увидеть, что занимает место:
// vite.config.ts
import { visualizer } from 'rollup-plugin-visualizer'
export default {
plugins: [
visualizer({ open: true, gzipSize: true })
]
}
Типичные «тяжёлые» зависимости, которые стоит заменить: moment.js → date-fns, lodash → отдельные импорты, axios → ky или нативный fetch.
Multi-tenancy: паттерны UI
В SaaS каждый клиент — отдельный «тенант» со своими данными, настройками и часто брендингом. На уровне фронтенда это решается через контекст:
interface Tenant {
id: string
name: string
plan: 'starter' | 'pro' | 'enterprise'
branding: { primaryColor: string; logoUrl: string }
features: string[] // список включённых фич
}
const TenantContext = createContext<Tenant | null>(null)
export function TenantProvider({ children }: { children: React.ReactNode }) {
const { data: tenant } = useQuery({
queryKey: ['tenant'],
queryFn: tenantApi.getCurrent
})
if (!tenant) return <FullPageLoader />
return <TenantContext.Provider value={tenant}>{children}</TenantContext.Provider>
}
// Хук для проверки доступа к фиче
export function useFeature(feature: string) {
const tenant = useContext(TenantContext)
return tenant?.features.includes(feature) ?? false
}
// Использование
function ExportButton() {
const canExport = useFeature('csv-export')
if (!canExport) return null
return <Button>Экспорт CSV</Button>
}
CSS-переменные для динамического брендинга
function BrandingApplicator() {
const tenant = useTenant()
useEffect(() => {
document.documentElement.style.setProperty(
'--color-primary', tenant.branding.primaryColor
)
}, [tenant.branding.primaryColor])
return null
}
Потоки аутентификации
SaaS обычно требует несколько сценариев: email/пароль, OAuth (Google, GitHub), приглашение по ссылке, SSO для enterprise.
Изолируйте всю логику в отдельном провайдере:
// features/auth/AuthProvider.tsx
export function AuthProvider({ children }) {
const [user, setUser] = useState<User | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
const unsubscribe = authService.onAuthStateChange((user) => {
setUser(user)
setLoading(false)
})
return unsubscribe
}, [])
if (loading) return <FullPageLoader />
return (
<AuthContext.Provider value={{ user, signIn, signOut, signUp }}>
{children}
</AuthContext.Provider>
)
}
Защищённые маршруты через компонент-обёртку:
function ProtectedRoute({ children, requiredPlan }: {
children: React.ReactNode
requiredPlan?: 'pro' | 'enterprise'
}) {
const { user } = useAuth()
const { data: tenant } = useTenant()
if (!user) return <Navigate to="/login" replace />
if (requiredPlan && !hasPlan(tenant, requiredPlan)) {
return <UpgradePrompt requiredPlan={requiredPlan} />
}
return <>{children}</>
}
Таблица: когда что выбирать
| Задача | Решение | |---|---| | UI-состояние (тема, модалки) | Zustand | | Сложная доменная логика | Redux Toolkit | | Данные с сервера | React Query | | Формы | React Hook Form + Zod | | Стили | Tailwind CSS + shadcn/ui | | Тесты компонентов | Vitest + Testing Library | | E2E тесты | Playwright |
Итог
Хорошая архитектура SaaS-приложения — это про предсказуемость. Разработчик, открывая незнакомый файл, должен сразу понимать, к какой фиче он относится и как с ним работать. Feature-based структура, разделение серверного и клиентского состояния, code splitting и чёткие границы между модулями — это не оверинжиниринг, а инвестиция в скорость команды через полгода.
О разработке с помощью AI-ассистентов, которые помогают ускорить работу над такими проектами, читайте в статье Разработка с AI.

