В магазине двенадцать тысяч карточек. Название кроссовок, фотографии, описание, таблица размеров: всё это за ночь почти не меняется. Зато у каждого покупателя своя корзина, свои адреса доставки и свой статус заказа. Если сервер при каждом заходе собирает карточку заново, он тратит силы на то, что можно было сделать один раз заранее. Если же весь сайт заморожен в файлах, корзина становится одинаковой для всех, а это уже не экономия, а авария.

Astro позволяет не выбирать крайность. Часть страниц превращается в готовый HTML на этапе сборки, часть выполняется на сервере в момент запроса. Задача разработчика в том, чтобы провести границу честно: по тому, что известно заранее, а что можно узнать, только когда пришёл конкретный человек. Ниже разберём эту границу на двух маршрутах: общей карточке товара и персональной корзине, затем проследим, как изменение цены доходит до покупателя, и отдельно поговорим о самой неприятной ошибке: когда чужая страница отдаётся не тому человеку.

Где проходит граница в магазине

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

Страницы магазина и момент, когда становится известно их содержимое
СтраницаОт чего зависит содержимоеКогда формировать
Карточка товараДанные каталога, одинаковые для всехПри сборке
Список товаров категорииТе же данные каталога, при условии что пагинация и сортировка заданы адресомПри сборке
ПоискСтрока запроса; индекс общий, а вот запрос приходит только от посетителяЗависит от способа, см. ниже
КорзинаСессия конкретного покупателяВ момент запроса
Вход и выходПароль, cookie, перенаправленияВ момент запроса
Личный кабинет, история заказовУчётная запись и заказыВ момент запроса

Поиск здесь самый интересный случай, потому что единой правильной схемы нет. Если каталог невелик, можно при сборке выгрузить компактный индекс в файл, а фильтрацию выполнять в браузере: сервер вообще не участвует. Для двенадцати тысяч позиций с длинными описаниями такой файл становится тяжёлым, и тогда разумнее поставить серверный endpoint, который получает строку запроса и возвращает короткий список совпадений. Сама страница поиска при этом может остаться статической оболочкой, а результаты подтягиваются отдельным запросом. Серверная часть сводится к одному маленькому маршруту, а не к рендеру всего сайта.

Заметьте, что в таблице нет ни одной строки вида «страница, которая немного персональная». Это не случайно. Самый частый источник ошибок в смешанных магазинах: карточка товара, в шапке которой написано «Здравствуйте, Анна» или показано число товаров в корзине. Стоит добавить такую мелочь, и карточка перестаёт быть общей. К этой ловушке мы вернёмся, когда дойдём до кеширования.

Что знает сборка и чего она знать не может

При сборке у Astro есть всё, что можно получить без посетителя: файлы проекта, ответ базы или CMS, переменные окружения этапа сборки. Нет у неё одного: запроса. Нет заголовка Cookie, нет адреса клиента, нет строки поиска, нет тела формы. Поэтому любая страница, чей результат зависит от этих вещей, не может быть собрана заранее по самому своему устройству. Не нужно бороться с этим ограничением, достаточно его принять.

Чтобы страница вообще выполнялась в момент запроса, проекту нужен адаптер: именно он превращает результат сборки в программу, способную принимать соединения. Для обычного VPS с Node.js это @astrojs/node. Он умеет два режима: standalone и middleware. Standalone поднимает собственный сервер, который обслуживает и страницы, и API-маршруты, и клиентские файлы. Режим middleware встраивается в уже существующее приложение, а раздачу файлов в этом случае нужно организовать отдельно. Для магазина, где Astro и есть основное приложение, проще и надёжнее standalone.

Адаптер задаёт только возможность, а решение для каждой страницы принимает флаг prerender. Документация Astro описывает два способа расставить умолчания. Первый: оставить сборку по умолчанию и пометить серверные страницы строкой export const prerender = false. Второй: поставить output: 'server', после чего по умолчанию всё выполняется на сервере, а статичные страницы отмечаются явно. Выбирают по тому, чего в магазине больше. Здесь каталог составляет почти весь сайт, а личных страниц всего несколько, поэтому исключениями удобнее объявить именно их. Если бы это был закрытый кабинет с парой публичных лендингов, логика была бы обратной.

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

