Переход на Next 16 и React 19: что сломалось и что я запинил
Год я вёл свой сайт на Next 15 и pnpm. Проблема была не во фреймворке — каждый раз, возвращаясь к проекту, я натыкался на то, как nvm и pnpm конфликтуют друг с другом: corepack то работает, то нет, а в CI внезапно оказывается другая версия Node. Это не технический долг, а просто мелкий камень, который колет каждый день.
Поэтому, когда вышел Next 16, я использовал это как повод и сменил всё окружение разом: пакетный менеджер, фреймворк, версию React и систему стилей. Сразу скажу — это не рекомендация, а рискованный шаг. Спасло меня то, что я разбил его на три отдельных pull request.
Первое решение: полностью перейти на Bun
Раньше я использовал Bun только на бэкенде (вместе с Hono). В этот раз занёс его и на фронтенд, и результат оказался лучше ожиданий: танцы с nvm закончились, bun install отрабатывает за секунды, скрипты просто запускаются через bun run dev и bun run build.
Важная деталь — пакетный менеджер нужно явно зафиксировать в package.json, иначе CI вернётся к старой привычке:
{
"packageManager": "bun@1.3.14",
"scripts": {
"dev": "bun --bun next dev",
"build": "bun --bun next build"
}
}Префикс bun --bun принципиален: без него Bun просто вызовет Node, и вы используете не рантайм Bun, а лишь его пакетный менеджер.
На стороне Vercel хватило одной строки:
{
"bunVersion": "1.x"
}Это пока Public Beta, поэтому я заранее записал себе путь отступления: если что-то пойдёт не так — удаляю строку, и сборка продолжится на Node. В миграции рядом с каждой новой вещью должен лежать такой однострочный план отката.
Next 16: middleware умер, родился proxy
Самое заметное изменение — middleware.ts теперь proxy.ts. Дело не только в имени, но и в расположении: у меня он переехал в src/proxy.ts. Логика внутри почти не изменилась, я использую его для установки языкового заголовка (x-locale).
Настоящая работа была в другом: `params` и `headers()` стали асинхронными. То есть правки затрагивают каждую страницу и каждый генератор метаданных:
export default async function Page({
params,
}: {
params: Promise<{ lang: string; slug: string }>
}) {
const { lang, slug } = await params
// ...
}Работа механическая, но охватывает много мест, и TypeScript отлавливает её целиком — поэтому лучшей стратегией оказалось запустить bun run build и идти сверху вниз по списку ошибок компилятора.
Сборка теперь по умолчанию идёт через Turbopack. Для меня это была в основном разница в скорости, ничего не сломалось.
React 19: легче, чем я ждал
Честно говоря, именно этого шага я боялся больше всего. На практике делать почти ничего не пришлось. Причина простая: в проекте не было классовых компонентов, старого context API и propTypes. Именно они делают переход на React 19 болезненным.
Отсюда практический вывод: насколько больно поднимать версию React, зависит не от самой версии, а от того, насколько ваш код опирается на старые паттерны.
Что съело больше всего времени: пин TypeScript
Вот здесь я потерял день.
Вышел TypeScript 7 — переписанный на Go, заметно более быстрый компилятор. Естественно, я его поставил. И линт тут же слёг: typescript-eslint пока не принимает TS 7, его peer-требование — <6.1.0.
Выбор встал так: быстрая компиляция или рабочий линт. Я выбрал линт и жёстко зафиксировал версию:
{
"devDependencies": {
"typescript": "6.0.3"
}
}Символа ^ нет намеренно. С кареткой следующий bun install снова уронил бы меня в ту же яму.
Этот случай хорошо показывает главную ловушку миграций: вас тормозит не фреймворк, а экосистема. Новая мажорная версия вышла, но плагины, линтеры и типы вокруг неё ещё не подтянулись. Поэтому, планируя миграцию, спрашивать надо не «вышел ли основной пакет?», а «вышли ли плагины, на которые я опираюсь?».
Попутная уборка
Большая миграция — лучший момент почистить зависимости, потому что вы и так перепроверяете всё подряд. Три пакета я убрал совсем: daisyui, autoprefixer и @eslint/eslintrc. Первый больше не нужен, второй теперь делает сам Tailwind 4, третий был мостом к старому формату конфигурации ESLint.
Ещё один момент связан с postcss. Next тянет за собой версию 8.4.31, в которой есть известная уязвимость (GHSA-qx2v-qp2m-jg93). Решение — принудительно поднять зависимость:
{
"overrides": {
"postcss": "^8.5.10"
}
}Такие записи в overrides я всегда оставляю с комментарием — через полгода никто не вспомнит, зачем это добавили, и запись просто удалят.
Система стилей в этой же миграции переехала на Tailwind 4, но это отдельная большая тема: конфигурационный файл исчезает полностью, и всё переезжает внутрь CSS. Подробно напишу об этом в следующей статье.
Что бы я сделал иначе
Почти ничего. Единственное верное решение — что я не запихнул всё в один «большой миграционный» PR. Три отдельных: первый — рантайм и пакетный менеджер, второй — фреймворк и React, третий — стили и линт. Каждый задеплоил и проверил отдельно.
Если бы всё было одним PR и сборка упала, я бы не смог ответить на вопрос «что именно сломало?» — потому что одновременно поменялись четыре переменные.
Если вы начинаете миграцию, порядок такой: сначала окружение (рантайм, пакетный менеджер), потом фреймворк, в конце слой представления. После каждого шага — сборка и деплой. И рядом с каждой новой вещью запишите однострочный план отката: он может не понадобиться, но когда понадобится — спасёт вас посреди ночи.