Как Макар собственный 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
:
renderToPipeableStream
- рендерит React-дерево в pipeable Node.js StreamrenderToReadableStream
- рендерит React-дерево в Readable Web StreamrenderToStaticMarkup
- отдаёт неинтерактивное React-дерево вhtml
строкуrenderToString
- для рендера react компонентов в строку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.