// astro.config.mjs
import { defineConfig } from 'astro/config';
import node from '@astrojs/node';

export default defineConfig({
  adapter: node({ mode: 'standalone' }),
});

Обратите внимание, чего в конфигурации нет: переключателя «весь сайт на сервере». Без него страницы остаются предварительно собранными, пока вы явно не сказали обратное. Это хороший порядок: ошибка «забыл пометить» приводит к тому, что личная страница не заработает вовсе (и это будет сразу видно), а не к тому, что каталог внезапно замедлится.

Если вы ещё не поднимали Astro на сервере и вам нужна установка с нуля, она подробно разобрана в статье Как установить и настроить Astro на VPS. Здесь мы считаем, что проект уже собирается, и занимаемся только границей между страницами.

Общий маршрут: карточка товара

Возьмём один адрес: /catalog/krossovki-trail-42. Файл маршрута называется src/pages/catalog/[slug].astro. Поскольку страница статическая, Astro должен заранее знать, какие значения slug существуют. Это сообщает функция getStaticPaths.

---
// src/pages/catalog/[slug].astro
import { getProducts } from '../../lib/products';

export async function getStaticPaths() {
  const products = await getProducts();
  return products.map((product) => ({
    params: { slug: product.slug },
    props: { product },
  }));
}

const { product } = Astro.props;
---
<h1>{product.title}</h1>
<p>{product.description}</p>
<p>Цена на момент сборки: {product.price} ₽</p>
<div data-cart-slot></div>

Функция getProducts здесь ваша: она читает файл выгрузки, обращается к CMS или базе данных. Для Astro важно лишь, что вызывается она один раз при сборке, а результат превращается в готовые HTML-файлы по числу товаров. После этого запрос к карточке сводится к отдаче файла с диска. Приложению не нужно ни обращаться к базе, ни выполнять шаблон. Именно поэтому такие страницы выдерживают нагрузку скромного сервера: ограничивает их в основном сеть и диск, а не процессор.

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

Элемент data-cart-slot в конце — место, где браузер после загрузки может подставить персональный фрагмент: кнопку «В корзину», мини-корзину в шапке, отметку «уже в корзине». Фрагмент приходит отдельным запросом к серверному маршруту, а не зашит в карточку. Так карточка остаётся одинаковой для всех посетителей, а персональное появляется поверх.

Персональный маршрут: корзина

Теперь противоположный пример: /cart. Содержимое зависит от сессии, поэтому страница выполняется на сервере в момент запроса.

---
// src/pages/cart.astro
export const prerender = false;

import { getCartBySession } from '../lib/cart';

const sid = Astro.cookies.get('sid')?.value;
if (!sid) {
  return Astro.redirect('/login');
}

Astro.response.headers.set('Cache-Control', 'private, no-store');

const cart = await getCartBySession(sid);
---
<h1>Корзина</h1>
{cart.items.map((item) => (
  <p>{item.title}: {item.qty} × {item.currentPrice} ₽</p>
))}

Строка export const prerender = false исключает страницу из предварительной сборки, и именно для этого адаптер должен быть подключён. Без адаптера Astro просто не сможет выполнить страницу в момент запроса. Тот же приём применяется и к endpoint-ам: файл src/pages/api/cart.ts с такой же строкой станет обработчиком, который принимает добавление товара и отвечает JSON-ом.

Теперь представьте путь запроса. Браузер Анны присылает cookie с идентификатором сессии. Node-процесс находит по нему корзину, подставляет в шаблон текущие цены и отправляет готовый HTML. Браузер Бориса присылает другой идентификатор, и тот же код выдаёт другую страницу. Один файл, но множество разных ответов: именно этим серверный маршрут и отличается от статического, где на любой запрос отдаётся один и тот же результат.

Заголовок Cache-Control: private, no-store нужен не для красоты. Он сообщает любому промежуточному кешу — прокси, CDN, браузерному общему хранилищу, что ответ предназначен одному человеку и сохранять его нельзя. К этому мы вернёмся отдельно: именно здесь чаще всего случаются неприятности.

Какой процесс обслужит следующий запрос

