Nitro

Going further

Server rendering

Put a page's Vue, React, Solid and Svelte views in its first HTML. Search engines and link previews see them, the first paint shows them, and the browser hydrates them: it takes over the elements that are there rather than rendering them again.

On this page

Blade views are always rendered on the server: they are Nitro's own. A JavaScript view is different. Without server rendering, the page's HTML has the view's element and the component's state, and the browser renders the view once its code has loaded. With it, your app's render service, a small Node process, renders the view, Laravel puts the HTML in the page, and the browser picks up from there.

Optional, and safe to turn off

Nothing in a view changes for server rendering. Turn it on when a page's JavaScript views matter for search or for the first paint; leave it off for views behind a login, where neither does.

How it works

  1. Laravel renders the page

    Blade views render as always. For each JavaScript view, Laravel sends the render service the view's name and the component's state: the same state the browser gets.

  2. The service renders the view

    It renders the view through the same adapter code the browser uses, from the same state, and answers with its HTML. Laravel puts it in the component's element.

  3. The browser hydrates it

    When the component starts, its adapter hydrates the HTML instead of rendering into an empty element: Vue's createSSRApp, React's hydrateRoot, Solid's and Svelte's hydrate. Event handlers attach, and the view is live.

Because both sides render from the same state through the same code, the service's HTML is what the browser's first render would be, and hydration finds what it expects.

Setting it up

The render service's entry

Name a renderer for each framework your views use. Each takes your views as import.meta.glob() gives them, with { eager: true }: the service loads them all when it starts.

JavaScript
// resources/js/ssr.js
import { serve } from 'nitro/ssr';
import { renderVue } from 'nitro/vue/server';
import { renderReact } from 'nitro/react/server';
import { renderSolid } from 'nitro/solid/server';
import { renderSvelte } from 'nitro/svelte/server';

/** A renderer for each framework the app's views use; leave out the ones it doesn't. */
serve({
    vue: renderVue(import.meta.glob(['../views/vue/**/*.vue', './vue/**/*.vue'], { eager: true })),
    react: renderReact(import.meta.glob(['../views/react/**/*.{jsx,tsx}', './react/**/*.{jsx,tsx}'], { eager: true })),
    solid: renderSolid(import.meta.glob(['../views/solid/**/*.{jsx,tsx}', './solid/**/*.{jsx,tsx}'], { eager: true })),
    svelte: renderSvelte(import.meta.glob(['../views/svelte/**/*.svelte', './svelte/**/*.svelte'], { eager: true })),
});

A renderer takes the same options as the browser's registration: Vue's setup(app), React's wrap(view). Create what they install per render (a new Pinia, a new query client): the service renders for every visitor, so nothing should be shared between them.

Vite

Give Laravel's Vite plugin the entry as its ssr input:

JavaScript
// vite.config.js
laravel({
    input: ['resources/js/app.js'],
    ssr: 'resources/js/ssr.js',
    refresh: true,
}),

Solid compiles a component differently for the browser and the server: turn on ssr: true in vite-plugin-solid, so both builds are hydratable. Vue, React and Svelte need nothing more.

Build and start it

Terminal
npx vite build && npx vite build --ssr
php artisan nitro:ssr

vite build --ssr bundles the entry to bootstrap/ssr/ssr.js. php artisan nitro:ssr runs that bundle with Node, on the port in nitro.ssr.url. Then turn server rendering on:

.env
NITRO_SSR=true

Configuration

PHP
// config/nitro.php
'ssr' => [
    'enabled' => env('NITRO_SSR', false),

    /** Where nitro:ssr listens, and where Laravel asks. */
    'url' => env('NITRO_SSR_URL', 'http://127.0.0.1:13714'),

    /** Seconds a view may take to render before the browser is left to render it. */
    'timeout' => 2,

    /** What nitro:ssr runs: the bundle `vite build --ssr` made. */
    'bundle' => base_path('bootstrap/ssr/ssr.js'),
],
Option Default What it does
enabled false Whether pages ask the render service. Off, the browser renders every JavaScript view
url http://127.0.0.1:13714 Where the service listens. nitro:ssr takes its port from here
timeout 2 Seconds a view may take before the page goes out without it
bundle bootstrap/ssr/ssr.js What nitro:ssr runs. php artisan nitro:ssr --bundle=... runs another

The service listens on 127.0.0.1 only: Laravel is its one client. GET /health answers with the engines it serves, for a health check.

When it can't render

Server rendering never holds a page up, and never breaks one. Each case falls back to what the page does without it:

When What happens
The service isn't running The connection is refused at once. The request's views go to the browser, and Laravel logs a warning
It is slow After timeout seconds the view goes to the browser. The rest of the request doesn't ask again
A view throws while it renders That view goes to the browser, and the error is logged. The other views are served
The entry names no renderer for an engine Those views are the browser's. That is not an error: serve only the frameworks you want rendered
The state can't be sent as JSON The view goes to the browser, with a warning naming it

Writing views that render on the server

  • A view only reads while it renders. Calling a method, set(), upload() or dispatch() during the render throws on the server, and the view is left to the browser. Do them in event handlers, as you would anyway.
  • Browser-only code (window, document, localStorage, measuring an element) goes where each framework runs code only in the browser: Vue's onMounted, React's useEffect, Solid's onMount, Svelte's $effect or onMount.
  • The first render must match. A value that differs between the server and the browser (the current time, a random number) makes hydration find something else. Work it out after mounting, or put it on the component, where both sides read the same value.
  • Validation messages are in the HTML. A page rendered after a failed form shows its errors in the first paint, as Blade does.

In production

Build both bundles in your deploy, then keep nitro:ssr running as a service, and restart it after each deploy so it serves the new views. With Supervisor:

INI
; /etc/supervisor/conf.d/nitro-ssr.conf
[program:nitro-ssr]
command=php /var/www/app/artisan nitro:ssr
directory=/var/www/app
user=www-data
autostart=true
autorestart=true
stdout_logfile=/var/www/app/storage/logs/ssr.log
redirect_stderr=true

Run supervisorctl restart nitro-ssr as a deploy step. Pages keep working while it restarts: their views are rendered by the browser for those few moments.

A view engine of your own

The service serves any engine named in serve(). A renderer is an object with render(view, handle) that returns the view's HTML; handle is what the browser's first render reads (handle.get(name), handle.errors(), the component's names), made from the state Laravel sent:

JavaScript
// resources/js/ssr.js: a renderer for a view engine of your own
import { serve } from 'nitro/ssr';
import { renderToString } from 'my-framework/server';

const views = import.meta.glob('../views/mine/**/*.mine', { eager: true });

serve({
    mine: {
        /** The view's name, and the component as the browser's first render reads it: handle.get('title'). */
        async render(view, handle) {
            const module = Object.entries(views).find(([path]) => path.endsWith(`/${view}.mine`))?.[1];
            return renderToString(module.default, { get: handle.get, errors: handle.errors() });
        },
    },
});

On the browser side, the engine's mount() is told { hydrate: true } when the element holds the server's HTML. See a view engine of your own.

Next steps