Стили, сборка и выкладка

проект≈ 15 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

Доска работает, но выглядит черновиком: размеры и рамки вписаны в разметку атрибутом style. До живого адреса остались три шага — разложить стили по файлам рядом с компонентами, собрать проект и решить, что поедет на сервер: папка с файлами или процесс Node. Последнее решается не вкусом, а списком того, что ваш код делает в момент запроса.

Разделы темы:·

Доска из прошлых трёх статей размечена атрибутом style прямо в JSX — так было честно, пока проверялась раскладка. Для настоящих стилей это тупик: ни наведения, ни медиазапроса, ни тёмной темы в инлайновом объекте не выразить.

В части II классы column и card были зацепками для CSS, и уникальность имён держалась на внимании: .card в том проекте был ровно один. Здесь компонентов полтора десятка, и вторая .card появляется сама — вместе с чужой карточкой, которая внезапно перекрасилась.

CSS-модули решают это без дисциплины. Next подключает их без единой строки настройки: файл, имя которого кончается на .module.css, обрабатывается как модуль. Имена классов в нём — локальные, наружу сборщик выпускает уникальные, а импорт отдаёт объект: ключи ваши, значения — итоговые строки.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Никакой магии в разметке при этом нет: 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 у заголовка?

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Ни диагностики компилятора, ни ошибки в браузере: атрибута просто нет. React не ставит class, если значение — undefined, поэтому опечатка в ключе выглядит как «стиль почему-то не применился». Искать такое приходится глазами.

Индексная сигнатура приходит из объявления Next, но её можно сузить один раз — там, где модуль импортируется. Дальше по коду опечатка становится обычной ошибкой типов, знакомой по части III.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Диагностика приходит на последнюю строку и даже подсказывает похожее имя: Свойство "titel" не существует в типе "Record<"card" | "title", string>". Вы имели в виду "title"?. Выполнение при этом идёт дальше и печатает undefined — типы живут до запуска.

Обратите внимание на as: обычная аннотация здесь не проходит. Объект с индексной сигнатурой не назначается типу с обязательными ключами — компилятор скажет, что свойств card и title в нём нет, хотя они там есть. Он не читает файл стилей и верит только объявлению.

Список ключей придётся держать в согласии с файлом стилей руками — зато рассогласование видно в редакторе, а не на живой странице. Для маленького компонента это лишние три строки; для карточки с десятком состояний — дешёвая страховка.

Карточка редко носит один класс: базовый, плюс модификатор состояния, плюс то, что передал родитель. Строка собирается в каждом компоненте, и это самое частое место, где классы тихо теряются. Предскажите обе строки вывода — обе печатаются через JSON.stringify, чтобы были видны пробелы.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

join зовёт String для каждого элемента, и false честно превращается в "false" — в атрибут class уезжает класс, которого нет ни в одном файле стилей. У null и undefined поведение другое, для них join подставляет пустую строку: получается хвостовой пробел и никакого сигнала, что данных не было.

Браузер на такое не жалуется: несуществующий класс — не ошибка. Значит, отсеивать пустые значения должны мы, и лучше в одном месте на весь проект. Функция на две строки закрывает вопрос — библиотека для этого не нужна.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Тип ClassValue здесь важнее реализации: он разрешает передавать результат && — то есть false вместо строки — и запрещает всё остальное. Число или объект в cx компилятор уже не пропустит.

Теперь настоящая карточка доски. Она получает className от родителя, добавляет собственный модификатор и печатает итоговую строку на каждом рендере.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Класс от родителя стоит последним в строке — но на приоритет это не влияет: в CSS побеждает специфичность селектора, а при равной специфичности тот, кто ниже в собранном файле стилей. Порядок классов в атрибуте не значит ничего, и «перебить» чужой класс своим той же специфичности — лотерея. Надёжный способ — не перебивать, а дать компоненту пропс.

Последняя ловушка раздела ловится компилятором, и на неё стоит посмотреть живьём: в className попал весь объект стилей.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Диагностика приходит до запуска, а браузер потом всё равно рисует карточку — с классом [object Object]. Это та же граница, что и во всей части: типы живут до сборки, а до страницы доезжает обычный JavaScript.