Есть вопрос, который в разговорах о SSR часто упускают: когда посетитель делает второй запрос, какой именно процесс его получит и что этот процесс помнит о первом? Для статики вопрос не имеет смысла, потому что файл одинаков для всех. Для корзины от ответа зависит, увидит ли Анна свои три пары кроссовок или пустую страницу.

Node-адаптер по умолчанию хранит сессии в файлах. Пока на сервере работает один процесс, всё просто: первый запрос записал файл сессии, второй его прочитал. Идентификатор в cookie указывает на файл, файл лежит на диске той же машины. Состояние переживёт даже перезапуск процесса, если каталог с файлами не стёрт при выкладке новой версии. Поэтому стоит заранее выяснить, где именно адаптер держит эти файлы в вашей установке, и убедиться, что скрипт обновления не очищает этот каталог вместе со сборкой. Иначе после каждого релиза все покупатели «потеряют» корзины.

Картина меняется, как только процессов становится больше одного. Если за балансировщиком работают два сервера и у каждого собственный диск, то второй запрос Анны может попасть на машину, где её файла нет. Если на одной машине запущено несколько процессов с общим каталогом, дело обстоит лучше, но тогда нужно думать о том, как они обращаются к одним и тем же файлам. Практический вывод: пока магазин живёт на одном VPS с одним процессом, файловое хранилище сессий — разумная отправная точка. Как только вы собрались масштабироваться горизонтально, место хранения сессий нужно вынести в общее для всех процессов, и это придётся запланировать до, а не после первого сбоя.

То же рассуждение применимо к корзине и вообще к любым изменяемым данным: серверный процесс не должен хранить их в обычной переменной в памяти. Переменная живёт, пока жив процесс, и видна только ему. Перезапуск — и данных нет, второй процесс — и они ему недоступны.

Ещё одна деталь, на которую наступают при первом выкладывании: адаптер сам не загружает переменные окружения времени выполнения. Если файл .env лежит рядом с проектом, это не значит, что запущенный Node-процесс его прочитает. На VPS самый естественный способ — передать значения через менеджер процессов. Например, в юните systemd можно указать EnvironmentFile с секретами базы данных и ключами. Тогда секреты лежат в одном месте с ограниченными правами, а не в репозитории и не в собранных файлах.

# фрагмент юнита systemd
[Service]
WorkingDirectory=/srv/shop
EnvironmentFile=/etc/shop/runtime.env
Environment=HOST=127.0.0.1
Environment=PORT=4321
ExecStart=/usr/bin/node ./dist/server/entry.mjs
Restart=on-failure

Адрес 127.0.0.1 здесь выбран намеренно: Node-процесс слушает только локальный интерфейс, а наружу его выставляет обратный прокси (nginx или Caddy). Статические карточки при этом прокси может отдавать и сам, напрямую с диска, а на Node отправлять только маршруты /cart, /login, /account и /api/. Так серверный процесс вообще не видит основной поток запросов к каталогу, а значит, и всплеск посещаемости на карточках не мешает человеку, который в этот момент оформляет заказ. Режим standalone умеет раздавать клиентские файлы сам, поэтому такая схема необязательна: она просто разгружает Node. Если выбрать middleware, раздачу файлов придётся организовывать самим, и тогда прокси или встроенный сервер становятся обязательной частью схемы.

Адаптер не заменяет систему авторизации. Он лишь исполняет ваш код в момент запроса. Проверка пароля, хеширование, защита от подбора, срок жизни сессии, сброс доступа: всё это нужно реализовать самостоятельно или взять готовую библиотеку либо внешний сервис. Серверное выполнение даёт место, где такой код может работать; сам код остаётся вашей ответственностью. И так же, как адаптер не делает вход, он не делает магазин: цены, остатки, оплата и заказы вы собираете сами.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации Россия, США, Франция и Великобритания. Оплата картой РФ и по СБП.

АРЕНДОВАТЬ СЕРВЕР

Как правка товара доходит до покупателя

