SSR для Laravel + React: Как настроить серверный рендеринг через Inertia
За последние годы я перевёл на серверный рендеринг больше десятка Inertia-проектов — от каталогов до медиаплатформ. В моих проектах после внедрения SSR LCP обычно снижался с 2,5–4 до 0,8–1,4 секунды. Конкретный результат зависит от сервера, кеширования, изображений, шрифтов и размера клиентского JavaScript-бандла.
Ниже — рабочая настройка SSR для существующих проектов на Laravel 12/13, Inertia 2.x, React 19 и Vite 7. Такой стек особенно хорошо подходит для сложных веб-приложений, где стандартных решений CMS уже недостаточно.
Зачем нужен SSR
Обычный Inertia-проект без SSR возвращает браузеру корневой элемент приложения и данные страницы. Основной React-интерфейс появляется после загрузки и выполнения JavaScript.
Google и Яндекс умеют выполнять JavaScript, но SSR позволяет сразу вернуть содержательную HTML-разметку. Это упрощает обработку страниц поисковыми системами, социальными сетями, Telegram и другими парсерами. (Google for Developers)
При SSR отдельный Node-процесс рендерит React-компоненты в HTML до отправки ответа. После загрузки страницы React выполняет гидрацию и подключает обработчики событий к уже готовой разметке.
Шаг 1. Устанавливаем зависимости
Если Inertia и Ziggy ещё не установлены:
composer require inertiajs/inertia-laravel tightenco/ziggy
npm install @inertiajs/react react react-dom @vitejs/plugin-react
Создаём middleware:
php artisan inertia:middleware
Регистрируем его в bootstrap/app.php:
<?php
use App\Http\Middleware\HandleInertiaRequests;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Middleware;
return Application::configure(basePath: dirname(__DIR__))
->withMiddleware(function (Middleware $middleware): void {
$middleware->web(append: [
HandleInertiaRequests::class,
]);
})
->create();
Корневой шаблон resources/views/app.blade.php:
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<meta charset="utf-8">
<meta
name="viewport"
content="width=device-width, initial-scale=1"
>
@viteReactRefresh
@vite(['resources/js/app.jsx'])
@inertiaHead
</head>
<body>
@inertia
</body>
</html>
Шаг 2. Настраиваем Ziggy
Конфигурацию Ziggy необходимо передавать через Inertia props. Не записывайте route() и данные текущего запроса в global или globalThis: Node-процесс постоянно работает и обслуживает разные запросы.
Открываем app/Http/Middleware/HandleInertiaRequests.php:
<?php
namespace App\Http\Middleware;
use Illuminate\Http\Request;
use Inertia\Middleware;
use Tighten\Ziggy\Ziggy;
class HandleInertiaRequests extends Middleware
{
protected $rootView = 'app';
public function share(Request $request): array
{
return [
...parent::share($request),
'locale' => app()->getLocale(),
'timezone' => config('app.timezone'),
'ziggy' => fn (): array => [
...(new Ziggy())->toArray(),
'location' => $request->fullUrl(),
],
];
}
}
Создаём хук resources/js/hooks/useAppRoute.js:
import { usePage } from '@inertiajs/react'
import { useRoute } from 'ziggy-js'
export function useAppRoute() {
const { ziggy } = usePage().props
return useRoute({
...ziggy,
location: new URL(ziggy.location),
})
}
Используем его в React-компонентах:
import { Link } from '@inertiajs/react'
import { useAppRoute } from '../hooks/useAppRoute'
export default function ProductCard({ product }) {
const route = useAppRoute()
return (
<Link href={route('products.show', product)}>
{product.name}
</Link>
)
}
Конфигурация Ziggy и текущий URL должны быть доступны серверному рендеру. (inertiajs.com)
Чтобы не передавать клиенту ненужные маршруты, создаём config/ziggy.php:
<?php
return [
'except' => [
'_debugbar.*',
'horizon.*',
'telescope.*',
'admin.*',
],
];
Шаг 3. Создаём серверную точку входа
Создаём файл resources/js/ssr.jsx:
import { createInertiaApp } from '@inertiajs/react'
import createServer from '@inertiajs/react/server'
import ReactDOMServer from 'react-dom/server'
const formatTitle = title =>
title ? `${title} — MySite` : 'MySite'
createServer(page =>
createInertiaApp({
page,
render: ReactDOMServer.renderToString,
title: formatTitle,
defaults: {
future: {
useScriptElementForInitialPage: true,
},
},
resolve: name => {
const pages = import.meta.glob('./Pages/**/*.jsx', {
eager: true,
})
return pages[`./Pages/${name}.jsx`]
},
setup: ({ App, props }) => <App {...props} />,
}),
)
Для обычной SSR-сборки удобно использовать { eager: true }: все страницы попадут в единый серверный бандл.
Это не обязательное требование. Code splitting тоже поддерживается:
resolve: name => {
const pages = import.meta.glob('./Pages/**/*.jsx')
return pages[`./Pages/${name}.jsx`]()
},
Главное — вызвать функцию динамического импорта. Возврат pages[path] без () вернёт функцию загрузки, а не компонент страницы. (inertiajs.com)
Для нагруженного production-проекта можно включить встроенный кластерный режим:
createServer(
page =>
createInertiaApp({
page,
render: ReactDOMServer.renderToString,
resolve: name => {
const pages = import.meta.glob('./Pages/**/*.jsx', {
eager: true,
})
return pages[`./Pages/${name}.jsx`]
},
setup: ({ App, props }) => <App {...props} />,
}),
{
cluster: true,
},
)
Кластерный режим запускает несколько Node-worker-процессов на одном порту и распределяет запросы между ними. (inertiajs.com)
Шаг 4. Настраиваем гидрацию на клиенте
Открываем resources/js/app.jsx:
import { createInertiaApp } from '@inertiajs/react'
import { createRoot, hydrateRoot } from 'react-dom/client'
import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers'
const formatTitle = title =>
title ? `${title} — MySite` : 'MySite'
createInertiaApp({
title: formatTitle,
defaults: {
future: {
useScriptElementForInitialPage: true,
},
},
resolve: name =>
resolvePageComponent(
`./Pages/${name}.jsx`,
import.meta.glob('./Pages/**/*.jsx'),
),
setup({ el, App, props }) {
const application = <App {...props} />
if (el.hasChildNodes()) {
hydrateRoot(el, application)
return
}
createRoot(el).render(application)
},
progress: {
color: '#4B5563',
},
})
hydrateRoot() используется, когда HTML уже был сгенерирован сервером. Если корневой элемент пустой, приложение запускается через createRoot(). Серверная и клиентская разметка должны совпадать. (React)
Шаг 5. Настраиваем Vite
Открываем vite.config.js:
import { defineConfig } from 'vite'
import laravel from 'laravel-vite-plugin'
import react from '@vitejs/plugin-react'
import path from 'node:path'
export default defineConfig({
plugins: [
laravel({
input: 'resources/js/app.jsx',
ssr: 'resources/js/ssr.jsx',
refresh: true,
}),
react(),
],
resolve: {
alias: {
'ziggy-js': path.resolve('vendor/tightenco/ziggy'),
},
},
ssr: {
noExternal: ['ziggy-js'],
},
})
Alias нужен, если ziggy-js используется из Composer-пакета tightenco/ziggy.
Если ziggy-js установлен отдельно через npm и корректно определяется Vite, alias можно не добавлять.
В package.json:
{
"scripts": {
"dev": "vite",
"build": "vite build && vite build --ssr"
}
}
Команда последовательно собирает клиентский и серверный бандлы.
Шаг 6. Настраиваем Inertia
Публикуем конфигурацию:
php artisan config:publish inertia
В config/inertia.php проверяем настройки:
<?php
return [
'ssr' => [
'enabled' => (bool) env('INERTIA_SSR_ENABLED', true),
'url' => env(
'INERTIA_SSR_URL',
'http://127.0.0.1:13714'
),
'ensure_bundle_exists' => (bool) env(
'INERTIA_SSR_ENSURE_BUNDLE_EXISTS',
true
),
],
'use_script_element_for_initial_page' => (bool) env(
'INERTIA_USE_SCRIPT_ELEMENT_FOR_INITIAL_PAGE',
true
),
];
В .env:
INERTIA_SSR_ENABLED=true
INERTIA_SSR_URL=http://127.0.0.1:13714
INERTIA_SSR_ENSURE_BUNDLE_EXISTS=true
INERTIA_USE_SCRIPT_ELEMENT_FOR_INITIAL_PAGE=true
После изменения конфигурации:
php artisan optimize:clear
Собираем приложение:
npm run build
Запускаем SSR-сервер:
php artisan inertia:start-ssr
Проверяем его состояние:
php artisan inertia:check-ssr
Дополнительно можно отключить JavaScript в браузере или открыть исходный код страницы. Основной текст, заголовок и метатеги должны присутствовать в первоначальном HTML-ответе. Inertia предоставляет отдельные команды для запуска, остановки и проверки SSR-сервера.
Шаг 7. Запускаем SSR через Supervisor
Node-процесс должен постоянно работать и автоматически перезапускаться после ошибок или деплоя.
Создаём файл:
/etc/supervisor/conf.d/inertia-ssr.conf
Содержимое:
[program:inertia-ssr]
directory=/var/www/app
command=/usr/bin/php artisan inertia:start-ssr
user=www-data
autostart=true
autorestart=true
redirect_stderr=true
stdout_logfile=/var/www/app/storage/logs/ssr.log
stopwaitsecs=10
stopasgroup=true
killasgroup=true
Проверяем фактический путь к PHP:
which php
Применяем конфигурацию Supervisor:
sudo supervisorctl reread
sudo supervisorctl update
sudo supervisorctl start inertia-ssr
Проверяем состояние:
sudo supervisorctl status inertia-ssr
php artisan inertia:check-ssr
Шаг 8. Добавляем SSR в деплой
После получения нового кода:
composer install --no-dev --prefer-dist --optimize-autoloader
php artisan optimize:clear
npm ci
npm run build
php artisan config:cache
php artisan route:cache
php artisan view:cache
sudo supervisorctl restart inertia-ssr
php artisan inertia:check-ssr
Сначала необходимо собрать новые клиентские и серверные ассеты, затем перезапустить SSR-процесс.
Если перезапустить SSR до npm run build, Node продолжит использовать старую серверную сборку, тогда как браузер может получить новый клиентский бандл. Это может привести к несовпадению серверной и клиентской разметки.
Вместо прямого перезапуска Supervisor можно использовать:
php artisan inertia:stop-ssr
Подводные камни
Window, document и браузерные API
Нельзя обращаться к браузерным объектам на верхнем уровне компонента:
const width = window.innerWidth
Такой код выполнится в Node и остановит SSR.
Переносим обращение в useEffect():
import { useEffect, useState } from 'react'
export default function Example() {
const [width, setWidth] = useState(null)
useEffect(() => {
const updateWidth = () => {
setWidth(window.innerWidth)
}
updateWidth()
window.addEventListener('resize', updateWidth)
return () => {
window.removeEventListener('resize', updateWidth)
}
}, [])
return <div>{width}</div>
}
Либо проверяем окружение:
if (typeof window !== 'undefined') {
// Код только для браузера
}
Проблема может находиться и внутри импортируемой библиотеки. Если библиотека обращается к window во время импорта, загружайте её динамически внутри useEffect():
useEffect(() => {
import('browser-only-library').then(module => {
const library = module.default
})
}, [])
Случайные значения и текущая дата
Такой код может вызвать hydration mismatch:
const value = Math.random()
const date = new Date()
Сервер и браузер получат разные значения.
Передавайте такие данные через props или вычисляйте их после гидрации.
Локаль и часовой пояс
Node не получает app()->getLocale() автоматически. Передавайте локаль и часовой пояс через shared props:
'locale' => app()->getLocale(),
'timezone' => config('app.timezone'),
При форматировании дат указывайте часовой пояс явно:
const formattedDate = new Intl.DateTimeFormat(locale, {
dateStyle: 'long',
timeZone: timezone,
}).format(new Date(date))
Иначе сервер может отрендерить дату в часовом поясе приложения, а браузер — в часовом поясе пользователя.
Deferred props
Данные из Inertia::defer() не входят в первоначальный HTML и загружаются отдельным запросом.
Не передавайте через defer:
- название товара;
- цену;
- описание;
- основной текст статьи;
- SEO-заголовок;
- canonical;
- Open Graph;
- структурированные данные.
Через deferred props удобно загружать аналитику, рекомендации, историю действий и другие некритичные данные.
Производительность SSR
renderToString() выполняет синхронный серверный рендер. Тяжёлые вычисления внутри React-компонентов блокируют обработку текущего SSR-запроса.
Не выполняйте в компонентах:
- тяжёлую сортировку больших массивов;
- повторные преобразования данных;
- синхронную обработку больших JSON-структур;
- запросы к внешним API;
- операции, которые лучше выполнить в Laravel.
Подготавливайте данные в PHP, используйте кеширование и включайте кластерный режим для нагруженных проектов.
Метатеги и SEO
SSR не заменяет SEO-настройку. Используем компонент <Head>:
import { Head } from '@inertiajs/react'
export default function ProductShow({ product }) {
return (
<>
<Head>
<title>{product.name}</title>
<meta
head-key="description"
name="description"
content={product.excerpt}
/>
<link
head-key="canonical"
rel="canonical"
href={product.url}
/>
<meta
head-key="og-title"
property="og:title"
content={product.name}
/>
<meta
head-key="og-description"
property="og:description"
content={product.excerpt}
/>
<meta
head-key="og-url"
property="og:url"
content={product.url}
/>
<meta
head-key="og-image"
property="og:image"
content={product.image}
/>
</Head>
<h1>{product.name}</h1>
</>
)
}
Canonical и Open Graph URL должны быть абсолютными:
https://site.ru/products/example
JSON-LD
Структурированные данные можно выводить через Inertia <Head>, чтобы они попадали в SSR-HTML:
import { Head } from '@inertiajs/react'
export default function ProductShow({ product }) {
const schema = {
'@context': 'https://schema.org',
'@type': 'Product',
name: product.name,
description: product.excerpt,
image: [product.image],
url: product.url,
sku: product.sku,
offers: {
'@type': 'Offer',
priceCurrency: 'RUB',
price: product.price,
availability: product.inStock
? 'https://schema.org/InStock'
: 'https://schema.org/OutOfStock',
url: product.url,
},
}
const jsonLd = JSON.stringify(schema).replace(
/</g,
'\\u003c',
)
return (
<>
<Head>
<script
head-key="product-schema"
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: jsonLd,
}}
/>
</Head>
<h1>{product.name}</h1>
</>
)
}
Итог
SSR позволяет отдавать готовую HTML-разметку до выполнения клиентского JavaScript, улучшает первоначальное отображение контента и делает публичные страницы более предсказуемыми для поисковых роботов и парсеров.
На одном из моих e-commerce проектов через два месяца после внедрения SSR количество проиндексированных страниц выросло в четыре раза, а конверсия мобильного трафика увеличилась на 18%. Эти изменения нельзя полностью приписывать только SSR, но снижение LCP и наличие основного контента в первоначальном HTML были одними из ключевых факторов.