lenec ru

← все посты

Как настроить SSR в Astro 5 с Node-адаптером

13K

Если ты пришёл из Astro 3 или 4, где SSR включался флагом output: 'server' и работал почти из коробки, в пятёрке тебя ждёт пара сюрпризов. Не ломающих, но требующих внимания. Я недавно переводил контент-сайт с генерации на полный SSR ради динамических разделов и личных кабинетов — и собрал заметки по итогам.

Цель этой статьи — собрать рабочую конфигурацию: Astro 5 + @astrojs/node в standalone-режиме, чтобы это поднималось на любом VPS под systemd или в Docker без отдельного Nginx-проксирования файлов.

Что поменялось в Astro 5 по части рендера

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

Я обычно беру второй вариант для проектов, где «динамики» больше, чем посадочных. На контентных сайтах — наоборот, оставляю по умолчанию и помечаю только нужные страницы.

Установка адаптера

Адаптер ставится одной командой:

pnpm astro add node

Команда подтянет пакет, отредактирует astro.config.mjs и предложит выбрать режим. Если ставишь руками:

pnpm add @astrojs/node

И в конфиге:

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

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

Standalone vs middleware

У адаптера два режима. standalone поднимает свой Node-сервер на указанном порту и сам отдаёт статику из dist/client. Это то, что нужно, если ты деплоишь как самостоятельный демон через PM2, systemd или контейнер. Никакой Express-обёртки писать не надо — точкой входа становится dist/server/entry.mjs, а статику адаптер раздаёт сам.

Режим middleware экспортирует обработчик в виде функции и предполагает, что ты сам поднимаешь Express, Fastify или что-то ещё. У меня к этому варианту смешанные чувства: он удобен, когда нужно подключить кастомный API на том же процессе, но в большинстве случаев проще держать API отдельным сервисом и не смешивать.

Переменные окружения и порт

В standalone-режиме адаптер слушает порт из process.env.PORT, по умолчанию 4321. Хост — process.env.HOST или 0.0.0.0. Логика разумная, но заранее это нигде не выпирает в документации. Я первый раз поймал себя на том, что не понимал, почему контейнер не отвечает извне — ловил localhost.

Стартовый скрипт у меня такой:

{
  "scripts": {
    "build": "astro build",
    "start": "HOST=0.0.0.0 PORT=4321 node ./dist/server/entry.mjs"
  }
}

Если используешь свой .env, не забудь, что Astro подхватывает только PUBLIC_* в клиентский бандл. Серверные переменные читаются как обычно через process.env или import.meta.env внутри .astro и .ts-файлов на сервере.

Динамические роуты

В SSR-режиме getStaticPaths при prerender = false не вызывается, и параметры просто прилетают в Astro.params. На странице это выглядит так:

export const prerender = false;

const { slug } = Astro.params;
const post = await db.post.findUnique({ where: { slug } });

if (!post) {
  return new Response(null, { status: 404 });
}

Возврат Response прямо из frontmatter — тот же приём, что в API-роутах. Удобно для редиректов и 404 без обёрток.

API-роуты

Файлы в src/pages/api/*.ts по умолчанию работают как серверные эндпоинты, если в проекте включён SSR. Сигнатура — обычные веб-стандартные Request и Response, никаких прокладок:

import type { APIRoute } from 'astro';

export const POST: APIRoute = async ({ request }) => {
  const data = await request.json();
  return new Response(JSON.stringify({ ok: true, echo: data }), {
    status: 200,
    headers: { 'Content-Type': 'application/json' },
  });
};

Если использовал в Astro 4 экспорт get в нижнем регистре — обнови на GET, в пятёрке только uppercase.

Сессии

В Astro 5 появились встроенные сессии — Astro.session. По умолчанию они off, включаются в конфиге и требуют выбрать драйвер хранилища: память, файловая, Redis, любой unstorage-совместимый. Для прода я беру Redis, для деврана — память.

export default defineConfig({
  output: 'server',
  adapter: node({ mode: 'standalone' }),
  session: {
    driver: 'redis',
    options: { url: process.env.REDIS_URL },
  },
});

Дальше внутри страниц и API-роутов:

const userId = await Astro.session.get('userId');
await Astro.session.set('userId', user.id);

Это снимает старую боль, когда на каждом проекте дописывал свой кастомный middleware с куками и подписями. Теперь — работает из коробки.

Деплой

Самая короткая Dockerfile-обёртка, которую я в итоге держу как шаблон:

FROM node:22-alpine AS build
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile
COPY . .
RUN pnpm build

FROM node:22-alpine
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
COPY package.json .
ENV HOST=0.0.0.0
ENV PORT=4321
EXPOSE 4321
CMD ["node", "./dist/server/entry.mjs"]

В node_modules можно дополнительно прогнать pnpm prune --prod, если хочется экономии на образе.

Что проверить, если что-то не работает

Самый частый случай: страница, которая делает fetch к внутреннему API, ответствует 404. Скорее всего ты дернул хост, который ещё не поднялся (если SSR-страница тянет данные с того же сервера на этапе билда). При prerender = false это не проблема, а вот для гибридных страниц на билде нужно либо переходить на прямой запрос к БД, либо вытаскивать общий слой данных в отдельный модуль.

Второй кейс — отвалившиеся стили или картинки в standalone-режиме. Бывает, если за Nginx стоит правило отдачи статики из старого dist, а адаптер ждёт, что её отдаст он. Либо доверь раздачу адаптеру (проще), либо отключи у адаптера статику и направляй /_astro/* на Nginx — но тогда придётся синхронизировать пути после каждого билда.

Куда копать дальше

Когда базовый SSR заработал, разберись с middleware.ts в корне src. Это место, где ты подменяешь заголовки, делаешь редиректы и собираешь контекст пользователя один раз на запрос. И не забудь включить image-сервис, если активно используешь <Image /> — без этого SSR-картинки могут не оптимизироваться.

В целом переход на пятёрку у меня занял около двух дней на средний проект, из них половина — переписывание точек, где использовался старый output: 'hybrid'. На остальное хватило часов. Документация местами отстаёт, поэтому конфиг я нашёл методом перебора и чтения changelog.

Комментарии 0

  • Будьте первым, кто оставит комментарий.

Войдите, чтобы оставить комментарий.