Теперь проследим одно изменение целиком. Допустим, менеджер в CMS поменял цену на кроссовки с 7 990 до 6 490 рублей. Что произойдёт дальше?

  1. Данные изменились в источнике. Новая цена лежит в CMS или в базе. Опубликованный сайт про это ещё не знает: готовые HTML-файлы по-прежнему содержат 7 990.
  2. Запущена сборка. Функция getStaticPaths заново вызывает getProducts, получает свежие данные и пересоздаёт файлы карточек. Если сборка запускается по расписанию или по сигналу из CMS, задержка — это время до ближайшего запуска плюс продолжительность самой сборки.
  3. Новая версия выложена. Готовые файлы и серверная часть заменяют прежние. Серверный процесс при этом обычно перезапускают, чтобы он начал использовать новый код.
  4. Покупатель видит новую цену. Браузеры получают обновлённую карточку. Если у браузера или прокси остался кеш предыдущей версии, посетитель может увидеть старую цену до истечения срока.

Для иллюстрации возьмём условный сценарий: каталог на 12 000 карточек, а прайс-лист обновляется четыре раза в сутки. Тогда достаточно четырёх сборок в день, и при каждой пересоздаются все страницы, даже те, что не менялись. Если сборка занимает, скажем, несколько минут, это почти не заметно, а вот если несколько десятков, то задержка между правкой и появлением цены на сайте становится заметной для менеджеров. Реальное время сборки зависит от вашего проекта, количества данных и мощности машины, поэтому его нужно замерить на собственном проекте, а не брать из примеров.

Здесь и проявляется смысл разделения. Цена на карточке в магазине — витрина, и небольшое отставание допустимо. Цена в корзине — основа денег, и расходиться с реальностью она не вправе. Поэтому корзина (серверный маршрут) каждый раз перечитывает цену из актуального источника, а не из того, что показывала карточка. Покупатель, положивший товар до обновления прайса, увидит в корзине новую цену, и это правильное поведение, а не сбой.

Скорость обновления можно улучшать, но осознанно. Если у магазина часто меняются остатки, выносить их в статическую карточку бессмысленно: она устареет раньше, чем успеет собраться. Лучше показывать на карточке только то, что меняется редко, а остаток («в наличии», «осталось 2») подтягивать малым запросом к серверному endpoint-у. Тогда пересборка нужна при смене описания и фотографий, а оперативные величины живут отдельно.

Почему чужая корзина может оказаться в общем кеше

Самая опасная ошибка в смешанной схеме выглядит безобидно. Между посетителем и приложением почти всегда стоит кеш: у вас он может быть в nginx, у провайдера, в CDN. Его задача — запомнить ответ на адрес и отдавать его следующим посетителям, не беспокоя приложение. Для карточки каталога это именно то, что нужно. Для корзины это катастрофа: если кеш сохранил страницу /cart, которую только что получила Анна, то на следующий запрос по тому же адресу Борис получит её корзину, её имя и адрес доставки.

Кеш не умеет понимать, что страница личная, если ему об этом не сказать. Он ориентируется на адрес и заголовки. Поэтому защита строится в нескольких слоях.

  • Заголовок ответа. Персональные маршруты отправляют Cache-Control: private, no-store. Это сообщение для всех кешей по дороге: ответ сохранять нельзя. Задавайте его там, где формируете ответ, как в примере с корзиной, и делайте это для каждой личной страницы и каждого API-маршрута, возвращающего личные данные.
  • Правила прокси. Если вы настроили кеширование в nginx, исключите из него личные пути явно: список путей, где кеш отключён, должен быть написан сознательно и храниться вместе с конфигурацией сайта. Правило «кешировать всё, кроме…» опаснее правила «кешировать только каталог».
  • Cookie. Ответ, который устанавливает cookie сессии, не должен попадать в общий кеш. Иначе чужой посетитель получит вместе со страницей и чужой идентификатор сессии, что хуже, чем просто чужие данные.

Теперь вернёмся к ловушке из начала. Допустим, в шапке карточки появилось «Здравствуйте, Анна». Если карточка собрана заранее, имени взять неоткуда: при сборке нет ни cookie, ни Анны. Так что ловушка тут срабатывает в другую сторону — разработчик видит, что имя не подставляется, и в раздражении переводит страницу в серверную. И вот тогда у него двенадцать тысяч страниц, которые строятся на каждый запрос, причём в шапке персональные данные, а в кеше прокси та же карточка, кешированная «для скорости». Именно такая цепочка и приводит к тому, что Борис видит приветствие для Анны.

