Next.js изнутри. Часть 6. Кастомный сервер.
Введение
В прошлой части мы разобрали серверный слой Next.js и познакомились с router-server, который занимается маршрутизацией, и render-server, который отвечает за рендеринг.
Стандартно Next.js сам поднимает сервер. Однако фреймворк позволяет написать кастомный сервер и самому решать, что делать с входящими запросами. В этой части мы разберём такой вариант подробнее.
Зачем может понадобиться кастомный сервер
Кастомный сервер — это когда мы поднимаем HTTP-сервер в том же процессе, что и Next.js, и сами передаём ему запросы. Это долго живущий Node.js-процесс, который мы держим самостоятельно. На платформах, где Next.js разворачивается как набор serverless-функций с маршрутизацией на стороне инфраструктуры (например, Vercel), такой процесс не вписывается в модель, и мы рискуем потерять часть платформенных оптимизаций.
Тем не менее остаются задачи, ради которых это может быть оправдано. Например, когда часть маршрутов в том же процессе обслуживает не Next.js, а отдельное Express- или Fastify-приложение, GraphQL-эндпоинт или вебхук. Сюда же относится WebSocket-сервер, которому по тем или иным причинам нужно жить в одном процессе и на одном порту с Next.js.
Всё это — про сценарии, когда нам нужно держать Next.js и другую серверную логику в одном процессе на одном хосте. Такое может потребоваться для serverful-приложения или если мы ограничены со стороны инфраструктуры. И всё же зачастую современный Next.js предлагает решения, не требующие собственного сервера.
Минимальный сервер
Канонический кастомный сервер выглядит так:
import { createServer } from 'http'
import next from 'next'
const port = parseInt(process.env.PORT || '3000', 10)
const dev = process.env.NODE_ENV !== 'production'
const app = next({ dev })
const handle = app.getRequestHandler()
app.prepare().then(() => {
createServer((req, res) => {
handle(req, res)
}).listen(port)
})
Здесь работают три вызова, и у каждого своя роль.
next(options) создаёт экземпляр приложения. В объект опций можно передать почти то же, что живёт в next.config.js, а также dev, dir (расположение проекта), hostname, port, httpServer и выбор бандлера (turbopack либо webpack). Функция ничего не запускает — она только конструирует объект.
app.prepare() инициализирует приложение и возвращает промис.
app.getRequestHandler() возвращает функцию-обработчик с сигнатурой (req, res, parsedUrl?). Именно её мы навешиваем на свой HTTP-сервер. Всё, что Next.js дальше делает с запросом, спрятано за этим handle.
Сам файл server.js не проходит через компилятор и бандлер Next.js, в отличие от страниц и компонентов.
Путь запроса в handle
Что делает handle, когда мы вызываем handle(req, res)?
Дело в том, что handle — это обработчик запросов router-server. Поэтому запрос попадает не сразу в рендеринг, а в начало той же лестницы, которую мы обсуждали в предыдущей части: заголовки из конфига, редиректы, middleware, rewrites, проверка файловой системы, отдача статики, оптимизация картинок — и только затем, если нужно, рендеринг через render-server.
Поскольку handle делегирует запрос в router-server, а middleware (он же proxy) — это один из шагов лестницы router-server, то proxy также отрабатывает, если путь подходит под его матчер. Однако здесь важно помнить, что он срабатывает только для того, что дошло до handle. Если наш кастомный сервер перехватил маршрут раньше и обработал его сам, ни разу не позвав handle, — Next.js этого запроса просто не видит, и его proxy для такого маршрута не запустится.
Кастомный сервер, таким образом, не заменяет сервер Next.js, а оборачивает его. Мы владеем сокетом и самым внешним (req, res), но всё, что происходит с запросом дальше, — это по-прежнему двухслойный сервер Next.js. В итоге весь процесс можно записать одной строкой: наш HTTP-сервер → handle → router-server → render-server.
Кастомная маршрутизация
Раз handle прогоняет запрос через весь роутинг Next.js, то где же тогда учитывается наша собственная маршрутизация? Для этого существует третий аргумент handle — parsedUrl.
Правильная модель кастомного роутинга выглядит так: мы сами разбираем req.url, при необходимости меняем pathname или query и передаём изменённый объект в handle третьим аргументом. Дальше, если parsedUrl передан, обработчик пересобирает req.url из него и только потом отдаёт запрос в router-server. По факту мы правим то, что router-server увидит как входной URL, а всю остальную работу он делает сам.
createServer((req, res) => {
const parsedUrl = parse(req.url, true)
const { pathname } = parsedUrl
if (pathname === '/legacy') {
// отдать запрос так, будто пришли на /modern —
// но пройдя через весь роутинг Next.js
handle(req, res, { ...parsedUrl, pathname: '/modern' })
return
}
handle(req, res, parsedUrl)
}).listen(port)
Pages Router и App Router
Зная о двух роутерах Next.js, мы вправе ожидать, что для App Router кастомный сервер настраивается как-то иначе. Однако нет. Кастомный сервер работает над router-server, а разрешение того, к какому роутеру относится маршрут, происходит гораздо глубже, уже внутри render-server. Поэтому слой, которым мы управляем через handle, вообще не знает и не должен знать, Pages это или App Router. Он оперирует сырым запросом и разрешённым маршрутом, а различие между роутерами для него не существует. Один и тот же handle обслуживает обе архитектуры, в том числе если они сосуществуют в одном приложении.
Устаревшие способы
Исторически у кастомного сервера был другой инструмент — метод app.render(req, res, pathname, query). Он позволял отрендерить конкретную страницу напрямую. Аналогично работали методы renderToHTML(), renderError(), render404(). В свежих версиях Next.js все эти методы помечены как устаревшие.
Причина деприкейта — раньше app.render() шёл в рендеринг напрямую, минуя router-server, а значит, минуя middleware, rewrites, редиректы и решения о кешировании. Для простого случая это работало, но означало, что часть поведения Next.js, которое разработчик видел при обычном запуске, при такой реализации пропускалась.
Однако в актуальных версиях этой разницы в поведении уже нет. Если заглянуть в текущую реализацию render() внутри NextCustomServer, окажется, что она больше ничего не рендерит напрямую, а только нормализует pathname, пересобирает из него, query и parsedUrl новый req.url и вызывает ровно тот же this.requestHandler, что и getRequestHandler(), то есть тот же самый обработчик запросов router-server. Это значит, что сейчас app.render() проходит через ту же лестницу, что и обычный путь.
Команда Next.js сводит все варианты к одному getRequestHandler(), чтобы свободно менять внутренности, а метод render() и аналогичные ему оставляет тонкими обёртками с предупреждением о деприкейте.
На одном из моих рабочих проектов кастомный сервер устроен именно на основе app.render(). Приложение не запускается через next start. Ответственным за процесс выступает NestJS на Express-адаптере: он владеет HTTP-сервером, роутингом, guard'ами и BFF, а Next.js вызывают только чтобы отрендерить страницу или отдать /_next/* и статику. Маршрутизацию страниц описывает контроллер NestJS: у каждого роута свой набор @UseGuards (авторизация, фичафлаги), которые отрабатывают на сервере до рендера, а @Render('some-page') под капотом превращается в вызов app.render(req, res, '/some-page') из Next.js.
Исторически такую связку нам давал пакет nest-next, но он перестал поддерживаться, и на очередном апгрейде Next мы переписали мост сами — на тот же публичный API кастомного сервера (next(), prepare() и т.д.). Для рендера страниц мы пока что оперлись на app.render(req, res, view), а не на getRequestHandler(), по двум причинам. Во-первых, так сохранялся прежний контракт: @Render('view') в контроллере продолжал один в один отображаться на app.render(req, res, '/view'), и переписать нужно было только прослойку, а не сами контроллеры. Во-вторых, сигнатура render(req, res, view) буквально выражает нашу модель — «контроллер уже выбрал страницу и пропустил её через guard'ы, теперь отрендери ровно эту view». getRequestHandler() устроен иначе: он берёт страницу из req.url. Там, где путь запроса и есть путь страницы, его можно отдать как есть. Но в случае с @Get('*') @Render('404') он должен отрендерить /404 независимо от того, какой прилетел URL, поэтому render(req, res, view) в данном случае подходит больше. Однако целевое решение, скорее всего, будет заключаться в переезде на getRequestHandler().
Ограничения
У кастомного сервера есть несколько несовместимостей и ограничений.
Он несовместим с режимом output: 'standalone'. Этот режим нужен, чтобы получить маленький самодостаточный артефакт для деплоя. Обычно, чтобы запустить собранное приложение через next start, на сервере должны лежать папка .next, весь node_modules и package.json — и основной вес приходится на node_modules, куда попадают в том числе зависимости сборки и разработки. Standalone решает это трассировкой: на next build Next статически анализирует серверный код, определяет, какие файлы реально нужны в рантайме, и складывает в папку .next/standalone только их — скомпилированный сервер, нужные куски node_modules и свою точку входа server.js, которую запускают через node server.js вместо next start. Получается папка, которой для старта достаточно установленного Node.js. Конфликт с кастомным сервером вытекает прямо из этого устройства. Наш server.js в эту трассировку не попадает, поэтому при попытке запустить собственный сервер он упадёт в рантайме, так как не найдёт нужные модули из node_modules.
Кастомный сервер также несовместим с output: 'export'. Экспорт превращает приложение в набор статических файлов в out/, и всё, что требует живого сервера, в нём запрещено: getServerSideProps, API-роуты, middleware/proxy, rewrites, redirects, headers, ISR, дефолтная оптимизация картинок. Если что-то из этого используется, падает уже сборка next build. Если же приложение полностью статическое и собирается, кастомный сервер в том понимании, в котором мы рассматривали его здесь, как правило не нужен.
Отдельно стоит упомянуть опцию useFileSystemPublicRoutes. По умолчанию Next.js обслуживает каждую страницу из pages/ по пути, совпадающему с её именем: файл pages/product.tsx доступен по адресу /product сам по себе, без дополнительной нашей маршрутизации. Когда роутинг строит кастомный сервер, это мешает: одну и ту же страницу мы отдаём по своему адресу (например, рендерим product на /products/:id), но она параллельно остаётся доступна и по «файловому» /product. Получается один контент по двум URL — дубли для поисковиков и открытые наружу пути, которых мы не планировали.
useFileSystemPublicRoutes: false выключает эту автоматическую файловую маршрутизацию: на сервере рендерятся только те пути, которые наш сервер обрабатывает явно, а прямой заход на файловый путь отдаёт 404. Важная оговорка: выключение работает только на стороне сервера. Клиентский роутинг (переходы по next/link, кнопка «назад») всё ещё может открыть такую страницу — её бандл лежит на клиенте, и клиентская навигация не проходит через наш сервер. Так что полностью закрыть путь одним флагом не выйдет: клиентские переходы придётся запрещать отдельно.
Что касается dev-режима — за кастомным сервером он продолжает работать, поднимая бандлер через тот же router-server, но у этого достаточно нюансов, чтобы разобрать их отдельно. Здесь достаточно помнить, что server.js живёт вне компилятора, поэтому про его перезапуск при изменениях стоит подумать отдельно.
Итого
Кастомный сервер — это не замена сервера Next.js, а обёртка над ним. Мы берём под контроль самые внешние (req, res), но всё, что происходит с запросом дальше, остаётся двухслойной машиной из прошлой части.
Большинство причин, по которым раньше писали кастомный сервер — редиректы, rewrites, заголовки, backend-for-frontend логика, — сегодня закрываются конфигом и proxy, без собственного процесса и без отказа от платформенных оптимизаций. Кастомный сервер по-прежнему существует и по-прежнему работает, но современный Next.js устроен так, чтобы к нему приходилось прибегать всё реже.