Макар Кузьмичев
Назад

Как Макар собственный SSR делал

История о том, как я собственный SSR делал

С чего все началось

Сидел я за столом, прямо попой на стуле, и тут понял, что уж больно часто меня спрашивают: "Макар, на дворе 2025, вышел react 19, расскажи, а как заиспользовать react server components?". "Так берешь какой-нибудь next.js, да используешь." "Не-е, хочу прямо чтобы с нуля - взял и пользуешься." Ну ШТОШ... Вы спрашивали, я рассказываю))

Что такое SSR

Как известно, раньше было лучше: PHP, темплейты и сгенерированный html...Это и было SSR - генерация html-изначального на сервере. Потом стали модными CSR-приложения: сервер отдавал только минимальный html, а все остальное брал на себя клиентский javaScript. Приложения стали более отзывчивыми, перестали появляться перезагрузки страниц при переходах на разные роуты, но появились проблемы с SEO. К тому же оказалось, что ждать загрузки javaScript, и только после этого иметь возможность показать хоть что-то на экране - тоже не самое лучшее решение (особенно если пользователь вообще выключил JS). И вот опять изобрели генерацию html на сервере, и назвали это все SSR - server side rendering. Итак, хватит истории, погнали писать код.

Собственно, начнём!

Во-первых, нужно написать сервер, который сумеет работать с реактовскими серверными компонентами, собирать html и отдавать его клиенту. Во-вторых, нужен будет клиент, который сможет полученные данные обработать и суметь "оживить".
В-третьих, будет нужна сборка всего это добра

server.js

Собственно что должен делать сервер? Отдавать нам html. Как это сделать? Идем в документацию реакта, видим, что существует несколько методов, для генерации html:

В итоге выбирай сам, что хочешь использовать. Если у тебя просто статика, то используй renderToStaticMarkup и renderToString, если что-то посложнее, поинтерактивнее - используй renderToPipeableStream и renderToReadableStream.

Набросаем небольшой проектик с серверными компонентами:

// src/components/BlogPage.jsx
import React from 'react';

export default function BlogPage({ articles }) {
	return (
		<div>
			<h1>Мой блог</h1>
			<ul>
				{articles.map(article => (
					<li key={article.id}>
						<a href={`/article/${article.id}`}>{article.title}</a> — {article.excerpt}
					</li>
				))}
			</ul>
		</div>
	);
}

// src/components/SSRBlogPage.jsx
import React, { Suspense } from 'react';
import BlogPage from './BlogPage';

import { readFile, readdir } from 'fs/promises';
import path from 'path';

export default async function SSRBlogPage() {
	// Загружаем статьи из папки data/articles
	const articlesDir = path.join(process.cwd(), 'data/articles');
	const files = await readdir(articlesDir);
	const articles = [];

	for (const file of files) {
		if (file.endsWith('.json')) {
			const content = await readFile(path.join(articlesDir, file), 'utf-8');
			articles.push(JSON.parse(content));
		}
	}

	articles.sort((a, b) => new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime());

	return (
		<div className="optimized-blog">
			<Suspense fallback={<div>Loading...</div>}>
				<BlogPage articles={articles} />
			</Suspense>
			{/* Предварительное подключение к внешним ресурсам */}
			<link rel="preconnect" href="https://fonts.googleapis.com" />
		</div>
	);
}

Далее, берем express и пишем наш сервер:

import express from 'express';
import path from 'path';
import React from 'react';
import { renderToPipeableStream } from 'react-dom/server';

import SSRBlogPage from '../components/SSRBlogPage';

const app = express();
const PORT = process.env.PORT || 3000;

app.use('/static', express.static(path.join(__dirname, '../../dist/client')));

app.use((req, res, next) => {
  res.setHeader('Content-Type', 'text/html; charset=utf-8');
  next();
});

// Главная страница (SSR)
app.get('/', async (req, res) => {
  try {
    const stream = renderToPipeableStream(React.createElement(SSRBlogPage), {
      bootstrapScripts: ['/static/bundle.js'],
      onShellReady() {
        stream.pipe(res);
      },
      onError(error) {
        console.error(error);
        res.status(500).send('Ошибка сервера');
      },
    });
  } catch (e) {
    console.error('ERROR: ', e);
    res.status(500).send('Ошибка сервера');
  }
});

app.listen(PORT, () => {
  console.log(`Сервер запущен на http://localhost:${PORT}`);
});

webpack.config.cjs

Теперь нужно собрать все, чтобы оно могло работать:

const path = require('path');
const nodeExternals = require('webpack-node-externals');

const serverConfig = {
	mode: 'development',
	target: 'node',
	entry: './src/server/index.js',
	output: {
		path: path.resolve(__dirname, 'build-server'),
		filename: 'server.bundle.js',
		libraryTarget: 'commonjs2',
	},
	externals: [nodeExternals()],
	module: {
		rules: [
			{
				test: /\.(js|jsx)$/,
				exclude: /node_modules/,
				use: 'babel-loader',
			},
		],
	},
	resolve: {
	extensions: ['.js', '.jsx'],
	}
};

module.exports = () => {
	return [serverConfig];
};

В итоге, после запуска

webpack --config webpack.config.cjs

и

nodemon --watch src build-server/server.bundle.js

получаем запущенный сервер на 3000 порту, с отрендеренными серверными компонентами.

Что дальше

Дальше можно добавить гидрацию на клиенте с помощью hydrateRoot, получить гибридное приложение, получающее лучше от SSR и CRS.

Больше всякого можно найти тут:Канал в telegram