Спокойный путь другой: карточка остаётся статической и общей, а имя и мини-корзина загружаются отдельным запросом к маленькому endpoint-у. Тот помечен prerender = false, зависит от cookie и возвращает private, no-store. Если такой запрос вдруг не удастся, пострадает только небольшой фрагмент, а не вся страница, и посетитель всё равно увидит товар и сможет прочитать описание.

Проверять это нужно руками, а не надеяться на конфигурацию. Откройте корзину в одном браузере, затем в другом, в режиме инкогнито: у второго не должно быть ни ваших товаров, ни приветствия. Затем посмотрите заголовки ответа на личных маршрутах: запросом curl -I с cookie и без неё. И убедитесь, что без cookie маршрут корзины отправляет посетителя на вход, а не показывает пустой макет с чужими остатками в кеше. После каждого изменения в конфигурации прокси эти проверки стоит повторять: они занимают пять минут и страхуют от одной из самых неприятных ошибок.

Что при этом нужно от сервера

Смешанная схема хороша тем, что нагрузка на VPS распределяется неравномерно, и это можно использовать. Статические карточки — это файлы: их отдают быстро, и диск NVMe здесь пригождается больше процессора. Серверный Node-процесс занят только личными страницами и API, то есть малой долей запросов. Основной вопрос ресурсов часто касается не работы сайта, а его сборки: пока генерируются тысячи страниц, процессору и памяти приходится тяжелее, чем при обычной работе сайта.

У MAATRIX на VPS указаны NVMe-накопители, процессоры AMD EPYC и root-доступ, а тарифы идут от Ba (1 vCPU, 2 ГБ RAM, 40 ГБ NVMe) через Uab (2 vCPU, 4 ГБ, 80 ГБ) и Heka (4 vCPU, 8 ГБ, 160 ГБ) до Maat, Djed и выше. Эти цифры описывают состав тарифа, а не обещание, сколько покупателей он выдержит: ёмкость зависит от вашего каталога, размера изображений, кода личных маршрутов и характера нагрузки. Поэтому подход один: развернуть, собрать, замерить. Посмотрите, сколько времени и памяти занимает сборка вашего каталога, и сколько процессор занят в обычные часы при работе корзины. Если сборка упирается в память, её можно перенести на другую машину или в CI и выкладывать на сервер готовый результат, а VPS оставить только для обслуживания запросов.

Root-доступ в этой схеме нужен по вполне приземлённой причине: чтобы установить Node.js, создать юнит systemd, настроить обратный прокси и аккуратно задать права на файл с секретами. Все эти шаги выполняются один раз и затем лежат в конфигурации, которую можно воспроизвести. Сервер создаётся автоматически через пару минут после оплаты, поэтому проверить схему на отдельной машине, не трогая работающий магазин, довольно просто. А вот точный выбор операционной системы и доступность образов в конкретной локации лучше уточнять в форме заказа.

Карта вашего магазина

Возьмите лист бумаги и выпишите все адреса, которые есть или будут у магазина. Рядом с каждым ответьте на три вопроса.

  • Чьи данные на этой странице? Если они общие, для карточки, категории, статьи, страницы доставки, сборка заранее подходит. Если есть хоть одна личная строка, страница выходит из этого круга или личное нужно вынести в отдельный запрос.
  • Как быстро должны обновляться данные? Описание можно пересобирать несколько раз в день, цену в корзине нужно читать при каждом запросе, остаток лучше подтягивать маленьким endpoint-ом.
  • Что случится, если эту страницу увидит не тот человек? Если ответ «ничего», страницу можно кешировать широко. Если «утечка личных данных», ей нужны prerender = false, заголовок private, no-store и отдельная строка в правилах прокси.

Скорее всего, серверной окажется небольшая часть карты: корзина, вход и выход, кабинет, оформление заказа, несколько endpoint-ов и, возможно, поиск. Всё остальное — десятки и тысячи страниц каталога — остаётся обычными файлами. Тогда один Node-процесс с файловым хранилищем сессий закрывает личную часть, nginx раздаёт каталог, а следующий запрос каждого посетителя попадает туда, где для него есть нужные данные: к готовой странице или к процессу, который помнит его сессию. Когда карта нарисована, настройки Astro превращаются в запись того, что вы уже решили, а не в набор переключателей, которые нужно подбирать наугад.