Привет, Хабр! Сегодня хочу поговорить о проблеме, с которой сталкивается каждый фулстек-разработчик, и о том, как её можно элегантно решить.
Проблема, которая всех бесит
Представьте: пишете форму регистрации. На фронте описываете типы для полей формы. На бэке — те же самые типы для валидации. Меняете одно поле — нужно помнить, что надо поменять в двух местах. Забыли обновить на бэке? Получите баг в продакшене.
А теперь добавьте сюда:
Константы (статусы заказов, роли пользователей)
Схемы валидации (Zod, Yup)
Утилитные функции (форматирование дат, работа со строками)
Бизнес-логику, которая должна работать одинаково на клиенте и сервере
Всё это обычно дублируется между фронтендом и бэкендом. И это не просто неудобно — это источник багов и постоянной головной боли.
Варианты решения
Вариант 1: Копипаста
Самый простой и самый плохой. Держите всё в одном репозитории, но фронт и бэк — отдельные проекты. Общий код копируете руками. Понятно, что это путь в никуда.
Вариант 2: Отдельный npm-пакет
Делаете отдельный пакет для shared-кода. Звучит неплохо, но:
Каждое изменение = публикация пакета
Нужно ждать, пока пакет обновится в проектах
Версионирование превращается в боль
Локальная разработка через
npm linkработает через раз
Вариант 3: Monorepo
А вот это уже интересно.
Что такое monorepo
Monorepo — это когда несколько связанных проектов живут в одном репозитории и могут легко переиспользовать код друг друга.
Структура выглядит примерно так:
my-app/
├── packages/
│ ├── frontend/ # React + Vite
│ ├── backend/ # Node.js + Express
│ └── shared/ # Общий код
└── package.json
И вот что меняется к лучшему:
Единственный источник истины
Описываете тип пользователя один раз в shared:
// shared/src/types/user.ts
export interface User {
id: string;
email: string;
role: 'admin' | 'user';
createdAt: Date;
}
Используете везде:
// frontend/src/App.tsx
import { User } from 'shared';
// backend/src/controllers/user.ts
import { User } from 'shared';
Изменили тип — изменения сразу видны и на фронте, и на бэке. TypeScript не даст собрать проект, если что-то несовместимо.
Zod-схемы для валидации
Это вообще магия. Описываете схему один раз:
// shared/src/schemas/auth.ts
import { z } from 'zod';
export const registerSchema = z.object({
email: z.string().email('Некорректный email'),
password: z.string().min(8, 'Минимум 8 символов'),
name: z.string().min(2, 'Минимум 2 символа'),
});
export type RegisterDto = z.infer<typeof registerSchema>;
На фронте используете для валидации формы (react-hook-form + zod):
import { registerSchema } from 'shared';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
const form = useForm({
resolver: zodResolver(registerSchema)
});
На бэке — для валидации входящих данных:
import { registerSchema } from 'shared';
app.post('/register', (req, res) => {
const result = registerSchema.safeParse(req.body);
if (!result.success) {
return res.status(400).json(result.error);
}
// ...
});
Одна схема, одни правила валидации, одни сообщения об ошибках. Везде.
Константы и энамы
// shared/src/constants/orders.ts
export enum OrderStatus {
PENDING = 'pending',
PROCESSING = 'processing',
SHIPPED = 'shipped',
DELIVERED = 'delivered',
CANCELLED = 'cancelled',
}
export const ORDER_STATUS_LABELS: Record<OrderStatus, string> = {
[OrderStatus.PENDING]: 'Ожидает обработки',
[OrderStatus.PROCESSING]: 'В обработке',
[OrderStatus.SHIPPED]: 'Отправлен',
[OrderStatus.DELIVERED]: 'Доставлен',
[OrderStatus.CANCELLED]: 'Отменён',
};
И на фронте, и на бэке используются одни и те же значения. Невозможно случайно написать 'pending' на фронте и 'Pending' на бэке.
Как это работает на практике
Существуют готовые шаблоны, которые уже настроены под этот подход. Обычно они используют pnpm workspaces для управления монорепо. Почему pnpm? Он быстрый, эффективно использует дисковое пространство, и у него отличная поддержка workspaces.
Например, можно быстро развернуть проект командой:
npm create fullstack-monorepo my-app
cd my-app
pnpm install
Как устроен shared-пакет
Это сердце всей системы. Обычно там есть автоматическая генерация индексного файла — скрипт обходит все файлы в src/ и автоматически создаёт экспорты в index.ts.
То есть вы просто создаёте файл в shared/src/types/product.ts, и он автоматически становится доступен через import { ... } from 'shared'. Не нужно помнить про ручные экспорты.
Для разработки запускается watch-режим:
"watch:index": "chokidar \"src/**/*.ts\" -c \"pnpm generate:index\""
Создали новый файл — индекс пересобрался. Всё работает с горячей перезагрузкой.
Запуск в разработке
Обычно есть удобные команды:
# Фронтенд с автообновлением shared
pnpm run dev:frontend
# Бэкенд с автообновлением shared
pnpm run dev:backend
Обе команды используют concurrently, чтобы одновременно запустить watch для shared и dev-сервер:
"dev:frontend": "concurrently -n shared,frontend \"pnpm --filter shared watch:index\" \"pnpm --filter frontend dev\""
Меняете код в shared — изменения мгновенно подхватываются и фронтом, и бэком.
Реальные примеры использования
API-контракты
// shared/src/api/user.ts
export interface GetUsersResponse {
users: User[];
total: number;
page: number;
}
export interface CreateUserRequest {
email: string;
name: string;
role: UserRole;
}
Теперь и фронтенд, и бэкенд знают точный формат запросов и ответов. IDE подсказывает поля, TypeScript ругается на несоответствия.
Утилиты для работы с данными
// shared/src/utils/date.ts
export function formatDate(date: Date): string {
return new Intl.DateTimeFormat('ru-RU').format(date);
}
export function isDateInPast(date: Date): boolean {
return date < new Date();
}
Одна реализация, одинаковое поведение на клиенте и сервере.
Бизнес-логика
// shared/src/utils/pricing.ts
export function calculateDiscount(
price: number,
userRole: UserRole
): number {
const discounts = {
admin: 0,
premium: 0.15,
user: 0.05,
};
return price * (1 - discounts[userRole]);
}
Цена считается одинаково и когда показываете её пользователю, и когда создаёте заказ на сервере.
Что можно добавить
В базовом шаблоне обычно есть минимум зависимостей. В реальных проектах часто добавляют:
ESLint/Prettier — общие настройки для всего монорепо
Тесты — Jest или Vitest для shared-пакета
CI/CD — GitHub Actions для автоматической сборки
Docker — контейнеризация для деплоя
Turborepo или Nx — для умной пересборки только изменённых пакетов
Но для старта базового набора вполне достаточно.
Альтернативы
Есть и другие решения для организации монорепо:
Nx — мощный, но сложный для начала
Turborepo — отличный инструмент, но требует настройки
Lerna — старичок, сейчас менее актуален
Rush — для очень больших монорепо
Простые шаблоны без лишних зависимостей — это решение на базе чистых pnpm workspaces и пары скриптов. Легко понять, легко настроить под себя.
Заключение
Monorepo — это не серебряная пуля, но для fullstack-разработки с общим кодом это очень удобно. Экономите время, уменьшаете количество багов и делаете код более поддерживаемым.
Если устали синхронизировать типы, валидацию и утилиты между фронтом и бэком — попробуйте подход с monorepo. Возможно, ваша жизнь станет чуточку проще.
P.S. А как вы решаете проблему переиспользования кода между фронтендом и бэкендом? Поделитесь в комментариях!
Комментарии (5)

savostin
29.10.2025 11:53Не дай бог у вас бэк на Go или прости господи на Python. Что тогда?
Кстати забыли еще на бэке синхронизацию со схемой базы. Тоже боль.

Bone
29.10.2025 11:53Я как-то раз попробовал, но у меня не завелось. У фронта и бека были разные tsconfig и из-за этого у меня толком ничего не работало.
Rive
Я не думаю, что после 2022 года это безопасно при работе с ru-локалью. Один из мэйнтейнеров выразил вполне недвусмысленную позицию посредством геоблока на сайте менеджера установки и я вполне допускаю, что он может подсунуть троян.
artem23112 Автор
Понимаю, откуда взялось это опасение, но фактически оно не подтверждается.
pnpm— полностью open-source, код открыт и виден всем, а любые изменения проходят публичный аудит. Если бы кто-то действительно добавил туда что-то вредоносное, это мгновенно стало бы заметно.Геоблокировка сайта pnpm.io действительно была, но она никак не влияет на сам инструмент — установка и обновления идут через npm-registry, где пакеты подписаны и проверяются по хэшам.
Короче, технических оснований считать
pnpm«троянским» нет. Это скорее вопрос отношения к поступку мейнтейнера, а не вопрос безопасности самого менеджера пакетов.