className={cx(...)} стоит писать даже там, где класс сейчас один. Второй появится через неделю, и место для него уже будет готово.

Изоляция имён — благо ровно до момента, когда цвет фона нужен всем компонентам сразу. Копировать #f5f5f5 в пятнадцать модулей нельзя: тёмную тему потом не собрать. Выход — разделить труд: имена классов изолируем, значения делаем общими.

Общими их делают CSS-переменные. Сборщик переименовывает только классы; --accent остаётся --accent в любом модуле, потому что это часть каскада, а не имя из вашего файла.

ЧтоГде живётПочему именно там
--bg, --text, --accentapp/globals.css, блок :rootзначение нужно всем — от карточки до подвала
.card { background: var(--bg) }task-card.module.cssимя класса локальное, значение общее
тёмная тема:root[data-theme="dark"] в globals.cssпереключается атрибутом на <html>, компоненты о ней не знают
цвет конкретной колонкиразметка: style с переменнойзначение приходит данными и на сборке неизвестно

Последняя строка таблицы — единственное место, где переменную задают из JavaScript: цвет колонки приходит вместе с данными, и в файле стилей его записать нечем. Перед запуском решите, какой цвет получит заголовок внутри секции.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

В выводе — 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>. Отдельного файла стилей в песочнице нет, поэтому здесь тот же набор задан на обёртке; механизм от этого не меняется — значение всё равно ищется вверх по дереву.

tsx
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Текст стал светлым — 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_, попадает в бандл значением. Предскажите, что станет с обеими строками исходника.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Публичная переменная превратилась в строковый литерал — её больше нет как переменной, поменять её в контейнере невозможно, только пересобрать образ. Серверная осталась обращением к 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: память, перезапуск, обновленияничего: файлы и обычная раздача

Тот же список удобно держать функцией — и заодно проверить на двух настоящих проектах: нашей доске и этом учебнике.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Ответ по доске получен: формы сделаны действиями, значит экспорт отпадает — не «работает хуже», а буквально останавливает сборку. Учебнику, наоборот, процесс не нужен ни для чего: все страницы известны на сборке, а всё живое в нём — песочница и подсветка — выполняется в браузере читателя.

Подробности жизни без процесса — 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/standaloneserver.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.tsoutput: "standalone"output: "export"
что уезжает в образserver.js + .next/static + publicпапка out/
базовый образ рантаймаnode:24-slimcaddy: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. Допишите её.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Фильтр убирает false, null, undefined и пустые строки, а предикат делает тип результата честным — string[] вместо ClassValue[].

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Обратите внимание на третью строку вывода: пустая строка, а не пробел. Именно она попадёт в className, когда у карточки не окажется ни одного модификатора, — и React в этом случае поставит пустой атрибут, а не мусор.

Переживёт ли проект экспорт

Соберите проверку из раздела про выбор: функция получает список того, чем пользуется приложение, и отвечает, хватит ли ему папки out/. Если не хватит — перечисляет, что именно мешает, в порядке использования.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Список того, что требует процесса, лучше объявить отдельно: он читается как документация и правится в одном месте.

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Порядок в ответе — порядок использования, а не порядок списка: filter идёт по used. Если бы мы фильтровали needsServer, третья строка вывода назвала бы то же самое, но в другом порядке — мелочь, из-за которой такие функции любят возвращать неповторяемый результат.

Опечатка, которую ловит компилятор

Объект стилей приходит из import с индексной сигнатурой, и любая опечатка в ключе проходит молча. Сделайте так, чтобы вторая строка вывода дала диагностику, а первая — нет.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Достаточно назвать ключи один раз — при импорте. Дальше по проекту работает обычная проверка свойств.

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Именно as, а не аннотация: объект с индексной сигнатурой компилятор не считает совместимым с типом, где ключи обязательны, — он ведь не знает, что там лежит на самом деле. После приведения диагностика приходит на нужную строку: свойства titel в этом типе нет, и рядом подсказка про похожее имя. Выполнение при этом не отменяется — в консоль всё равно попадёт undefined: типы живут до запуска, а до браузера доезжает обычный JavaScript.

Тот же приём читается и как satisfies из части III, если объект вы задаёте сами, а не получаете из import: const styles = { ... } satisfies Record<Keys, string>.