Nitro

JavaScript views

Overview

A component's view can be a Vue, React, Solid or Svelte component instead of Blade. The PHP class doesn't change: its properties, its methods compiled to run in the browser, its #[Server] methods and their rules. Only the view does, and it works with the component by the same names, in the framework's own way.

On this page

Blade is Nitro's own view engine: Nitro compiles it, and renders it on the server and in the browser. A JavaScript view is a component of the framework you choose, built by your app's Vite like any other. Nitro gives it the component (its state, its methods, its server calls, its validation messages) and keeps them in step. You write the view the way that framework is written, and the PHP the way Nitro is.

When to use one

Blade is the default, and the right choice for most views. Reach for a JavaScript view when:

  • A library you want is written for a framework: a rich text editor, a chart, a date picker, a drag-and-drop board, a map.
  • Your team already writes one, and has components to reuse.
  • A view is mostly interaction: many small pieces of state that live only in the browser, animation, canvas work.

You don't have to choose for the whole app. The choice is per component, and components with different views sit side by side on a page, talking through events and stores as any Nitro components do.

One component, any view

The class names its view with #[View]: vue:, react:, solid: or svelte:. Everything else is the class you would write for Blade:

app/Nitro/Components/PostEditor.php Browser + server
<?php

namespace App\Nitro\Components;

use App\Models\Post;
use Nitro\Attributes\Computed;
use Nitro\Attributes\Server;
use Nitro\Attributes\Validate;
use Nitro\Attributes\View;
use Nitro\Component;

#[View(vue: 'post-editor')]
class PostEditor extends Component
{
    #[Validate('required|min:3|max:120')]
    public string $title = '';

    public string $body = '';

    public array $tags = [];

    #[Computed]
    public function words(): int
    {
        return str_word_count($this->body);
    }

    public function addTag(string $tag): void
    {
        $tag = strtolower(trim($tag));

        if ($tag !== '' && ! in_array($tag, $this->tags, true)) {
            $this->tags[] = $tag;
        }
    }

    #[Server]
    public function save(): string
    {
        $this->validate();

        $post = auth()->user()->posts()->create([
            'title' => $this->title,
            'body' => $this->body,
            'tags' => $this->tags,
        ]);

        return "Saved as draft #{$post->id}";
    }
}

Whichever view it has, the component behaves the same:

  • addTag() runs in the browser, compiled from the PHP. No request.
  • save() runs on the server, with the browser's changes sent along and the state signed, so #[Locked] properties can't be forged. Its rules are checked there, and its value comes back to the view.
  • words is worked out again when body changes, wherever it changes.
  • Validation messages, unsaved changes, uploads, and a call's progress reach the view the same way.

Tested as one

Nitro's browser tests run one component against its Blade, Vue, React, Solid and Svelte views, with the same steps, and expect the same results from each: typing, methods, server calls, validation, uploads, links, server rendering and hydration.

What every view gets

Each framework's page shows these in its own idiom (refs in Vue, props in React and Solid, $props() in Svelte), but the names and what they do are the same in all four:

Example What it is
Properties title , tags By their PHP names. Arrays, models as objects with their attributes, uploads as Upload objects, enums as their cases
#[Computed] values words Worked out again when what they read changes
Browser methods addTag(tag) Run in the browser, compiled from the PHP
#[Server] methods save() A promise of the method's value; null when its rules refused the data
A call's status save.processing save.slow save.failed save.error slow once the call has taken 200 ms; failed when the request itself failed
errors errors.title?.[0] The validation messages, by field, as $errors has them
unsaved unsaved.length The properties changed in the browser that the server hasn't seen yet
uploads uploads.cover?.progress Each upload while it runs: uploading , progress , error
set(path, value) set('form.email', value) Writes a property, or a key under one
upload(path, files) upload('cover', files) Files into an Upload or #[Uploads] property
dispatch(event, ...) dispatch('saved', id) As $dispatch in Blade
link(href, ...modifiers) link('/posts', 'prefetch') A nitro:navigate link's attributes, to spread on an <a>
visit(url) visit('/posts') A visit without a reload, as $visit in Blade

Several frameworks in one app

Each framework's page shows its own setup. To use several, add each one's Vite plugin and register each one's views. React and Solid both compile .jsx, so tell each which folders are its own:

JavaScript
// vite.config.js
import { defineConfig } from 'vite';
import laravel from 'laravel-vite-plugin';
import vue from '@vitejs/plugin-vue';
import react from '@vitejs/plugin-react';
import solid from 'vite-plugin-solid';
import { svelte } from '@sveltejs/vite-plugin-svelte';
import nitro from './vendor/nitro/nitro/js/vite.js';

export default defineConfig({
    plugins: [
        laravel({ input: ['resources/js/app.js'], refresh: true }),
        nitro(),
        vue(),
        svelte(),
        // React and Solid both compile .jsx: give each its own folders.
        react({ include: /resources[\\/](views|js)[\\/]react[\\/].*\.[jt]sx$/ }),
        solid({ include: /resources[\\/](views|js)[\\/]solid[\\/].*\.[jt]sx$/ }),
    ],
});

// resources/js/app.js
import { nitroVue } from 'nitro/vue';
import { nitroReact } from 'nitro/react';
import { nitroSolid } from 'nitro/solid';
import { nitroSvelte } from 'nitro/svelte';

