Меню
SSR для Laravel + React: Как настроить серверный рендеринг через Inertia

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 были одними из ключевых факторов.