03 окт. 2025 г.·5 мин. чтения

Next.js App Router: смешения серверных и клиентских компонентов — как исправить

Научитесь быстро находить и исправлять ошибки смешения серверных и клиентских компонентов в Next.js App Router: как реструктурировать компоненты, Server Actions и получение данных, чтобы избежать падений в рантайме.

Next.js App Router: смешения серверных и клиентских компонентов — как исправить

Что такое смешение серверного и клиентского кода?\n\nСмешение серверного и клиентского кода происходит, когда часть кода выполняется не там, где должна.\n\nВ Next.js App Router некоторые компоненты предназначены для выполнения на сервере (безопасно для секретов, прямых вызовов БД, приватных API). Другие — для выполнения в браузере (клики по кнопкам, локальное состояние, доступ к window). Когда эти обязанности перепутаны, вы получите падения, проблемы с гидратацией или сборки, которые падают только после деплоя.\n\nПолезная модель для мышления:\n\n- Серверные компоненты получают и подготавливают данные, затем передают простые props вниз.\n- Клиентские компоненты обрабатывают взаимодействие, но не должны тянуть серверный код.\n\nВ деве это часто выглядит нормально — режим разработки может быть более снисходительным. Hot reload, другая упаковка и тайминги могут скрывать проблемы границ. Продакшен‑сборки строже на то, что можно запустить в браузере, а гидратация менее терпима к несоответствиям.\n\nТипичные симптомы:\n\n- Пустая страница после навигации (ошибка видна только в консоли)\n- Ошибки гидратации: UI мигнёт, затем ломается\n- Неожиданные 500‑ошибки при рендере\n- “window is not defined” или “document is not defined”\n- Ошибки сборки про импорт серверных модулей в клиентские файлы\n\nAI‑сгенерированный код делает такие ошибки чаще, потому что фрагменты копируются без учёта границ. Типичный пример: добавить "use client" на всю страницу, чтобы заглушить ошибку с хуком, хотя страница импортирует хелпер для БД или читает секреты.\n\n## Как App Router разделяет серверные и клиентские компоненты\n\nВ App Router по умолчанию каждый компонент — Server Component. Это простое правило объясняет большинство сюрпризов.\n\n### Серверные компоненты (по умолчанию)\n\nСерверные компоненты выполняются на сервере. Используйте их для получения данных, чтения cookies/headers, работы с environment‑секретами и любой тяжёлой логики, которую не хочется класть в браузер.\n\nЕсли это касается базы данных, приватных API‑ключей или сессии аутентификации — держите это на сервере и передавайте результаты как простые props.\n\n### Клиентские компоненты (по желанию)\n\nКомпонент становится клиентским только когда вы добавляете "use client" в начале файла. Клиентские компоненты выполняются в браузере, поэтому они могут использовать состояние, эффекты, обработчики событий и браузерные API вроде localStorage.\n\nГраница работает так:\n\n- Серверный компонент может импортировать клиентский компонент. Всё внутри этого клиентского компонента будет выполняться на клиенте.\n- Клиентский компонент не может импортировать серверный компонент или сервер‑только модули.\n\nОбычно "use client" нужен, если компонент использует хуки вроде useState/useEffect, события браузера onClick или API типа window и document.\n\nВам не нужен "use client" для простой разметки, которая только рендерит props. Распространённый фикс — держать страницу и загрузку данных на сервере, а рендерить маленький клиентский компонент только для интерактивной части (фильтр, модалка или inline‑редактор).\n\n## Шаблоны падений рантайма, которые легко распознать\n\nБольшинство падений в App Router — одна и та же проблема: код для браузера выполняется на сервере, либо серверный код попадает в бандл для браузера.\n\n### Паттерн 1: хуки в серверном компоненте\n\nЕсли вы видите ошибки вроде “React Hook ... is not supported in Server Components” или “You're importing a component that needs useState/useEffect”, проверьте шапку файла. Если файл не начинается с "use client", React будет считать его серверным компонентом.\n\n### Паттерн 2: серверные модули попали в клиентский код\n\nОшибки про fs, path, crypto или “Module not found: Can't resolve 'fs'” часто означают, что клиентский компонент импортировал общий хелпер, который (возможно косвенно) импортирует Node‑только код.\n\nТакое часто случается, когда общий utils или lib смешивает серверные и клиентские помощники, а клиент импортирует его «только ради одной функции».\n\n### Паттерн 3: браузерные API используются во время серверного рендера\n\n“window is not defined”, “document is not defined” и “localStorage is not defined” означают, что код выполняется на сервере. Это может быть серверный компонент, серверное действие или модуль, который импортируется в ходе серверного рендера.\n\n### Паттерн 4: вызов серверной логики с клиента без безопасного моста\n\nТакие ошибки выглядят как “You're importing a Server Action into a Client Component”, “Server-only module cannot be imported from a Client Component” или просто клиентский вызов функции, которая никогда не должна была выполняться в браузере.\n\n## Пошагово: найдите плохую границу в дереве компонентов\n\nСамые быстрые победы приходят от воспроизводимой ошибки. Попробуйте воспроизвести её в деве и в прод‑сборке. “Работает в dev” — не значит, что границы в порядке.\n\nКогда вы смотрите на ошибку, остановитесь на первом файле, который действительно принадлежит вам. Фреймворковые трассировки громоздки. Первый файл в вашем репозитории обычно — место, где в дерево попадает неверный импорт или тип компонента.\n\nПростой рабочий процесс:\n\n- Воспроизведите падение одинаково каждый раз (тот же путь, то же действие, то же состояние пользователя).\n- В трассировке стека перейдите к первому app‑файлу и посмотрите, какой компонент его отрендерил.\n- Проверьте шапку файла: серверный это компонент по умолчанию или он начинается с "use client"?\n- Идите по импортам, пока не найдёте первое несоответствие:\n - серверный импорт используется в клиентском коде (fs, клиенты БД, next/headers)\n - браузерное использование внутри серверного кода (window, document, localStorage)\n- Примите решение по владению: секреты и данные — на сервере, состояние UI и события — на клиенте.\n\nОчень частая ошибка: серверная страница передаёт клиентскому компоненту клиенту объект подключения к БД, данные, полученные из cookies, или серверный хелпер. Это сломает всё. Делайте fetch на сервере, затем передавайте простые JSON‑данные вниз.\n\n## Реструктурирование компонентов, смешивающих сервер и клиент\n\nПадения обычно происходят, когда один компонент пытается делать всё сразу.\n\nНадёжное разделение: \n\n- Серверный компонент: получает данные, проверяет аутентификацию, использует секреты.\n- Клиентский компонент: управляет состоянием, событиями, эффектами и любым UI‑кодом, завязанным на DOM.\n\nПоднимите работу с данными выше по дереву. Доставайте данные в серверном компоненте (или в серверной функции, вызываемой им), затем передавайте результат как простые props. Это не даст серверному коду попасть в клиентский бандл.\n\nИзолируйте интерактивность. Держите клиентские части маленькими, чтобы не отправлять всю страницу в браузер ради одной кнопки.\n\nНа границе сервер→клиент держите props простыми: строки, числа, булевы, массивы, простые объекты. Не передавайте клиентов БД, объекты запроса, экземпляры классов или функции.\n\nПример: dashboard‑страница получает данные пользователя, подписку и недавнюю активность, но имеет фильтры, модалку и график. Получите всё в DashboardPage (сервер) и передайте { userName, plan, activityItems } вниз. DashboardControls — клиентский компонент — отвечает за состояние фильтров и открытие/закрытие модалки.\n\n## Server Actions: безопасные паттерны для форм и мутаций\n\nServer Actions хорошо подходят, когда пользователь отправляет форму и нужно изменить данные: создать запись, обновить профиль, сбросить пароль или выполнить небольшой workflow.\n\nБезопасная структура: держите UI формы в клиентском компоненте, а мутацию — в серверном файле как экспортируемое действие. Клиент управляет полями, состоянием загрузки и отображением ошибок. Сервер выполняет проверку авторизации, валидацию и обращения к БД.\n\nts\n// actions.ts\n'use server'\n\nexport async function updateProfile(formData: FormData) {\n const name = String(formData.get('name') ?? '')\n // validate, check auth, write to DB\n return { ok: true }\n}\n\n\nНа стороне клиента передавайте только то, что действительно нужно серверу. Не гоните секреты, токены или сырые объекты пользователя через props только чтобы action сработал. Если действию нужно знать, кто пользователь — прочитайте это на сервере (cookies/session) внутри action.\n\nДве привычки предотвращают большинство утечек:\n\n- Валидируйте ввод и перепроверяйте авторизацию внутри action.\n- Возвращайте понятные, безопасные ошибки для пользователей, а не трассировки стека.\n\nЕсли нужен оптимистичный UI, держите его локальным и небольшим. Не делайте всю страницу клиентской только ради индикатора загрузки.\n\n## Получение данных в App Router без лишней работы\n\nМногие проблемные границы начинаются с двойного получения данных.\n\nВ App Router по умолчанию желательно делать fetch на сервере близко к маршруту. Так вы получите более быстрый первый рендер, меньше бандлы в браузере и сохраните секреты в серверной среде.\n\nДелайте запросы на клиенте только когда это действительно нужно (polling, реальные виджеты, или кнопка обновления для одной секции).\n\nРаспространённая ошибка: серверный рендер получает данные, затем клиентский компонент монтируется и снова делает fetch в useEffect. Это вызывает мерцание, проблемы с лимитами и запутанные несоответствия.\n\nЧистый поток выглядит так:\n\n- Запрос приходит на маршрут\n- Серверный fetch получает данные (БД, внутренний API или сторонний сервис)\n- Серверные компоненты рендерят страницу с этими данными\n- Клиентские компоненты обрабатывают взаимодействия и запускают целевые обновления\n\nКэширование тоже может скрывать проблемы при тестировании. Если данные выглядят случайно устаревшими, проверьте, не кэшируется ли fetch и как настроена рева‑валидация.\n\n## Аутентификация и секреты: что должно оставаться на сервере\n\nПроблемы с аутентификацией часто начинаются с ошибки границы: клиентский компонент трогает то, что никогда не должно покидать сервер. Иногда это вызывает падения или странные редиректы. Иногда вы тихо сливаете секреты в клиентский бандл.\n\nЧаще всего в сгенерированном коде утечки:\n\n- Чтение переменных окружения в клиентском компоненте\n- Помещение конфигурации в общий файл, который импортируется и сервером, и клиентом\n\nЕсли это будет тяжело увидеть в DevTools — этого не должно быть доступно клиенту.\n\nДержите проверки авторизации и логику ролей на сервере. Клиент может рендерить состояния UI, но не должен быть источником истины для «разрешён ли пользователь».\n\nИзбегайте хранения чувствительных токенов в localStorage по умолчанию. Это легко посмотреть и может быть украдено через XSS.\n\nБыстрые поломки, за которыми стоит понаблюдать:\n\n- Циклы редиректов, когда и сервер, и клиент пытаются защищать один и тот же маршрут\n- Несоответствия сессий, когда сервер отрендерил одно состояние, а клиент гидрируется в другое\n- Путаница runtimes (Edge vs Node) в библиотеках для auth\n- “Работает локально, ломается в проде”, когда env vars отличаются и клиентские бандлы меняются\n\n## Частые ошибки, из‑за которых падения возвращаются снова и снова\n\nБольшинство повторяющихся падений — не таинственные баги фреймворка. Это те же ошибки границ, залеченные наспех и затем снова введённые.\n\nПара закономерных паттернов:\n\n- Добавление "use client" на большую страницу, чтобы заглушить ошибку с хуком\n- Общие хелперы, которые смешивают серверный и клиентский код\n- Создание или импорт клиента БД прямо в файлах компонентов (распространяется через импорты очень быстро)\n- Вызов fetch() к собственному API из серверного компонента по привычке, хотя можно вызвать серверный код напрямую\n- Фиксы методом проб и ошибок вместо следования к первому плохому импорту в стеке\n\nТипичный пример: dashboard‑страница падает только в проде, потому что импортирует getUser() (читает cookies, сервер‑только), но страницу пометили "use client" ради графика. Надёжный фикс — вынести график в отдельный клиентский компонент и оставить страницу сервер‑первой.\n\n## Быстрая чек‑лист перед отправкой в прод\n\nБольшинство падений App Router — результат одного файла, выполняющего две задачи.\n\n### Проверка границ\n\nСпросите для каждого компонента: может ли этот файл выполниться в браузере?\n\nЕсли да — он не должен трогать секреты, сервер‑только env‑переменные, клиентов БД или библиотеки Node. Если вы видите такие импорты — вынесите работу в Серверный Компонент, Server Action или серверный маршрут.\n\nФинальная проверка:\n\n- Browser API (window, document, localStorage, navigator) и хуки — значит Клиентский Компонент. Убирайте серверную логику.\n- Секреты и сервер‑только импорты — значит Серверный Компонент. Передавайте только нужные данные для UI.\n- Props, пересекающие границу, должны быть сериализуемыми (простые объекты, массивы, строки, числа). Избегайте экземпляров классов, BigInt и функций.\n- Для операций записи (формы, обновления, удаления) используйте Server Action или серверный маршрут.\n- Тестируйте продакшен‑сборку локально, а не только next dev.\n\n### Одна практическая привычка\n\nПеред деплоем прогоните основные сценарии после чистой сборки. Если страница падает только в прод режиме, скорее всего это проблема границы, несериализуемый prop или сервер‑только импорт, попавший в клиент.\n\n## Пример: починка падающей dashboard‑страницы\n\nКлассическая ситуация: dashboard‑страница нужна серверно‑полученные данные пользователя плюс интерактивные фильтры (диапазон дат, переключатели статуса, поиск).\n\n### Где ошибка\n\nПервая версия часто смешивает обязанности в одном файле. Например, app/dashboard/page.tsx получает данные на сервере, но также использует useState, читает localStorage или вызывает window.matchMedia для запоминания настроек фильтров. В браузере это выглядит ок, но по умолчанию страница — Server Component, поэтому может упасть с “window is not defined” или “Hooks can only be used in a Client Component.”\n\nЕщё частая оплошность: filter UI помечен 'use client', но импортирует сервер‑только хелпер, который читает cookies или обращается к приватной базе. Это вызовет ошибки вида “You’re importing a Server Component into a Client Component”.\n\n### Простая реструктуризация, которая останавливает падения\n\nСделайте страницу ответственной за данные, а клиентский компонент — за интерактивность.\n\nНа сервере (page): получаете данные и рендерите их.\n\ntsx\n// app/dashboard/page.tsx (Server Component)\nimport Filters from './Filters';\nimport { getDashboardData } from './data';\n\nexport default async function Page() {\n const data = await getDashboardData();\n return (\n <>\n <Filters initial={data.filters} />\n {/* render table using data.items */}\n </>\n );\n}\n\n\nНа клиенте (filters): держите состояние и UI‑события локальными и отправляйте изменения через Server Action.\n\ntsx\n// app/dashboard/actions.ts\n'use server';\nexport async function updateFilters(next) {\n // validate input, save, return safe data\n return { ok: true };\n}\n\n\nРезультат: меньше падений во время выполнения, чище ответственность (данные и секреты остаются на сервере, клиент обрабатывает клики), а обновления проходят по одному безопасному пути.\n\n## Что делать дальше, если App Router продолжает ломаться\n\nЕсли та же ошибка повторяется снова и снова, скорее всего в проекте системная проблема границ: клиент тянет сервер‑только модули, сервер импортирует хуки, или мутации разбросаны по клиенту.\n\nAI‑сгенерированные кодовые базы от инструментов вроде Lovable, Bolt, v0, Cursor или Replit склонны повторять эти ошибки, потому что смешивают паттерны из старых установок, которые не выдерживают строгих правил App Router.\n\nКогда одинаковые симптомы проявляются на нескольких страницах, фокусированный рефактор часто быстрее, чем латание.\n\nЕсли вы унаследовали сломанный AI‑прототип и хотите быстрый структурированный диагноз, FixMyMess (fixmymess.ai) специализируется на ремонте и укреплении таких Next.js кодовых баз, начиная с бесплатного аудита кода, чтобы найти первые ошибки границ и рискованные импорты.