Стили, сборка и выкладка
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Доска работает, но выглядит черновиком: размеры и рамки вписаны в разметку атрибутом style. До живого адреса остались три шага — разложить стили по файлам рядом с компонентами, собрать проект и решить, что поедет на сервер: папка с файлами или процесс Node. Последнее решается не вкусом, а списком того, что ваш код делает в момент запроса.
Доска из прошлых трёх статей размечена атрибутом style прямо в JSX — так было честно, пока проверялась раскладка. Для настоящих стилей это тупик: ни наведения, ни медиазапроса, ни тёмной темы в инлайновом объекте не выразить.
В части II классы column и card были зацепками для CSS, и уникальность имён держалась на внимании: .card в том проекте был ровно один. Здесь компонентов полтора десятка, и вторая .card появляется сама — вместе с чужой карточкой, которая внезапно перекрасилась.
CSS-модули решают это без дисциплины. Next подключает их без единой строки настройки: файл, имя которого кончается на .module.css, обрабатывается как модуль. Имена классов в нём — локальные, наружу сборщик выпускает уникальные, а импорт отдаёт объект: ключи ваши, значения — итоговые строки.
Никакой магии в разметке при этом нет: className получает обычную строку. Вся работа сборщика — переименовать классы так, чтобы совпасть они не могли, и склеить все модули в один файл стилей.
Место для файла определилось ещё в первой статье главы: модуль лежит папкой своего имени, а стили — рядом с разметкой, под тем же именем.
| Файл | Что внутри |
|---|---|
src/ui/task-card/task-card.tsx | разметка и логика |
src/ui/task-card/task-card.module.css | стили этого компонента и больше ничьи |
src/ui/task-card/task-card.types.ts | типы пропсов |
app/globals.css | сброс, шрифты и переменные темы — единственный глобальный файл |
app/globals.css импортируется ровно один раз — в корневой раскладке app/layout.tsx. Его классы глобальны намеренно: body, :root, сброс полей. Всё остальное — по модулю на компонент.
Имя файла решает всё: task-card.css Next тоже подключит, но классы из него останутся глобальными. Слово module в имени — это не соглашение об аккуратности, а переключатель поведения.
Если вы верстали на Sass, рука тянется назвать файл .module.scss. Но главные причины ставить Sass браузер забрал себе: переменные, вложенность и смешение цветов через color-mix() стали обычным CSS. С примерами все три разобраны в прологе: что CSS забрал у Sass. А арифметику давно считает calc(), причём прямо на странице, поэтому ему по силам и «100% минус 24px»: при сборке такое не посчитать — сколько это, 100%, знает только браузер.
За Sass остались три вещи: миксины — в том числе такие, что принимают целый блок правил и вставляют его на место @content; циклы @each и @for, которые пишут правила по списку; модули @use со своими переменными у каждого файла. Less, второй язык той же схемы, — уже история: на нём был написан Bootstrap 3, а Bootstrap 4 в 2018 году вышел на Sass. Next примет и .module.scss — достаточно поставить пакет sass, и файл станет таким же модулем. Доска Sass не берёт: имена изолирует модуль, общие значения держат CSS-переменные темы, а миксины и циклы полутора десяткам компонентов не нужны — лишний пакет и компиляция на каждой сборке ничем не окупятся.
За изоляцию имён приходится платить, и счёт выставляется в самом неудобном месте. Next объявляет любой модуль стилей как { readonly [key: string]: string } — ключи не перечислены. Прежде чем нажать Run, решите: что окажется в атрибуте class у заголовка?
Ни диагностики компилятора, ни ошибки в браузере: атрибута просто нет. React не ставит class, если значение — undefined, поэтому опечатка в ключе выглядит как «стиль почему-то не применился». Искать такое приходится глазами.
Индексная сигнатура приходит из объявления Next, но её можно сузить один раз — там, где модуль импортируется. Дальше по коду опечатка становится обычной ошибкой типов, знакомой по части III.
Диагностика приходит на последнюю строку и даже подсказывает похожее имя: Свойство "titel" не существует в типе "Record<"card" | "title", string>". Вы имели в виду "title"?. Выполнение при этом идёт дальше и печатает undefined — типы живут до запуска.
Обратите внимание на as: обычная аннотация здесь не проходит. Объект с индексной сигнатурой не назначается типу с обязательными ключами — компилятор скажет, что свойств card и title в нём нет, хотя они там есть. Он не читает файл стилей и верит только объявлению.
Список ключей придётся держать в согласии с файлом стилей руками — зато рассогласование видно в редакторе, а не на живой странице. Для маленького компонента это лишние три строки; для карточки с десятком состояний — дешёвая страховка.
Карточка редко носит один класс: базовый, плюс модификатор состояния, плюс то, что передал родитель. Строка собирается в каждом компоненте, и это самое частое место, где классы тихо теряются. Предскажите обе строки вывода — обе печатаются через JSON.stringify, чтобы были видны пробелы.
join зовёт String для каждого элемента, и false честно превращается в "false" — в атрибут class уезжает класс, которого нет ни в одном файле стилей. У null и undefined поведение другое, для них join подставляет пустую строку: получается хвостовой пробел и никакого сигнала, что данных не было.
Браузер на такое не жалуется: несуществующий класс — не ошибка. Значит, отсеивать пустые значения должны мы, и лучше в одном месте на весь проект. Функция на две строки закрывает вопрос — библиотека для этого не нужна.
Тип ClassValue здесь важнее реализации: он разрешает передавать результат && — то есть false вместо строки — и запрещает всё остальное. Число или объект в cx компилятор уже не пропустит.
Теперь настоящая карточка доски. Она получает className от родителя, добавляет собственный модификатор и печатает итоговую строку на каждом рендере.
Класс от родителя стоит последним в строке — но на приоритет это не влияет: в CSS побеждает специфичность селектора, а при равной специфичности тот, кто ниже в собранном файле стилей. Порядок классов в атрибуте не значит ничего, и «перебить» чужой класс своим той же специфичности — лотерея. Надёжный способ — не перебивать, а дать компоненту пропс.
Последняя ловушка раздела ловится компилятором, и на неё стоит посмотреть живьём: в className попал весь объект стилей.
Диагностика приходит до запуска, а браузер потом всё равно рисует карточку — с классом [object Object]. Это та же граница, что и во всей части: типы живут до сборки, а до страницы доезжает обычный JavaScript.
className={cx(...)} стоит писать даже там, где класс сейчас один. Второй появится через неделю, и место для него уже будет готово.
Изоляция имён — благо ровно до момента, когда цвет фона нужен всем компонентам сразу. Копировать #f5f5f5 в пятнадцать модулей нельзя: тёмную тему потом не собрать. Выход — разделить труд: имена классов изолируем, значения делаем общими.
Общими их делают CSS-переменные. Сборщик переименовывает только классы; --accent остаётся --accent в любом модуле, потому что это часть каскада, а не имя из вашего файла.
| Что | Где живёт | Почему именно там |
|---|---|---|
--bg, --text, --accent | app/globals.css, блок :root | значение нужно всем — от карточки до подвала |
.card { background: var(--bg) } | task-card.module.css | имя класса локальное, значение общее |
| тёмная тема | :root[data-theme="dark"] в globals.css | переключается атрибутом на <html>, компоненты о ней не знают |
| цвет конкретной колонки | разметка: style с переменной | значение приходит данными и на сборке неизвестно |
Последняя строка таблицы — единственное место, где переменную задают из JavaScript: цвет колонки приходит вместе с данными, и в файле стилей его записать нечем. Перед запуском решите, какой цвет получит заголовок внутри секции.
В выводе — rgb(192, 57, 43). Переменная задана на секции, а прочитал её вложенный заголовок: var() ищет значение вверх по дереву, и модули на этот поиск не влияют — они переименовывают классы, а не переменные. Проверяли мы при этом не намерение, а результат: getComputedStyle показывает то, что браузер посчитал на самом деле.
Приведение в этой строке — не небрежность. Тип React.CSSProperties перечисляет свойства CSS по именам, и пользовательских переменных в этом списке нет: в проекте литерал { "--accent": accent } без приведения компилятор отвергнет — «объектный литерал может использовать только известные свойства». Это тот редкий случай, когда as не прячет ошибку, а сообщает компилятору то, чего он знать не может: список ваших переменных не описан нигде.
Тёмная тема живёт в globals.css целиком: компоненты пишут var(--bg) и не знают, какая тема включена. Ни одного if по теме в разметке быть не должно.
В проекте оба набора переменных лежат в globals.css — в :root и в :root[data-theme="dark"], а переключатель ставит атрибут на <html>. Отдельного файла стилей в песочнице нет, поэтому здесь тот же набор задан на обёртке; механизм от этого не меняется — значение всё равно ищется вверх по дереву.
Текст стал светлым — rgb(26, 26, 26) сменился на rgb(245, 245, 245), — и в разметке при этом не поменялось ни одного класса: <p> как читал var(--text), так и читает. Поменялось значение на одном узле выше — остальное сделал каскад. Ровно поэтому тему не нужно пробрасывать через контекст и ветвить по ней разметку.
next build — не «минификация на выходе». Это три разные работы подряд, и в логе они идут отдельными строками: Compiled successfully, Running TypeScript ..., Generating static pages ..., а в конце — таблица маршрутов с легендой значков. А самая первая строка, ещё до них, называет сборщик — словом в скобках после версии Next: без флагов это (Turbopack), а с флагом --webpack, который включает прежний сборщик, — (webpack).
Строка про TypeScript стоит того, чтобы её заметить. Сборка запускает ту же проверку типов, что и tsc из части III, и при несходящихся типах проект просто не собирается. Разработка себя так не ведёт: next dev страницу покажет — типы стираются, а код выполняется. «Работает локально» и «собирается» — разные утверждения, и второе строже.
Таблица маршрутов в конце — отчёт о том, что Next успел посчитать заранее. Для нашей доски она выглядит так (читать её подробно учит статья «Статика, динамика и всё между ними»).
| Маршрут | Значок | Почему именно он |
|---|---|---|
/ | ○ Static | доска считается один раз, на сборке: ни кук, ни заголовков запроса в коде нет |
/tasks/[id] | ƒ Dynamic | у сегмента нет generateStaticParams — список задач заранее не назван |
/tasks/[id]/edit | ƒ Dynamic | то же самое: адрес известен только в момент запроса |
Первая строка здесь — источник самого обидного бага выкладки. Доска собрана на сборке; посетитель, зашедший через час после добавления задачи, увидит её такой, какой она была в момент next build, — если действие не позвало revalidatePath. Под next dev это не воспроизводится никогда: там страница считается заново на каждый запрос.
Проверять проект надо командами next build и next start, а не разработкой. Заморозка страниц, вшитые переменные и забытый generateStaticParams видны только там.
Вторая вещь, которая случается именно на сборке, — подстановка переменных окружения. Всё, что начинается с NEXT_PUBLIC_, попадает в бандл значением. Предскажите, что станет с обеими строками исходника.
Публичная переменная превратилась в строковый литерал — её больше нет как переменной, поменять её в контейнере невозможно, только пересобрать образ. Серверная осталась обращением к process.env и будет прочитана в момент запроса, на сервере, куда браузер не заглядывает. Сама модель — регулярка: окажись process.env.NEXT_PUBLIC_API_URL внутри строкового литерала, она заменила бы и его. Настоящая подстановка идёт по дереву и строку с тем же текстом не тронет.
Отсюда странная на первый взгляд строка в Dockerfile учебника: адрес сайта приходит туда аргументом сборки, а не переменной окружения контейнера. Из NEXT_PUBLIC_SITE_URL строятся канонические ссылки и sitemap.xml, и подставляются они на сборке — переменной в рантайме их уже не исправить.
Секрет с префиксом NEXT_PUBLIC_ — это опубликованный секрет: он лежит в исходнике страницы, который открывается двумя нажатиями. Ключи к базе и токены живут без префикса и читаются только серверным кодом.
У сборки два разных конца. Обычная оставляет после себя приложение, которое нужно запустить: next start или server.js держат процесс Node, и каждый запрос проходит через ваш код. Строка output: "export" в next.config.ts заканчивает сборку папкой out/: готовые HTML, CSS и JavaScript, которые отдаст любой файловый сервер.
Выбирают между ними не по размеру сайта и не по вкусу. Правило одно: если в момент запроса должен выполниться ваш код — нужен процесс. Дальше остаётся сверить со списком.
| Что делает приложение | С процессом Node | С output: "export" |
|---|---|---|
| Server Actions — наши формы | работают | сборка останавливается: Server Actions are not supported with static export |
cookies(), headers() | работают | неоткуда взять: запроса на сборке нет |
revalidatePath, срок годности страницы | работает | переписывать файл некому |
route.ts с POST | работает | остаётся только GET, и тот превращается в файл |
proxy.ts перед маршрутами | работает | не выполняется вовсе |
next/image с оптимизацией | работает | нужен images.unoptimized или свой загрузчик |
| Что держать на сервере | процесс Node: память, перезапуск, обновления | ничего: файлы и обычная раздача |
Тот же список удобно держать функцией — и заодно проверить на двух настоящих проектах: нашей доске и этом учебнике.
Ответ по доске получен: формы сделаны действиями, значит экспорт отпадает — не «работает хуже», а буквально останавливает сборку. Учебнику, наоборот, процесс не нужен ни для чего: все страницы известны на сборке, а всё живое в нём — песочница и подсветка — выполняется в браузере читателя.
Подробности жизни без процесса — generateStaticParams, правила отдачи файлов, заголовки кеша — разобраны в статье «Статический экспорт: сайт без Node на сервере». Здесь важно другое: этот выбор делается до того, как написана первая форма, а не в день выкладки. Переезд с действий на отдельный API — это переписанная половина проекта.
Промежуточный вариант существует: статика плюс отдельный домен с API, куда браузер ходит сам. Он не отменяет правила, а переносит процесс за границу сайта — процесс всё равно чей-то.
Доске нужен процесс — значит, в образ поедет Node. Класть туда весь проект с node_modules не нужно: output: "standalone" просит сборку собрать самодостаточный запуск. В .next/standalone появляется server.js и урезанный node_modules — только те файлы, до которых дотянулись импорты.
Запускается это командой node server.js. Порт берётся из переменной PORT — по умолчанию 3000, — а слушает сервер 0.0.0.0, то есть все интерфейсы контейнера: привяжись он к localhost, снаружи до него никто бы не достучался. Ни next, ни pnpm в рантайм-образе больше не нужны.
И сразу ловушка, ради которой стили и выкладка попали в одну статью: .next/static и public в standalone не копируются. Их кладут рядом руками. Симптом узнаваемый — страница открывается, разметка на месте, а стилей нет: в панели сети видно 404 на файл из /_next/static/css/.
| Слой образа | Что делаем | Зачем |
|---|---|---|
| сборка | pnpm install и next build | получаем .next/standalone, .next/static и статику |
| рантайм | копируем .next/standalone | server.js и его урезанные зависимости |
| рантайм | копируем .next/static в .next/static | те самые стили и чанки, иначе сайт без оформления |
| рантайм | копируем public | картинки, иконки, robots.txt |
| запуск | node server.js | процесс слушает 3000 и рисует страницы |
Наружу этот контейнер сам не смотрит. Перед ним стоит обратный прокси — Caddy или nginx: он получает и продлевает сертификат, слушает 80 и 443, а внутрь ходит по имени сервиса — reverse_proxy web:3000. Приложение о HTTPS не знает ничего, и это правильно: сертификат — забота входа, а не вашего кода.
Сборка образа на маленьком VPS обрывается загадочно и без объяснений: next build не влезает в два гигабайта памяти. Лечится swap-файлом или сборкой образа не на сервере.
Теперь сверим с учебником, который вы читаете, — у него ровно тот же Next и совсем другая выкладка. В его next.config.ts стоит output: "export": сборка заканчивается папкой out/ со всеми статьями, и в рантайм-образе нет Node вообще — там caddy:2-alpine, которому просто отдали эту папку.
| Доска задач | Учебник java-script.pro | |
|---|---|---|
строка в next.config.ts | output: "standalone" | output: "export" |
| что уезжает в образ | server.js + .next/static + public | папка out/ |
| базовый образ рантайма | node:24-slim | caddy:2-alpine |
| что происходит на запрос | код рисует страницу | сервер отдаёт файл |
| как меняется содержимое | действие и revalidatePath | пересборка и выкладка |
| что может упасть ночью | процесс | нечему: процесса нет |
Разница в двух образах выросла из одной строки конфигурации, а та — из ответа на вопрос прошлого раздела. Ничего «продвинутого» в выкладке учебника нет: это буквально папка файлов и файловый сервер поверх неё.
Выложили — проверяйте на живом адресе, а не на localhost. Пять пунктов ловят почти все ошибки первого выката.
| Проверка | Что ловит |
|---|---|
открыть /tasks/<id> прямым вводом адреса, а не переходом по ссылке | правила отдачи: переход внутри приложения работает и на сломанном сервере |
| посмотреть в панели сети, что CSS приехал с кодом 200 | забытый .next/static в образе |
| добавить задачу и обновить страницу | забытую перевалидацию после действия |
| найти в исходнике страницы адрес сайта | аргумент сборки: вшито то, что подставили на next build |
| перезапустить контейнер | данные доски живут в памяти процесса — после перезапуска доска пуста |
Последний пункт — не баг выкладки, а честная граница проекта: базы у нас нет, и задачи лежат в памяти сервера. Всё, что нужно, чтобы они пережили перезапуск, — тема части VI.
Сборка трассирует зависимости от корня проекта, и в монорепо этот корень она угадывает по файлу блокировки. Угадав чуть выше или чуть ниже, чем вы ожидали, она положит server.js не туда, куда смотрит Dockerfile, — и образ соберётся, а контейнер упадёт на «Cannot find module».
Лечится явным outputFileTracingRoot в конфигурации: корень называется руками, и путь внутри .next/standalone перестаёт зависеть от догадок. В учебнике эта строка стоит по той же причине, хотя он и собирается экспортом.
- CSS-модуль лежит рядом с компонентом и даёт объект «локальное имя → уникальный класс»: совпадения имён становятся невозможными, а не маловероятными.
- Ключи модуля объявлены индексной сигнатурой, поэтому опечатка не ловится ничем: элемент остаётся без класса. Лечится сужением типа при импорте.
- className собирается одной маленькой функцией: join сам по себе превращает false в класс «false», а порядок классов в атрибуте на приоритет не влияет.
- Общие значения живут CSS-переменными в globals.css; модули переименовывают классы и не трогают переменные — на этом стоит переключение темы.
- next build проверяет типы, печатает таблицу маршрутов и вшивает NEXT_PUBLIC_* в бандл навсегда; проверять проект нужно сборкой, а не разработкой.
- Выбор «экспорт или процесс» делает код: действиям, cookies и перевалидации нужен Node. Доске — standalone-образ с обязательными .next/static и public, учебнику — папка out/ под файловым сервером.
1/4В task-card.module.css есть класс .title, а в разметке написано className={styles.titel} — с опечаткой. Что произойдёт?
2/4done равен false. Что вернёт [styles.card, done && styles.done].join(" ")?
3/4Проект, где форма добавления сделана Server Action, собрали с output: "export". Что будет?
4/4Приложение выложено standalone-образом. Страница открывается, текст и разметка на месте, стилей нет; в панели сети — 404 на файл из /_next/static/css/. Что забыли?
Склейка, которая не теряет классы
Функция cx должна собирать className из чего угодно: строк, результатов &&, undefined из необязательного пропса. Сейчас она отдаёт всё подряд, включая слово false. Допишите её.
Фильтр убирает false, null, undefined и пустые строки, а предикат делает тип результата честным — string[] вместо ClassValue[].
Обратите внимание на третью строку вывода: пустая строка, а не пробел. Именно она попадёт в className, когда у карточки не окажется ни одного модификатора, — и React в этом случае поставит пустой атрибут, а не мусор.
Переживёт ли проект экспорт
Соберите проверку из раздела про выбор: функция получает список того, чем пользуется приложение, и отвечает, хватит ли ему папки out/. Если не хватит — перечисляет, что именно мешает, в порядке использования.
Список того, что требует процесса, лучше объявить отдельно: он читается как документация и правится в одном месте.
Порядок в ответе — порядок использования, а не порядок списка: filter идёт по used. Если бы мы фильтровали needsServer, третья строка вывода назвала бы то же самое, но в другом порядке — мелочь, из-за которой такие функции любят возвращать неповторяемый результат.
Опечатка, которую ловит компилятор
Объект стилей приходит из import с индексной сигнатурой, и любая опечатка в ключе проходит молча. Сделайте так, чтобы вторая строка вывода дала диагностику, а первая — нет.
Достаточно назвать ключи один раз — при импорте. Дальше по проекту работает обычная проверка свойств.
Именно as, а не аннотация: объект с индексной сигнатурой компилятор не считает совместимым с типом, где ключи обязательны, — он ведь не знает, что там лежит на самом деле. После приведения диагностика приходит на нужную строку: свойства titel в этом типе нет, и рядом подсказка про похожее имя. Выполнение при этом не отменяется — в консоль всё равно попадёт undefined: типы живут до запуска, а до браузера доезжает обычный JavaScript.
Тот же приём читается и как satisfies из части III, если объект вы задаёте сами, а не получаете из import: const styles = { ... } satisfies Record<Keys, string>.