Next.js меня окончательно достал
- понедельник, 8 сентября 2025 г. в 00:00:05
Наконец, настал этот момент, и я решился написать статью. Давно хотел, но как-то не хватало мотивации. А ведь, знаете, как говорят: «гнев — лучший мотиватор». Есть же такое выражение?
Я приглашаю вас в путешествие, но сначала нужно расставить декорации. Представьте, что вы работаете в некой компании X, и один из ваших сервисов на Next.js крякнулся. Ну и поскольку это Next.js, то мы понятия не имеем, что конкретно произошло, так как логирование процессов по умолчанию включено только при разработке.
И теперь перед нами квест — найти и настроить механизм логирования для продакшена. Будет нелегко, но нам как бы не привыкать.
Первым на своём пути мы встречаем промежуточное ПО. В документации даже сказано:
«Промежуточное ПО выполняется до разрешения маршрутов, и особенно полезно для реализации кастомной серверной логики вроде аутентификации, логирования или обработки перенаправлений».
Хорошо, вроде ничего сложного. Пора выбирать библиотеку логирования. Я обратился к pino, так как уже с ней знаком. Хотя любое решение будет лучше, чем console.log
. Думаю, разберёмся с этим до обеда.
Начнём с настройки основного промежуточного ПО:
// middleware.ts
import { NextResponse, NextRequest } from "next/server";
export async function middleware(request: NextRequest) {
return new NextResponse.next({
request: request,
headers: request.headers,
// status: 200,
// statusText: 'OK'
});
}
export const config = {
matcher: "/:path*",
};
Думаю, что у нас уже возникла проблема. Из своего промежуточного ПО мы можем передать не более 4 параметров. Единственное, что реально влияет на задействованный маршрут, это headers. Давайте не будем упускать тот факт, что нельзя использовать несколько промежуточных программ или связывать их в цепочку. Как же можно было так налажать? Мы используем программные прослойки с начала 2010-х, когда только появился Express.
Как бы то ни было, мы достаточно умны, и можем воспользоваться изящными решениями, которые предлагает нам современный Node.js. Обратимся к AsyncLocalStorage
.
// app/logger.ts
import { AsyncLocalStorage } from "async_hooks";
import { Logger, pino } from "pino";
const loggerInstance = pino({
// Необходимая конфигурация.
level: process.env.LOG_LEVEL ?? "trace",
});
export const LoggerStorage = new AsyncLocalStorage<Logger>();
export function logger(): Logger | null {
return LoggerStorage.getStore() ?? null;
}
export function requestLogger(): Logger {
return loggerInstance.child({ requestId: crypto.randomUUID() });
}
// middleware.ts
export async function middleware(request: NextRequest) {
LoggerStorage.enterWith(requestLogger());
logger()?.debug({ url: request.url }, "Started processing request!");
return NextResponse.next();
}
Уфф…самое тяжёлое позади. Теперь протестируем всё это. Переходим на localhost:3000 и видим следующее:
{ requestId: 'ec7718fa-b1a2-473e-b2e2-8f51188efa8f' } { url: 'http://localhost:3000/' } 'Started processing request!'
GET / 200 in 71ms
{ requestId: '09b526b1-68f4-4e90-971f-b0bc52ad167c' } { url: 'http://localhost:3000/next.svg' } 'Started processing request!'
{ requestId: '481dd2ff-e900-4985-ae15-0b0a1eb5923f' } { url: 'http://localhost:3000/vercel.svg' } 'Started processing request!'
{ requestId: 'e7b29301-171c-4c91-af25-771471502ee4' } { url: 'http://localhost:3000/file.svg' } 'Started processing request!'
{ requestId: '13766de3-dd00-42ce-808a-ac072dcfd4c6' } { url: 'http://localhost:3000/window.svg' } 'Started processing request!'
{ requestId: '317e054c-1a9a-4dd8-ba21-4c0201fbeada' } { url: 'http://localhost:3000/globe.svg' } 'Started processing request!'
Не знаю, использовали ли вы pino ранее, но так быть не должно. А можете понять, почему?
Я не Next.js и томить вас ожиданиями не стану. Это вывод браузера. Почему? Ну, потому что по умолчанию средой выполнения промежуточного ПО в Next.js является edje
. Да, мы можем переключиться на среду nodejs
, которая должна нормально заработать. Вот только на деле это может оказаться не так.
Я пробовал такой подход в свеженьком проекте Next.js, и у меня получилось. Но вот повторить это в реальном проекте мне не удалось. Не подумайте, я не сумасшедший. Ну да ладно, основная проблема всё равно не в этом. Мы постепенно к ней приближаемся.
Логировать промежуточное ПО круто и всё такое, но главная магия происходит не здесь. Для её раскрытия нужно логировать страницы и макеты. Попробуем.
// app/page.tsx
export default function Home() {
logger()?.info("Logging from the page!");
return <div>Real simple website!</div>
}
Теперь обновляем страницу и получаем:
✓ Compiled / in 16ms
GET / 200 in 142ms
И всё? И всё. Ничего. Совсем.
Для сохранения исторической ясности покажу, как этот вывод должен выглядеть:
✓ Compiled / in 2.2s
[11:38:59.259] INFO (12599): Logging from the page!
requestId: "2ddef9cf-6fee-4d1d-8b1e-6bb16a3e636b"
GET / 200 in 2520ms
Ладно, что-то я затянул, пора переходить к сути. Функция logger возвращает null
. Почему? Не уверен, но мне кажется, что рендеринг выполняется не в том же асинхронном контексте, что и промежуточное ПО.
И что с этим делать? Вы не поверите. Помните, что из промежуточной программы можно передать лишь одно значение — headers
? Да, именно это нам и нужно.
Следующий код не для слабонервных:
// app/log/serverLogger.ts
import { pino } from "pino";
export const loggerInstance = pino({
// Необходимая конфигурация.
level: process.env.LOG_LEVEL ?? "info",
});
// app/log/middleware.ts
// Да, нужно разделить логгеры ...
// Здесь почти всё то же самое.
import { loggerInstance } from "./serverLogger";
export function requestLogger(requestId: string): Logger {
return loggerInstance.child({ requestId });
}
// app/log/server.ts
import { headers } from "next/headers";
import { loggerInstance } from "./serverLogger";
import { Logger } from "pino";
import { NextRequest } from "next/server";
const REQUEST_ID_HEADER = "dominik-request-id";
export function requestHeaders(
request: NextRequest,
requestId: string,
): Headers {
const head = new Headers(request.headers);
head.set(REQUEST_ID_HEADER, requestId);
return head;
}
// Да, эта функция должна быть асинхронной ...
export async function logger(): Promise<Logger> {
const hdrs = await headers();
const requestId = hdrs.get(REQUEST_ID_HEADER);
return loggerInstance.child({ requestId });
}
// middleware.ts
import { logger, LoggerStorage, requestLogger } from "./app/log/middleware";
import { requestHeaders } from "./app/log/server";
export async function middleware(request: NextRequest) {
const requestId = crypto.randomUUID();
LoggerStorage.enterWith(requestLogger(requestId));
logger()?.debug({ url: request.url }, "Started processing request!");
return NextResponse.next({ headers: requestHeaders(request, requestId) });
}
// app/page.tsx
export default async function Home() {
(await logger())?.info("Logging from the page!");
// ...
}
Разве не прекрасно? Мне особенно нравится, что теперь можно импортировать код логирования промежуточного слоя с сервера. Естественно, работать он не будет. Или, наоборот, импортировать код логирования сервера из промежуточного слоя. Который тоже работать не будет. Здесь важно ничего не напутать. И это мы ещё не говорили о логировании в клиентских компонентах, которые, вопреки своему названию, тоже выполняются на сервере. Да, это уже третье разделение.
Мне следует извиниться за то, что завёл вас в эту ловушку. Просто я сам уже несколько раз в неё попадал. Система промежуточного ПО может быть очень полезна при правильном дизайне, и я хотел показать вам, как бывает в противном случае. По факту это и стало основной причиной для написания статьи.
Думаю, что каждый из нас достигал в своей жизни некой точки, когда чувствовал, что с него хватит. Для меня эта точка возникла здесь. К чёрту! Давайте использовать кастомный сервер.
Эта возможность Next.js позволяет программно запускать сервер с нестандартной конфигурацией. Чаще всего вам это не потребуется, но в исключительных случаях может оказаться полезным.
Взглянем на пример из документации:
import { createServer } from 'http'
import { parse } from 'url'
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) => {
const parsedUrl = parse(req.url!, true)
handle(req, res, parsedUrl)
}).listen(port)
console.log(
`> Server listening at http://localhost:${port} as ${
dev ? 'development' : process.env.NODE_ENV
}`
)
})
Обратите внимание, что здесь снова handle
не получает никакие параметры — только URL запроса, сам сырой запрос и ответ.
Как бы то ни было, у нас есть AsyncLocalStorage
, так что волноваться не стоит. Давайте слегка перепишем этот пример.
// app/logger.ts
// Возвращаемся к нашей вариации с AsyncLocalStorage.
import { pino, Logger } from "pino";
import { AsyncLocalStorage } from "async_hooks";
const loggerInstance = pino({
// Вся необходимая конфигурация.
level: process.env.LOG_LEVEL ?? "info",
});
export const LoggerStorage = new AsyncLocalStorage<Logger>();
export function logger(): Logger | null {
return LoggerStorage.getStore() ?? null;
}
export function requestLogger(): Logger {
return loggerInstance.child({ requestId: crypto.randomUUID() });
}
// server.ts
import { logger, LoggerStorage, requestLogger } from "./app/logger";
app.prepare().then(() => {
createServer(async (req, res) => {
// Новый код.
LoggerStorage.enterWith(requestLogger());
logger()?.info({}, "Logging from server!");
const parsedUrl = parse(req.url!, true);
await handle(req, res, parsedUrl);
}).listen(port);
});
// middleware.ts
import { logger } from "./app/logger";
export async function middleware(request: NextRequest) {
logger()?.info({}, "Logging from middleware!");
return NextResponse.next();
}
// app/page.tsx
import { logger } from "./logger";
export default async function Home() {
logger()?.info("Logging from the page!");
// ...
}
Хорошо, теперь протестируем наше решение. Обновляем браузер, и …
> Server listening at http://localhost:3000 as development
[12:29:52.183] INFO (19938): Logging from server!
requestId: "2ffab9a2-7e15-4188-8959-a7822592108f"
✓ Compiled /middleware in 388ms (151 modules)
○ Compiling / ...
✓ Compiled / in 676ms (769 modules)
И всё. Да они издеваются. Какого хрена?
Тут вы можете подумать, что AsyncLocalStorage
работает не так. И вполне можете оказаться правы, но я напомню, что headers() и cookies() используют AsyncLocalStorage. Это то преимущество разработчиков Next.js, которого у нас нет.
Насколько я знаю, есть лишь два способа передать информацию из промежуточного слоя на страницу.
Заголовки
NextResponse.redirect
/ NextResponse.rewrite
для перенаправления ответа с дополнительными параметрами (например, /[requestId]/page.tsx
)
Как вы могли заметить, радужным ни один из них в нашем случае не выглядит. К вам просто относятся как к детям. Разработчики Next.js имеют чёткое представление о том, как всё должно работать, и вы либо ему подчиняетесь, либо проходите мимо. Обратите внимание: если бы это касалось только промежуточного ПО, то я бы не стал тратить свои выходные на всю эту критику фреймворка React. У меня есть дела поважнее. Но это постоянная боль, с которой при работе с Next.js вы встречаетесь ежедневно.
Бесит же в этом примере то, что Vercel может справиться с подобными задачами намного лучше. Я не хочу излишне хвалить Svelte(Kit), так как их последние решения вызывают у меня опасения, но этот фреймворк намного лучше Next.js. Давайте заглянем в их документацию по промежуточному ПО:
handle — эта функция выполняется при каждом получении запроса сервером SvelteKit [...] Она позволяет изменять заголовки или тело ответа, либо полностью обходить SvelteKit (для программной реализации маршрутов, например).
Пока звучит неплохо.
locals — чтобы добавить собственные данные в запрос, который передаётся обработчикам в +server.js и серверным функциям load, заполните объект event.locals как показано ниже.
На моих глазах от радости навернулись слёзы. Туда также можно передавать реальные объекты и классы — например, логгер.
Вы можете определить несколько функций обработки и выполнять их последовательно.
Вот так выглядит реальный инжиниринг. SvelteKit — это продукт Vercel. Но как так получается, что флагманский проект уступает побочному по возможностям? Что за чертовщина?
Мне больше нечего особо добавить, но раз уж мы тут все собрались, то будет уместным упомянуть про баг-трекер на GitHub. Это, пожалуй, вершина всей той мусорной кучи недоразумений, которые есть в Next.js. Это то место, куда все надежды и мольбы приходят умирать. Среднее время ответа на баг-репорт здесь — никогда. Я из спортивного интереса решил поискать истории отправки отчётов о багах и их обсуждения касательно тех проблем, с которыми сталкивался сам. В итоге я даже готов принимать ставки на то, сколько лет уйдёт, чтобы получить ответ от команды Next.js.
Думаете, я шучу? Здесь годами лежат сотни запросов с кучей эмодзи 👍 в ожидании официального ответа. И когда этот ответ, наконец, приходит, в нём говорится, что вы действуете неправильно, и решение для ваших реальных проблем уже в разработке. После этого упомянутое «решение» ещё несколько лет томится в канареечной версии.
Я сам лично отправлял два баг-репорта год назад. Имейте в виду, что для создания валидного баг-репорта вам нужно воспроизвести проблему.
И что же ты получаешь за время, потраченное на минимальное воспроизведение бага? Всё верно. Полное молчание.
Я бы сообщил ещё о десятке проблем, которые встречал, но после такого уже не стал.
Честно говоря, даже не знаю, существуют ли ещё те баги.
Не знаю. Лично я больше не хочу использовать Next.js. Вы можете решить, что это всего-навсего одна проблема, которую я преувеличил. Но в этом фреймворке на каждом углу можно встретить баги и пограничные случаи. Как они вообще умудрились сделать так, что TypeScript компилируется медленнее Rust? Зачем проводить различие между кодом, выполняющемся на клиенте и на сервере, не предоставляя никаких инструментов для использования этого факта? Зачем? Почему? И так далее. Не думаю, что у меня хватит ресурса, чтобы вытащить всех нас из этого болота Next.js. Но я обязательно озвучу своё мнение, если в итоге мы напишем другое приложение. Посмотрим, вдруг трава в нём окажется зеленее.