nitroVue(import.meta.glob(['../views/vue/**/*.vue', './vue/**/*.vue']));
nitroReact(import.meta.glob(['../views/react/**/*.{jsx,tsx}', './react/**/*.{jsx,tsx}']));
nitroSolid(import.meta.glob(['../views/solid/**/*.{jsx,tsx}', './solid/**/*.{jsx,tsx}']));
nitroSvelte(import.meta.glob(['../views/svelte/**/*.svelte', './svelte/**/*.svelte']));

Nitro's Vite plugin gives you nitro/vue, /react, /solid and /svelte (and their /server parts for server rendering), keeps a single copy of each framework in the bundle, and leaves a .vue or .svelte file's <php> block to Nitro. Only the frameworks you register are bundled.

Side by side on a page

Components are placed from Blade, whatever their view. A page can hold a Vue desk, a React card and a Blade list together:

Blade
{{-- resources/views/nitro/pages/editor.blade.php: one page, three engines --}}
<x-layouts.app>
    <h1>Editor</h1>

    <div class="grid grid-cols-3 gap-6">
        <nitro:vue.post-list />                        {{-- picks a post, publishes it --}}
        <nitro:react.post-preview />                   {{-- hears both, and shows the post --}}
        <nitro:posts.recent :limit="10" />             {{-- a Blade view, hearing the same events --}}
    </div>
</x-layouts.app>

Each JavaScript view is its own app (its own Vue app, React root, Solid root or Svelte app) mounted in its component's element. A JavaScript view can't place <nitro:...> components inside itself; compose them from Blade, as above.

Components that talk to each other

Components with different views talk the way any Nitro components do: through their PHP. One dispatches an event, the others listen with #[On]. None of them needs to know what the others are written in, and their listeners run in the browser, so the views follow at once.

<?php

// app/Nitro/Components/Vue/PostList.php: #[View(vue: 'post-list')]

/** Picked: said at once, in the browser. */
public function pick(int $id): void
{
    $this->post = $id;
    $this->dispatch('post-picked', id: $id);
}

/** Published on the server; the others hear of it with the answer. */
#[Server]
public function publish(): void
{
    $this->validate();
    $post = Post::findOrFail($this->post)->publish();

    $this->dispatch('post-published', post: $post->only('id', 'title', 'category'));
}
<?php

// app/Nitro/Components/React/PostPreview.php: #[View(react: 'post-preview')]

#[On('post-picked')]
public function show(int $id): void
{
    $this->current = $id;
}

#[On('post-published')]
public function published(array $post): void
{
    $this->published[$post['category']] = ($this->published[$post['category']] ?? 0) + 1;
}
  • From a browser method, the event is sent in the browser: no request.
  • From a #[Server] method, it is sent with the answer, once the server has done its work.
  • From the view itself, dispatch('event', ...) sends one, as $dispatch does in Blade.
  • To one component: $this->dispatch(...)->to('react.post-preview').
  • The view's own code can listen on window for an event sent without ->to().

For state that several components share and keep, rather than news of something that happened, use a store.

The PHP in the view's file

A small component can live in one file: its class in a <php> block at the top of a .vue or .svelte file, or in a /* <php> ... </php> */ comment at the top of a .jsx or .tsx file. Nitro makes it a class named after the view, and an error in it names the line in the view. Each framework's page has an example.

The two ways mix freely in one app: a class with its view beside it for components that grow, both in one file for small ones.

Keeping them by framework

A component's name follows its folder, so putting classes in a folder per framework puts the framework in the tag where the component is placed:

Blade
{{-- app/Nitro/Components/Vue/PostEditor.php, React/ViewsChart.php, Solid/CommentThread.php, Svelte/TagPicker.php --}}
<nitro:vue.post-editor />
<nitro:react.views-chart />
<nitro:solid.comment-thread />
<nitro:svelte.tag-picker />

It is a convention, not a rule: feature folders (Posts/, Comments/) work the same. The view's file is named by #[View], so moving a class doesn't move its view.

On a visit

When nitro:navigate visits a page, the browser loads the code of the JavaScript views the page places before it shows the page, alongside its data. The page appears with its views already rendered, not filling in one by one after it. A view's code loads once, and is kept for the next page that shows it.

Server rendering

Without it, the page's HTML has each JavaScript view's element and the component's state, and the browser renders the view. With server rendering, a small Node service renders the views into the page's first HTML, for search engines and a first paint that shows them, and the browser hydrates them. It is optional, falls back to the browser whenever it can't render, and needs no change to the views.

Framework upgrades

All four adapters share one core, which holds everything about a Nitro component that isn't a framework's own reactivity. Each adapter only binds that core to its framework, through the framework's public API alone (Vue's refs, React's useSyncExternalStore, Solid's stores, Svelte's $state). A new release of Vue, React, Solid or Svelte needs nothing from Nitro beyond the tests that run each view against the others.

Another framework

nitro.views lists the engines: where each one's views are, their extensions, and the adapter that knows the framework's files. Add an entry for another framework, with an adapter class of your own (extending Nitro\View\ScriptEngine) or none, and register its browser side with Nitro.engine(). See a view engine of your own.

Next steps