Переписывать работающий портал страшно: любая ошибка сразу бьёт по людям, которые каждый день ведут в нём задачи и регламенты. Поэтому мы не стали останавливать старую систему и переписывать всё разом. Интерфейс мы пересобирали на Next.js (App Router) экран за экраном, а PHP-бэкенд всё это время продолжал отвечать на запросы. Работа над новым фронтендом началась в мае 2025 года. Ниже расскажу, как это устроено и что в итоге получилось.
С чего мы начинали
Портал построен на Kanboard и десятках PHP-плагинов. Каждый плагин сам рисовал свои страницы: контроллер на сервере собирал HTML, а интерактивность дописывалась отдельным скриптом. Для примера кусочек плагина функциональной схемы, где карточка собирается обычной конкатенацией строк:
// SchemeDraw/Template/dashboard/scheme.php
$tree .= '<div class="card" style="background:#6293ef;border-radius:10px;">';
$tree .= '<h1 style="color:white;font-size:14px;"><B>' . $item['name'] . ' </B></h1>';
$tree .= $modal->medium('edit', t('Edit'), 'GroupListController', 'show_edit_group', array('plugin' => 'RelatedGroups', 'group_id' => $item['group_id']));
Разметка, стили, права и запросы к данным лежали в одном месте. Такой код трудно менять без оглядки на соседние плагины, а любой переход между страницами был полной перезагрузкой. Таких плагинов в проекте больше семидесяти, и жили они вперемешку: у каждого свои шаблоны, свои скрипты и свои допущения о том, как выглядит страница вокруг. Как выглядела та схема целиком и почему мы её переписывали, я рассказал в статье про React Flow и ELK.js.
Стратегия: заменять экраны по одному
Бэкенд мы трогать не стали. Все новые экраны получили собственный фронтенд на Next.js, а PHP остался отвечать на API и отдавать файлы. Между браузером и обеими частями стоит Nginx:
Страницы теперь отдаёт Next.js, а за PHP осталось только то, что у него получается лучше всего: JSON-RPC и REST для данных, файлы пользователей и PHP-точки входа бэкенда. Чтобы старые ссылки (закладки, письма, уведомления) не ломались, Nginx перенаправляет их на новые адреса по параметрам запроса плагина:
# старый адрес плагина → новая страница
set $pass 1;
if ($arg_controller != "ControlledTaskBoardController") { set $pass 0; }
if ($arg_action != "show") { set $pass 0; }
if ($arg_plugin != "ControlledTasks") { set $pass 0; }
if ($pass) { return 301 $scheme://$host/board/controlled-tasks; }
Всё, что не подошло ни под одно правило, уходит на Next.js. Ссылки старого формата, которые выпускало мобильное приложение, мы закрыли ещё и на стороне самого Next.js:
// next.config.ts
redirects: async () => [
{ source: "/", destination: "/dashboard", permanent: true },
// старые ссылки: закладки и deep link мобильного приложения
{ source: "/tasks", destination: "/board/my-tasks", permanent: true },
{ source: "/tasks/board/:path+", destination: "/board/:path+", permanent: false },
],
Когда в бэкенде появился Laravel, в Nginx добавился отдельный маршрут /laravel-api. Позже пришлось явно прокинуть заголовок Authorization в нужные location: PHP-FPM сам его не передаёт, и без этого новый REST не видел токен.
Благодаря редиректам старые ссылки продолжили работать: они вели на новую страницу, а не на ошибку.
Структура приложения
Разделы лежат в App Router в группах маршрутов: зоны с авторизацией и без неё разведены на уровне папок, а у каждой своё оформление (layout.tsx).
src/app/
├── (authorized)/
│ ├── (tasks)/ доска и календарь задач
│ ├── @modal/ окна поверх страниц
│ ├── dashboard/ regulation/ regulations/
│ ├── reports/ schema/ team/
│ └── layout.tsx
├── (unauthorized)/
│ ├── sign-in/ reset-password/ change-password/
│ └── layout.tsx
└── api/ BFF и раздача обновлений
Защита зоны с авторизацией стоит в одном месте, в её layout.tsx. Если сессии нет, пользователя отправляют на страницу входа, а рядом с children отрисовывается слот для окон:
// app/(authorized)/layout.tsx
export default async function AuthorizedLayout({
children,
modal,
}: {
children: React.ReactNode;
modal: React.ReactNode;
}) {
const session = await getServerSession(authOptions);
if (!session || !session.user) {
redirect("/sign-in");
}
return (
<SidebarProvider defaultOpen={false}>
<AppSidebar />
<SidebarInset>
<main>{children}</main>
{modal}
</SidebarInset>
</SidebarProvider>
);
}
Окна сделаны перехватывающими маршрутами в параллельном слоте @modal. У каждого такого окна две страницы с одним и тем же виджетом: одна показывается поверх текущего экрана, когда пользователь переходит по ссылке внутри приложения, а вторая открывается как обычная страница при прямом заходе или обновлении.
В итоге адрес в строке браузера остаётся настоящим, окно можно открыть по прямой ссылке или отправить коллеге, а обновление страницы не теряет контекст.
В новой версии окно открывается по адресу вида /board/my-tasks?taskId=…, а закрывается крестиком в углу.
Feature-Sliced Design с нумерованными слоями
Чтобы проект не превратился в свалку, мы разложили код по Feature-Sliced Design. К названиям слоёв мы добавили числа, и редактор теперь показывает их в порядке иерархии, а не по алфавиту:
Правило одно: слой импортирует только из слоёв ниже. Поэтому виджет может использовать фичи и сущности, но не наоборот, и циклических зависимостей не возникает.
Чтобы новый слайс каждый раз выглядел одинаково, я написал скрипт генерации. Команда создаёт папку слайса с сегментами ui, model, api, lib и публичным index.ts:
yarn slice:create widgets today-board
Авторизация и общение с PHP
Пользователь входит в Next.js, но данные по-прежнему живут в PHP. Поэтому вход устроен в два шага: NextAuth с провайдером Credentials получает токен у сервиса авторизации, а затем запрашивает профиль у Kanboard.
Профиль и токен сохраняются в JWT-сессии NextAuth на 30 дней:
// _shared/api/auth.ts
export const authOptions: AuthOptions = {
session: { strategy: "jwt", maxAge: 30 * 24 * 60 * 60 },
providers: [
CredentialsProvider({
name: "Credentials",
async authorize(credentials) {
// 1. логин в сервисе авторизации, в ответе токен
const token = await login(credentials);
if (!token) return null;
// 2. профиль из Kanboard по этому токену
const user = await getMe(token);
return user ? { ...user, token } : null;
},
}),
],
callbacks: {
// профиль и токен кладём в JWT
async jwt({ token, user }) {
if (user) {
return { ...token, id: user.id, name: user.name, token: user.token /* ... */ };
}
return token;
},
// и достаём обратно в сессию: серверные маршруты читают session.user.token
async session({ session, token }) {
return {
...session,
user: { ...session.user, id: token.id, name: token.name, token: token.token /* ... */ },
};
},
},
};
(Функции login и getMe здесь сокращены: в проекте это обычные запросы fetch. В jwt и session я оставил только часть полей: в реальном коде копируются ещё фамилия, отчество, аватар и компания.)
Браузер сам с PHP не общается. Все запросы идут через серверные маршруты Next.js, которые достают токен из сессии и подставляют его в заголовок. Для JSON-RPC Kanboard это /api/[method]:
// app/api/[method]/route.ts
export async function POST(req: NextRequest) {
const session = await getServerSession(authOptions);
if (!session?.user?.token) {
return NextResponse.json({ message: "Unauthorized" }, { status: 401 });
}
const method = req.nextUrl.pathname.substring(5); // убираем "/api/"
const data = await jsonRpcRequest({
token: session.user.token,
method,
params: await req.json(),
});
return NextResponse.json(data);
}
Токен не попадает в браузер, а клиентский код просто вызывает /api/getTasks. Рядом лежит второй прокси, /api/rest/*, для нового REST API на Laravel. В коде он помечен как временный: когда авторизация перейдёт на JWT самого Laravel, клиент будет ходить к бэкенду напрямую, а этот прокси вместе с NextAuth удалим.
Слой данных
Всё, что приходит с бэкенда, у нас проходит через один путь: запрос на клиенте, BFF-маршрут Next.js, PHP. Серверное состояние ведёт React Query, а запросы сгруппированы по предметным областям (tasks, regulations, schema и так далее). Каждый запрос это небольшой хук с ключом кэша и типом ответа:
// _shared/api/schema/useGetSchema.ts
export function useGetSchema(): UseQueryResult<FunctionalSchemaResponse, ApiError> {
return useQuery({
queryKey: schemaKeys.schema(),
queryFn: () =>
apiRequest<FunctionalSchemaResponse>("getFunctionalScheme", undefined, true),
});
}
Виджеты и фичи видят только хук и его результат, поэтому когда метод бэкенда переезжает с JSON-RPC на REST, меняется одна функция внутри _shared/api, а экраны остаются как были. По этой же причине у нас два прокси: /api/[method] для старых методов и /api/rest/* для новых.
Разделы нового интерфейса
Код старых страниц мы не переносили: каждый экран писали заново на React. Вот главные экраны портала и то, что с ними связано.
Вместе с переездом на Next.js мы сделали редизайн всех экранов, поэтому изменился не только стек, но и сам вид интерфейса. Основные экраны ниже показаны до и после. Нажмите на картинку, чтобы увеличить.
Регламенты
Для них я сделал компонент пошагового создания (Stepper), страницы списка и деталей, а описание регламента редактируется в Quill. Регламенты версионируются, поэтому в запросах всегда нужны две пары идентификаторов: сам регламент и его версия.
Раньше список регламентов был страницей с вкладками «Одобрить», «Изучить», «Все» и «Мои» сверху, кнопками «Фильтр» и «Статистика» рядом и отдельным окном для добавления.
Список регламентов в старом портале.
В новом интерфейсе вкладки и кнопка «Добавить» собраны в одну панель, а поиск вынесен в отдельную строку на всю ширину и ищет не только по названию, но и по тексту регламента. Каждая карточка показывает статус изучения, для кого регламент, автора и дату.
Список регламентов в новом интерфейсе.
Страница самого регламента выглядит так: этапы согласования, действия, состав документа и списки «Изучили / Не изучили».
Страница регламента в новом интерфейсе.
Доска задач и расписание
Доска «Мои задачи» тоже выглядит по-другому. Раньше разделы «Мои», «Контролирую», «Участвую» и «Сегодня» переключались вкладками над доской, карточки лежали в колонках таблицы.
Доска «Мои задачи» в старом портале.
Теперь переключатель разделов стоит в боковом меню слева, над доской только «Доска» и «Календарь», у каждой колонки есть иконка и счётчик, а на карточке видно исполнителя, состояние срока («Просрочена», «Не принято более 1 дня») и дату.
Доска «Мои задачи» в новом интерфейсе.
В списках сотен задач страница начинала тормозить, поэтому длинные списки мы виртуализировали через @tanstack/react-virtual: в DOM лежат только видимые группы, а следующие подгружаются при прокрутке.
// _2_widgets/today-board/ui/today-board.tsx
const rowVirtualizer = useVirtualizer({
count: hasNextPage ? tasks.length + 1 : tasks.length,
getScrollElement: () => parentRef.current,
estimateSize: () => GROUP_ESTIMATED_HEIGHT,
overscan: 1,
});
На записи доска «Сегодня» на 600 тестовых задач: в DOM в каждый момент лежит около двадцати карточек (счётчик внизу справа это показывает), а остальное рисуется по мере прокрутки.
Доска «Сегодня»: 600 задач на тестовых данных, в DOM около двадцати карточек.
Тот же приём работает в табеле рабочего времени: ряды сотрудников виртуализируются (по 40 пикселей на ряд, запас в десять строк), а данные подгружаются порциями по 50 человек. На записи 600 вымышленных сотрудников и 31 день месяца, в DOM лежит около тридцати пяти строк.
Табель: 600 сотрудников на тестовых данных, в DOM около тридцати пяти строк.
Окно задачи и чат
Карточка задачи открывается окном поверх доски: слева поля задачи (название, описание, срок, исполнитель, участники), справа журнал событий и чат.
Окно задачи в старом портале.
В новой версии вверху закреплены метки состояния («Не принята», «Ожидают»), а поля идут строками с подписями: описание, ожидаемый результат, срок, исполнитель, участники, доказательство и чек-лист. Настройки вроде «Обязательно к сроку» и «Требует одобрения» стали переключателями рядом с нужным полем. Справа чат: системные события («Создана задача») идут в той же ленте, что и сообщения, внизу вкладки «Чат», «Файлы», «Фото» и «Ссылки», а под ними кнопка «Принять» и поле ввода.
Окно задачи в новом интерфейсе.
Новые сообщения приходят по WebSocket. Мы не храним их в отдельном состоянии: событие сразу записывается в кэш React Query, и список в интерфейсе обновляется сам.
// _shared/api/chat/useMessagesWebSocket.ts
ws.onmessage = (ev) => {
const parsed = JSON.parse(ev.data);
if (parsed?.type === "comment.create" && String(parsed.data.comment.task_id) === taskId) {
queryClient.setQueryData(["getMessages", taskId], (old: GetMessagesResponse) =>
old.messages.some((m) => m.id === parsed.data.comment.id)
? old
: { ...old, messages: [parsed.data.comment, ...old.messages] },
);
}
};
Аналитика
Продуктовые события уходят в Яндекс.Метрику через единую типизированную схему. Как она устроена, я описал в статье про типизированную аналитику.
Тесты: от Jest к Vitest
В проекте накопилось несколько десятков тестовых файлов на Jest. Когда мы решили перейти на Vitest, нужно было переписать немного: из jest.config.ts ушла отдельная настройка трансформации, а в тестах jest.fn и jest.mock заменились на vi.fn и vi.mock. Конфиг теперь короткий:
// vitest.config.ts
export default defineConfig({
plugins: [react()],
test: {
environment: "jsdom",
globals: true,
setupFiles: ["./vitest.setup.ts"],
include: ["src/**/*.{test,spec}.{ts,tsx}"],
},
resolve: { alias: { "@": path.resolve(__dirname, "./src") } },
});
В vitest.setup.ts лежат заглушки для того, чего нет в jsdom (matchMedia, ResizeObserver). Сегодня в проекте 42 тестовых файла.
Сборка и запуск
Фронтенд собирается в Docker в два этапа. В итоговый образ попадает только standalone-сборка Next.js (output: "standalone"), а запускается она не от root:
# Stage 1: сборка
FROM node:22-alpine AS builder
ENV NODE_OPTIONS="--dns-result-order=ipv4first --no-network-family-autoselection"
WORKDIR /app
COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile
COPY . .
ARG APP_ENV
COPY .env.${APP_ENV} /app/.env
RUN yarn build
# Stage 2: запуск
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_OPTIONS="--dns-result-order=ipv4first --no-network-family-autoselection"
ENV NODE_ENV=production PORT=3001
RUN addgroup -g 1001 -S nodejs && adduser -S -u 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
USER nextjs
CMD ["node", "server.js"]
Параметры NODE_OPTIONS про IPv4 пришлось задать в обоих этапах: в итоговый образ их добавили отдельным исправлением.
Окружение выбирается аргументом сборки: для каждого стенда подставляется свой .env. Запускают контейнер уже Ansible-ролью вместе с Nginx и остальными сервисами.
Цена решения
- Два способа общения с бэкендом. Старые методы вызываются через JSON-RPC Kanboard, новые через REST на Laravel. Хуки скрывают разницу, но разработчику приходится держать в голове оба.
- Прокси и NextAuth временные. Когда авторизация перейдёт на JWT Laravel, клиент будет ходить к бэкенду напрямую, а
/api/restи сам NextAuth удалим. - Next.js мы используем скорее как роутер и BFF. Из 433 компонентов 235 клиентские, а данные грузятся на клиенте через React Query. Серверный код нужен для сессий, прокси и раздачи обновлений. Серверный рендеринг данных мы почти не задействовали.
- Правила редиректов длинные. Девятнадцать правил
ifв Nginx читаются плохо, а сам Nginx не рекомендуетifвlocation. Для набора разовых редиректов это самый короткий вариант, но по мере роста его придётся переносить в карту (map). - Прокси надо беречь. Все запросы идут через BFF, поэтому он стал узким местом. В августе 2026 года мы добавили в прокси таймаут в десять секунд через
AbortSignal.timeout. Через три дня его убрали: утечку сокетов в прокси закрыло обновление Next.js до 16.3.0. - Миграция не закончена. PHP остаётся источником данных, и плагины в бэкенде никуда не делись.
Итог в цифрах
Несколько цифр на сегодня:
| Что | Сколько |
|---|---|
| PHP-плагинов в бэкенде | больше семидесяти |
| Страниц на Next.js | 39 |
| Слайсов FSD | 11 страниц, 28 виджетов, 34 фичи, 40 сущностей |
| Правил редиректа со старых адресов | 19 |
| Тестовых файлов | 42 |
| Коммитов во фронтенде | около тысячи |
Пользователи всё это время работали в портале, а каждый готовый экран можно было проверить на реальных данных, не дожидаясь, пока будет готово всё остальное. Для нас это и есть главный плюс подхода «по частям»: риск растёт не разом, а по одному экрану.