Интеграция SSR с Express

Server-Side Rendering в связке Vite и Express строится на разделении ответственности: Vite отвечает за сборку клиентского и серверного бандлов, а Express предоставляет HTTP-слой, через который выполняется рендеринг HTML на сервере и доставка статических ресурсов.

Ключевая идея SSR в Vite заключается в том, что один и тот же код приложения используется в двух средах:

  • серверная среда (Node.js через Express)
  • клиентская среда (браузер)

Это требует явного разделения точек входа и аккуратного управления зависимостями, чтобы избежать попадания браузерных API в серверный контекст и наоборот.


Базовая структура SSR-проекта

Типичная структура проекта с Vite SSR и Express выглядит следующим образом:

project/
  src/
    entry-client.js
    entry-server.js
    App.vue / App.jsx / App.tsx
  server/
    index.js
  vite.config.js
  package.json

Роли файлов

entry-client

  • выполняется в браузере
  • отвечает за гидратацию HTML
  • подключает клиентский роутинг и состояние

entry-server

  • выполняется в Node.js
  • возвращает HTML-строку приложения
  • использует renderToString или аналог

server/index.js

  • Express-сервер
  • обрабатывает запросы
  • вызывает серверный рендеринг
  • интегрируется с Vite в режиме middleware

Настройка Express как SSR-сервера

Express используется как слой обработки HTTP-запросов и точка интеграции Vite в режиме разработки и продакшена.

Базовый сервер

import express from 'express';

const app = express();

app.use(express.static('dist/client'));

app.get('*', async (req, res) => {
  const url = req.originalUrl;

  const html = await renderApp(url);

  res.status(200).set({ 'Content-Type': 'text/html' }).end(html);
});

app.listen(3000);

На этом уровне Express просто делегирует рендеринг функции renderApp, которая будет подключена к Vite SSR API.


Интеграция Vite в middleware mode

В режиме разработки Vite запускается как middleware внутри Express. Это позволяет:

  • горячую замену модулей (HMR)
  • трансформацию SSR-модулей на лету
  • единый сервер для API и фронтенда

Подключение Vite к Express

import express from 'express';
import { createServer as createViteServer } from 'vite';

async function startServer() {
  const app = express();

  const vite = await createViteServer({
    server: { middlewareMode: true },
    appType: 'custom'
  });

  app.use(vite.middlewares);

  app.use('*', async (req, res) => {
    const url = req.originalUrl;

    let template = await vite.transformIndexHtml(
      url,
      '<html><body><div id="app"></div></body></html>'
    );

    const { render } = await vite.ssrLoadModule('/src/entry-server.js');

    const appHtml = await render(url);

    const html = template.replace('<!--app-->', appHtml);

    res.status(200).set({ 'Content-Type': 'text/html' }).end(html);
  });

  app.listen(3000);
}

startServer();

Разделение клиентской и серверной сборки

Vite SSR требует два независимых бандла:

Клиентский бандл

  • используется браузером
  • содержит гидратацию
  • подключает DOM-API

Серверный бандл

  • выполняется в Node.js
  • не содержит DOM
  • экспортирует функцию рендеринга

Конфигурация Vite для SSR

import { defineConfig } from 'vite';

export default defineConfig({
  build: {
    ssrManifest: true,
    outDir: 'dist/client'
  },
  ssr: {
    noExternal: []
  }
});

Для серверной сборки:

export default defineConfig({
  build: {
    ssr: 'src/entry-server.js',
    outDir: 'dist/server'
  }
});

Серверная точка входа (entry-server)

Файл entry-server.js отвечает за генерацию HTML на основе URL.

import { createApp } from './app';

export async function render(url) {
  const { app, router } = createApp();

  router.push(url);
  await router.isReady();

  const html = await renderToString(app);

  return html;
}

Особенности:

  • отсутствует доступ к window и document
  • вся логика маршрутизации должна быть изолирована
  • состояние должно сериализоваться для клиента

Клиентская точка входа (entry-client)

import { createApp } from './app';

const { app, router } = createApp();

router.isReady().then(() => {
  app.mount('#app');
});

Гидратация

Клиент не перерисовывает DOM, а «подхватывает» уже существующий HTML.


Связка Express и SSR-рендерера

Ключевой момент интеграции — функция, которая объединяет:

  • шаблон HTML
  • серверный рендеринг
  • клиентские скрипты
