Простой подход к SSR с React 19 и esbuild
Данная статья описывает создание простого, гибкого React-приложения с минимальным количеством инструментов и фреймворков, используя последние возможности React. Хотя Next.js и Remix отлично подходят для многих задач, это руководство ориентировано на разработчиков, желающих иметь полный контроль над процессом, сохраняя при этом функциональность, аналогичную этим фреймворкам.
В связи с выходом React 19 и новых функций поддержки SSR, был проведен эксперимент по созданию приложения с SSR, используя минимальный набор инструментов. Прежде чем начать, рассмотрим ключевые возможности Next.js, которые нужно учитывать:
Особенность | Next.js |
SSR (серверный рендеринг) | Встроенный, с минимальной настройкой. |
SSG (Статическая генерация сайта) | Встроенный с getStaticProps. |
Маршрутизация | Маршрутизация на основе файлов. |
Разделение кода | Автоматический. |
Оптимизация изображения | Встроенный с next/image. |
Оптимизация производительности | Автоматически (например, предварительная загрузка, критический CSS). |
SEO | Встроенные инструменты, такие как next/head. |
На основе этих требований будет создана минимальная конфигурация; ниже представлено пошаговое руководство:
Примечание: полный код этого руководства доступен в репозитории https://github.com/willyelm/react-app
Настройка среды
Для начала необходимо установить Node.js и следующие пакеты:
- react: библиотека для создания пользовательских интерфейсов на основе компонентов.
- react-dom: отвечает за восстановление содержимого SSR с использованием возможностей React.
- react-router-dom: обработка маршрутизации в React-приложениях.
- express: легковесный веб-сервер Node.js для статических файлов и REST API.
- esbuild: инструмент для транспиляции TypeScript и объединения файлов JS, CSS, SVG и других ресурсов.
- typescript: добавление статической типизации в код.
В данном приложении маршрутизация осуществляется с помощью express для обслуживания статических файлов, публичных ресурсов и REST API. Обработка запросов выполняется react-router-dom. После загрузки в браузере, клиентский пакет гидратирует предварительно рендеренный контент, делая компоненты интерактивными. Схема работы представлена ниже:
Схема работы приложения демонстрирует обработку запросов сервером Express: предварительная визуализация компонентов React на сервере и отправка HTML клиенту. На стороне клиента React «гидратирует» этот предварительно отрисованный контент, обеспечивая интерактивность.
С учетом этой схемы, структура проекта будет следующей:
react-app/ # This will be our workspace directory.
- public/
- scripts/
- build.js # Bundle our server and client scripts.
- config.js # esbuild config to bundle.
- dev.js # Bundle on watch mode and run server.
- src/
- App/ # Our components will be here.
- App.tsx # The main application with browser routing.
- Home.tsx. # Our default page component.
- NotFound.tsx # Fallback page for unmatched routes.
- index.tsx # Hydrate our pre-rendered client app.
- main.tsx # Server app with SSR components.
- style.css # Initial stylesheet.
package.json
tsconfig.json
Далее, добавим зависимости и настроим package.json
:
{
"name": "react-app",
"type": "module",
"devDependencies": {
"@types/express": "^5.0.0",
"@types/node": "^22.10.2",
"@types/react": "^19.0.2",
"@types/react-dom": "^19.0.2",
"esbuild": "^0.24.2",
"typescript": "^5.7.2"
},
"dependencies": {
"express": "^4.21.2",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.0"
},
"scripts": {
"build": "node scripts/build",
"dev": "node scripts/dev",
"start": "node dist/main.js"
}
}
Примечание: свойство "type": "module"
необходимо для запуска ESM-скриптов Node.js.
Для работы с TypeScript, настроим tsconfig.json
:
{
"compilerOptions": {
"esModuleInterop": true,
"verbatimModuleSyntax": true,
"noEmit": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"strict": true,
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"baseUrl": ".",
"paths": {
"src": [
"./src/"
]
}
},
"include": [
"src"
],
"exclude": [
"node_modules"
]
}
Настройка esbuild
Выбор esbuild обусловлен его минимализмом, высокой скоростью (на сегодняшний день — самый быстрый бандлер) и встроенной поддержкой TypeScript и ESM.
В данном проекте esbuild используется для создания скриптов разработки и сборки, а также для транспиляции клиентского и серверного кода. В этом разделе мы будем работать с папкой scripts
.
scripts/config.js
: этот файл содержит базовую конфигурацию для клиентского и серверного кода, используемую нашими скриптами.
import path from 'node:path';
// Working dir
const workspace = process.cwd();
// Server bundle configuration
export const serverConfig = {
bundle: true,
platform: 'node',
format: 'esm', // Support esm packages
packages: 'external', // Omit node packages from our node bundle
logLevel: 'error',
sourcemap: 'external',
entryPoints: {
main: path.join(workspace, 'src', 'main.tsx') // Express app
},
tsconfig: path.join(workspace, 'tsconfig.json'),
outdir: path.join(workspace, 'dist')
};
// Client bundle configuration
export const clientConfig = {
bundle: true,
platform: 'browser',
format: 'esm',
sourcemap: 'external',
logLevel: 'error',
tsconfig: path.join(workspace, 'tsconfig.json'),
entryPoints: {
index: path.join(workspace, 'src', 'index.tsx'), // Client react app
style: path.join(workspace, 'src', 'style.css') // Stylesheet
},
outdir: path.join(workspace, 'dist', 'static'), // Served as /static by express
};
scripts/dev.js
: этот скрипт объединяет клиентское и серверное приложения и запускает основной серверный скрипт в режиме наблюдения.
import { spawn } from 'node:child_process';
import path from 'node:path';
import { context } from 'esbuild';
import { serverConfig, clientConfig } from './config.js';
// Working dir
const workspace = process.cwd();
// Dev process
async function dev() {
// Build server in watch mode
const serverContext = await context(serverConfig);
serverContext.watch();
// Build client in watch mode
const clientContext = await context(clientConfig);
clientContext.watch();
// Run server
const childProcess = spawn('node', [
'--watch',
path.join(workspace, 'dist', 'main.js')
], {
stdio: 'inherit'
});
// Kill child process on program interruption
process.on('SIGINT', () => {
if (childProcess) {
childProcess.kill();
}
process.exit(0);
});
}
// Start the dev process
dev();
Запуск осуществляется командой npm run dev
, как определено в package.json
.
scripts/build.js
: аналогичен dev.js
, но включает минификацию.
import { build } from 'esbuild';
import { clientConfig, serverConfig } from './config.js';
// build process
async function bundle() {
// Build server
await build({
...serverConfig,
minify: true
});
// Build client
await build({
...clientConfig,
minify: true
});
}
// Start the build process
bundle();
Этот скрипт создаёт готовый к продакшену пакет dist
, запускаемый командами npm run build
и npm start
.
После настройки esbuild для объединения клиентского и серверного кода, перейдём к созданию сервера Express и реализации React SSR.
Сервер Express и маршрутизация
Простое приложение использует Express для обслуживания статических файлов, обработки серверных маршрутов и маршрутизации с помощью react-router-dom
.
src/main.tsx
: основное приложение Node.js, инициализирующее сервер, обрабатывающее маршруты с помощью Express и реализующее React SSR.
import path from 'node:path';
import express, { type Request, type Response } from 'express';
import { renderToPipeableStream } from 'react-dom/server';
import { StaticRouter } from 'react-router';
import { App } from './App/App';
const app = express(); // Create Express App
const port = 3000; // Port to listen
const workspace = process.cwd(); // workspace
// Serve static files like js bundles and css files
app.use('/static', express.static(path.join(workspace, 'dist', 'static')));
// Server files from the /public folder
app.use(express.static(path.join(workspace, 'public')));
// Fallback to render the SSR react app
app.use((request: Request, response: Response) => {
// React SSR rendering as a stream
const { pipe } = renderToPipeableStream(
<html lang="en">
<head>
<meta charSet="UTF-8" />
<link rel='stylesheet' href={`/static/style.css`} />
</head>
<body>
<base href="/" />
<div id="app">
<StaticRouter location={request.url}>
<App />
</StaticRouter>
</div>
</body>
</html>,
{
bootstrapModules: [ `/static/index.js` ], // Load script as modules
onShellReady() {
response.setHeader('content-type', 'text/html');
pipe(response);
}
});
});
// Start server
app.listen(port, () => {
console.log(`[app] listening on port ${port}`)
});
src/index.tsx
: на стороне клиента, для активации и обеспечения интерактивности компонентов, необходима «гидратация».
import { hydrateRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router';
import { App } from './App/App';
// Hydrate pre-renderer #app element
hydrateRoot(
document.getElementById('app') as HTMLElement,
<BrowserRouter>
<App />
</BrowserRouter>
);
React-приложение
React-приложение использует react-router-dom
для маршрутизации. Приложение состоит из страниц Home
и NotFound
для проверки гидратации. На странице Home
добавлена кнопка счётчика, а мета-теги title
и description
обновляются с использованием возможностей React 19.
src/App/Home.tsx
: минимальный функциональный компонент.
import { useState, type FunctionComponent } from 'react';
// Home Page Component
export const Home: FunctionComponent = () => {
const [count, setCount] = useState(0);
const update = () => {
setCount((prev) => prev + 1);
}
return <>
<title> App | Home </title>
<meta name='description' content='This is my home page' />
<h1> Home Page </h1>
<p> Counter: {count} </p>
<button onClick={() => update()}>Update</button>
</>;
}
src/App/NotFound.tsx
: функциональный компонент для отображения страницы 404.
import { type FunctionComponent } from 'react';
// Not Found Page Component
export const NotFound: FunctionComponent = () => {
return <>
<title> App | Not Found </title>
<meta name='description' content='Page not found' />
<h1>404</h1>
<p> Page Not Found</p>
</>;
}
src/App/App.tsx
: настройка приложения с использованием react-router-dom
.
import { type FunctionComponent } from 'react';
import { Routes, Route } from 'react-router';
import { Home } from './Home';
import { NotFound } from './NotFound';
// App Component
export const App: FunctionComponent = () => {
return <Routes>
<Route path="/">
<Route index element={<Home />} />
<Route path="*" element={<NotFound />} />
</Route>
</Routes>;
}
Запуск приложения
После настройки скриптов esbuild, сервера Express и реализации React SSR, запустите сервер командой:
npm run dev
В консоли появится сообщение "[app] listening on port 3000"
. Откройте http://localhost:3000 в браузере для проверки.
Проверка метатегов SSR и SEO:
Исходный код доступен в репозитории willyelm/react-app
В этом руководстве создано минималистичное и гибкое приложение React с SSR, используя esbuild и Express. Применение современных инструментов, таких как React 19, React Router DOM 7 и esbuild, обеспечило быстрый и эффективный рабочий процесс без накладных расходов, характерных для крупных фреймворков.