Переписывать работающий портал страшно: любая ошибка сразу бьёт по людям, которые каждый день ведут в нём задачи и регламенты. Поэтому мы не стали останавливать старую систему и переписывать всё разом. Интерфейс мы пересобирали на 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:

Браузер обращается к Nginx, Nginx направляет страницы в Next.js, API и файлы в PHP, а чат в WebSocket-сервер; Next.js серверным прокси вызывает PHP Браузер пользователь Nginx редиректы и маршруты Next.js страницы, модальные окна PHP: Kanboard и Laravel API и файлы WebSocket-сервер чат задач серверный BFF /api/*

Страницы теперь отдаёт 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. У каждого такого окна две страницы с одним и тем же виджетом: одна показывается поверх текущего экрана, когда пользователь переходит по ссылке внутри приложения, а вторая открывается как обычная страница при прямом заходе или обновлении.

Переход по ссылке внутри приложения открывает окно из слота @modal, прямая ссылка открывает обычную страницу, обе показывают один виджет Ссылка в приложении клик по кнопке @modal/(...)schema/create-function окно поверх схемы Прямая ссылка или F5 заход по адресу schema/create-function обычная страница FunctionModal один и тот же виджет

В итоге адрес в строке браузера остаётся настоящим, окно можно открыть по прямой ссылке или отправить коллеге, а обновление страницы не теряет контекст.

В новой версии окно открывается по адресу вида /board/my-tasks?taskId=…, а закрывается крестиком в углу.


Feature-Sliced Design с нумерованными слоями

Чтобы проект не превратился в свалку, мы разложили код по Feature-Sliced Design. К названиям слоёв мы добавили числа, и редактор теперь показывает их в порядке иерархии, а не по алфавиту:

Пять слоёв FSD сверху вниз: pages, widgets, features, entities, shared; каждый слой импортирует только из нижних _1_pages страницы, собирают виджеты _2_widgets крупные блоки интерфейса _3_features действия пользователя _4_entities бизнес-сущности _shared UI-кит, API-клиенты, типы, утилиты

Правило одно: слой импортирует только из слоёв ниже. Поэтому виджет может использовать фичи и сущности, но не наоборот, и циклических зависимостей не возникает.

Чтобы новый слайс каждый раз выглядел одинаково, я написал скрипт генерации. Команда создаёт папку слайса с сегментами ui, model, api, lib и публичным index.ts:

yarn slice:create widgets today-board

Авторизация и общение с PHP

Пользователь входит в Next.js, но данные по-прежнему живут в PHP. Поэтому вход устроен в два шага: NextAuth с провайдером Credentials получает токен у сервиса авторизации, а затем запрашивает профиль у Kanboard.

Вход: браузер отправляет логин в Next.js, тот получает токен у сервиса авторизации, запрашивает профиль у Kanboard и возвращает браузеру сессию Браузер Next.js Сервис входа Kanboard 1. email и пароль 2. запрос входа 3. токен 4. профиль по токену (getMe) 5. данные пользователя 6. сессия в cookie

Профиль и токен сохраняются в 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.js39
Слайсов FSD11 страниц, 28 виджетов, 34 фичи, 40 сущностей
Правил редиректа со старых адресов19
Тестовых файлов42
Коммитов во фронтендеоколо тысячи
Интерфейс портала: было → стало
PHP-плагины с HTML-строками заменены интерфейсом на Next.js, а бэкенд остался API БЫЛО СТАЛО HTML собирает PHP на сервере React-компоненты и слои FSD Каждый переход перезагружает страницу Клиентская навигация и окна по маршрутам Сессия и права внутри Kanboard NextAuth, токен на сервере, BFF-прокси Правки без автоматических тестов Vitest и сборка в Docker

Пользователи всё это время работали в портале, а каждый готовый экран можно было проверить на реальных данных, не дожидаясь, пока будет готово всё остальное. Для нас это и есть главный плюс подхода «по частям»: риск растёт не разом, а по одному экрану.