async function renderApp(url, vite) {
  const template = await vite.transformIndexHtml(
    url,
    await fs.readFile('./index.html', 'utf-8')
  );

  const { render } = await vite.ssrLoadModule('/src/entry-server.js');

  const appHtml = await render(url);

  return template.replace('<!--app-->', appHtml);
}

Работа с index.html как шаблоном

Vite использует index.html как часть сборочного процесса:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8" />
    <title>SSR App</title>
  </head>
  <body>
    <div id="app"><!--app--></div>
    <script type="module" src="/src/entry-client.js"></script>
  </body>
</html>

Метка <!--app--> служит точкой вставки SSR-HTML.


Различия dev и production режима

Development

  • Vite работает как middleware
  • модули загружаются динамически
  • SSR выполняется через ssrLoadModule
  • HMR поддерживается автоматически

Production

  • используются предварительно собранные бандлы
  • Express загружает dist/server/entry-server.js
  • HTML собирается без Vite middleware

Продакшен-сервер на Express

import express from 'express';
import fs from 'fs';

const app = express();

const template = fs.readFileSync('./dist/client/index.html', 'utf-8');
const { render } = await import('./dist/server/entry-server.js');

app.use('/assets', express.static('./dist/client/assets'));

app.get('*', async (req, res) => {
  const url = req.originalUrl;

  const appHtml = await render(url);

  const html = template.replace('<!--app-->', appHtml);

  res.status(200).setHeader('Content-Type', 'text/html').end(html);
});

app.listen(3000);

Передача состояния от сервера к клиенту

SSR требует синхронизации состояния между сервером и браузером.

Сериализация состояния

const state = {
  user: { name: 'John' }
};

const html = `
<script>
  window.__INITIAL_STATE__ = ${JSON.stringify(state)}
</script>
`;

Использование на клиенте

const state = window.__INITIAL_STATE__;

Важно избегать утечек чувствительных данных при сериализации.


Маршрутизация в SSR

Роутер должен работать одинаково в серверной и клиентской среде.

Основные требования:

  • детерминированное поведение
  • отсутствие browser-only API на сервере
  • синхронизация URL

Пример:

router.push(url);
await router.isReady();

Обработка ошибок SSR

Ошибки рендеринга должны возвращать корректный HTTP-статус.

app.get('*', async (req, res) => {
  try {
    const html = await renderApp(req.url);
    res.status(200).end(html);
  } catch (err) {
    res.status(500).end('Server Error');
  }
});

В продакшене важно логировать стек ошибок отдельно от ответа пользователю.


Оптимизация SSR с Vite и Express

Code splitting

Vite автоматически разбивает клиентский бандл, снижая время загрузки.

Prefetch ресурсов

SSR может добавлять preload-скрипты в HTML:

<link rel="modulepreload" href="/assets/entry-client.js">

Минимизация серверного бандла

Серверный код должен быть максимально лёгким:

  • исключение тяжёлых зависимостей
  • использование ESM
  • контроль ssr.noExternal

Работа с внешними зависимостями

Некоторые библиотеки нельзя бандлить в SSR:

export default defineConfig({
  ssr: {
    external: ['some-native-lib']
  }
});

Это важно для:

  • нативных Node.js модулей
  • библиотек с доступом к window
  • legacy CJS пакетов

Middleware архитектура Express SSR

Express позволяет расширять SSR пайплайн:

app.use(authMiddleware);
app.use(i18nMiddleware);
app.use(ssrHandler);

SSR-слой становится последним звеном цепочки, получая уже подготовленный контекст запроса.


Контекст запроса в SSR

Часто SSR требует передачи request-specific данных:

const render = (url, context) => {
  return renderToString(app, {
    url,
    context
  });
};

Контекст может включать:

  • cookies
  • заголовки
  • user session
  • locale

Стриминговый SSR (Streaming SSR)

Vite может использоваться с потоковым рендерингом:

import { renderToNodeStream } from 'vue/server-renderer';

const stream = renderToNodeStream(app);

stream.pipe(res);

Преимущества:

  • более быстрый first byte
  • постепенная отрисовка HTML
  • улучшение TTFB

Разделение ответственности между Vite и Express

Vite:

  • трансформация модулей
  • сборка бандлов
  • SSR-модуль загрузка
  • HMR

Express:

  • маршрутизация HTTP
  • управление запросами
  • авторизация
  • интеграция SSR результата в ответ

Архитектура строится вокруг идеи, что Vite не является сервером приложений, а Express не занимается сборкой кода.