Nitro

JavaScript views

Pages

A page's view can be Vue, React, Solid or Svelte, not only a component's. Then the whole page is one .vue, .jsx or .svelte file, and its PHP class is its controller: routed, loading its data in mount(), doing the server's work in #[Server] methods. Visits move between pages of any framework without a reload.

On this page

A page is a component with a route, so everything a component's JavaScript view can do, a page's can too. What a page adds is its route, its layout, its title and head, and data that loads after it shows. This page covers those. Each framework's page covers the view itself.

One page, four ways

The class names its view with #[View], as a component does. The rest is the page class you would write for Blade:

app/Nitro/Pages/Demo/VuePage.php Browser + server
<?php

namespace App\Nitro\Pages\Demo;

use App\Blog\Posts;
use Nitro\Attributes\Computed;
use Nitro\Attributes\Layout;
use Nitro\Attributes\Locked;
use Nitro\Attributes\Server;
use Nitro\Attributes\Title;
use Nitro\Attributes\Validate;
use Nitro\Attributes\View;
use Nitro\Component;

/** A page whose whole view is Vue: the blog's posts, searched in the browser, written on the server. */
#[Title('A Vue page')]
#[Layout('layouts.demo')]
#[View(vue: 'demo/page')]
class VuePage extends Component
{
    /** @var list<array{id: string, title: string, body: string, date: string}> */
    #[Locked]
    public array $posts = [];

    public string $search = '';

    #[Validate('required|min:3|max:120')]
    public string $title = '';

    #[Validate('required|max:2000')]
    public string $body = '';

    public function mount(): void
    {
        $this->posts = Posts::all();
    }

    #[Computed]
    public function results(): array
    {
        $search = mb_strtolower(trim($this->search));

        if ($search === '') {
            return $this->posts;
        }

        return array_values(array_filter(
            $this->posts,
            fn (array $post) => str_contains(mb_strtolower($post['title'].' '.$post['body']), $search),
        ));
    }

    #[Server]
    public function publish(): void
    {
        $this->validate();
        Posts::save(null, $this->title, $this->body);
        $this->posts = Posts::all();
        $this->reset('title', 'body');
    }
}

The four pages differ only in that line: #[View(react: 'demo/page')], #[View(solid: 'demo/page')], #[View(svelte: 'demo/page')]. Their views are the same page in each framework's idiom:

<template>
    <div class="space-y-8">
        <header class="rounded-2xl bg-emerald-50 p-6 ring-1 ring-emerald-200 dark:bg-emerald-500/10 dark:ring-emerald-500/20">
            <p class="text-sm font-semibold text-emerald-700 dark:text-emerald-300">This is a Vue page</p>
            <h1 class="mt-1 text-2xl font-semibold tracking-tight text-slate-900 dark:text-white">The whole page is one .vue file</h1>
            <p class="mt-2 text-sm leading-6 text-slate-600 dark:text-slate-400">
                Its class, <code>App\Nitro\Pages\Demo\VuePage</code>, is routed with <code>Route::nitro()</code> and loads
                the posts in <code>mount()</code>. Its view is <code>resources/views/vue/demo/page.vue</code>: there is no
                Blade view. Search runs in the browser; publishing is a <code>#[Server]</code> method.
            </p>
            <nav class="mt-4 flex flex-wrap gap-2 text-sm">
                <a v-bind="link('/demo/react', 'prefetch')" class="demo-switch">React page</a>
                <a v-bind="link('/demo/solid', 'prefetch')" class="demo-switch">Solid page</a>
                <a v-bind="link('/demo/svelte', 'prefetch')" class="demo-switch">Svelte page</a>
                <a v-bind="link('/docs/javascript-pages')" class="demo-switch">Back to the docs</a>
            </nav>
        </header>

        <form class="space-y-3 rounded-2xl bg-white p-5 ring-1 ring-slate-200 dark:bg-slate-900 dark:ring-white/10" @submit.prevent="publish()">
            <h2 class="text-sm font-semibold text-slate-900 dark:text-white">Write a post</h2>
            <input v-model="title" placeholder="Title" aria-label="Title" class="demo-input">
            <p v-if="errors.title" class="text-xs text-red-600">{{ errors.title[0] }}</p>
            <textarea v-model="body" rows="3" placeholder="What happened?" aria-label="Body" class="demo-input"></textarea>
            <p v-if="errors.body" class="text-xs text-red-600">{{ errors.body[0] }}</p>
            <button :disabled="publish.processing" class="demo-button">{{ publish.processing ? 'Publishing…' : 'Publish' }}</button>
        </form>

        <section class="space-y-3">
            <input v-model="search" type="search" placeholder="Search posts" aria-label="Search posts" class="demo-input">
            <p v-if="results.length === 0" class="text-center text-sm text-slate-500">No posts match your search.</p>
            <article v-for="post in results" :key="post.id" class="rounded-2xl bg-white p-5 ring-1 ring-slate-200 dark:bg-slate-900 dark:ring-white/10">
                <h3 class="font-semibold text-slate-900 dark:text-white">{{ post.title }}</h3>
                <p class="mt-0.5 text-xs text-slate-500">{{ post.date }}</p>
                <p class="mt-2 text-sm leading-6 whitespace-pre-line text-slate-600 dark:text-slate-400">{{ post.body }}</p>
            </article>
        </section>
    </div>
</template>
export default function Page({ results, search, title, body, errors, set, publish, link }) {
    return (
        <div className="space-y-8">
            <header className="rounded-2xl bg-sky-50 p-6 ring-1 ring-sky-200 dark:bg-sky-500/10 dark:ring-sky-500/20">
                <p className="text-sm font-semibold text-sky-700 dark:text-sky-300">This is a React page</p>
                <h1 className="mt-1 text-2xl font-semibold tracking-tight text-slate-900 dark:text-white">The whole page is one .jsx file</h1>
                <p className="mt-2 text-sm leading-6 text-slate-600 dark:text-slate-400">
                    Its class, <code>App\Nitro\Pages\Demo\ReactPage</code>, is routed with <code>Route::nitro()</code> and
                    loads the posts in <code>mount()</code>. Its view is <code>resources/views/react/demo/page.jsx</code>:
                    there is no Blade view. Search runs in the browser; publishing is a <code>#[Server]</code> method.
                </p>
                <nav className="mt-4 flex flex-wrap gap-2 text-sm">
                    <a {...link('/demo/vue', 'prefetch')} className="demo-switch">Vue page</a>
                    <a {...link('/demo/solid', 'prefetch')} className="demo-switch">Solid page</a>
                    <a {...link('/demo/svelte', 'prefetch')} className="demo-switch">Svelte page</a>
                    <a {...link('/docs/javascript-pages')} className="demo-switch">Back to the docs</a>
                </nav>
            </header>

            <form className="space-y-3 rounded-2xl bg-white p-5 ring-1 ring-slate-200 dark:bg-slate-900 dark:ring-white/10" onSubmit={(e) => { e.preventDefault(); publish(); }}>
                <h2 className="text-sm font-semibold text-slate-900 dark:text-white">Write a post</h2>
                <input value={title} onChange={(e) => set('title', e.target.value)} placeholder="Title" aria-label="Title" className="demo-input" />
                {errors.title && <p className="text-xs text-red-600">{errors.title[0]}</p>}
                <textarea value={body} onChange={(e) => set('body', e.target.value)} rows={3} placeholder="What happened?" aria-label="Body" className="demo-input" />
                {errors.body && <p className="text-xs text-red-600">{errors.body[0]}</p>}
                <button disabled={publish.processing} className="demo-button">{publish.processing ? 'Publishing…' : 'Publish'}</button>
            </form>

            <section className="space-y-3">
                <input value={search} onChange={(e) => set('search', e.target.value)} type="search" placeholder="Search posts" aria-label="Search posts" className="demo-input" />
                {results.length === 0 && <p className="text-center text-sm text-slate-500">No posts match your search.</p>}
                {results.map((post) => (
                    <article key={post.id} className="rounded-2xl bg-white p-5 ring-1 ring-slate-200 dark:bg-slate-900 dark:ring-white/10">
                        <h3 className="font-semibold text-slate-900 dark:text-white">{post.title}</h3>
                        <p className="mt-0.5 text-xs text-slate-500">{post.date}</p>
                        <p className="mt-2 text-sm leading-6 whitespace-pre-line text-slate-600 dark:text-slate-400">{post.body}</p>
                    </article>
                ))}
            </section>
        </div>
    );
}
import { For, Show } from 'solid-js';

export default function Page(props) {
    return (
        <div class="space-y-8">
            <header class="rounded-2xl bg-blue-50 p-6 ring-1 ring-blue-200 dark:bg-blue-500/10 dark:ring-blue-500/20">
                <p class="text-sm font-semibold text-blue-700 dark:text-blue-300">This is a Solid page</p>
                <h1 class="mt-1 text-2xl font-semibold tracking-tight text-slate-900 dark:text-white">The whole page is one .jsx file</h1>
                <p class="mt-2 text-sm leading-6 text-slate-600 dark:text-slate-400">
                    Its class, <code>App\Nitro\Pages\Demo\SolidPage</code>, is routed with <code>Route::nitro()</code> and
                    loads the posts in <code>mount()</code>. Its view is <code>resources/views/solid/demo/page.jsx</code>:
                    there is no Blade view. Search runs in the browser; publishing is a <code>#[Server]</code> method.
                </p>
                <nav class="mt-4 flex flex-wrap gap-2 text-sm">
                    <a {...props.link('/demo/vue', 'prefetch')} class="demo-switch">Vue page</a>
                    <a {...props.link('/demo/react', 'prefetch')} class="demo-switch">React page</a>
                    <a {...props.link('/demo/svelte', 'prefetch')} class="demo-switch">Svelte page</a>
                    <a {...props.link('/docs/javascript-pages')} class="demo-switch">Back to the docs</a>
                </nav>
            </header>

            <form class="space-y-3 rounded-2xl bg-white p-5 ring-1 ring-slate-200 dark:bg-slate-900 dark:ring-white/10" onSubmit={(e) => { e.preventDefault(); props.publish(); }}>
                <h2 class="text-sm font-semibold text-slate-900 dark:text-white">Write a post</h2>
                <input value={props.title} onInput={(e) => props.set('title', e.currentTarget.value)} placeholder="Title" aria-label="Title" class="demo-input" />
                <Show when={props.errors.title}><p class="text-xs text-red-600">{props.errors.title[0]}</p></Show>
                <textarea value={props.body} onInput={(e) => props.set('body', e.currentTarget.value)} rows={3} placeholder="What happened?" aria-label="Body" class="demo-input" />
                <Show when={props.errors.body}><p class="text-xs text-red-600">{props.errors.body[0]}</p></Show>
                <button disabled={props.publish.processing} class="demo-button">{props.publish.processing ? 'Publishing…' : 'Publish'}</button>
            </form>

            <section class="space-y-3">
                <input value={props.search} onInput={(e) => props.set('search', e.currentTarget.value)} type="search" placeholder="Search posts" aria-label="Search posts" class="demo-input" />
                <Show when={props.results.length === 0}><p class="text-center text-sm text-slate-500">No posts match your search.</p></Show>
                <For each={props.results}>
                    {(post) => (
                        <article class="rounded-2xl bg-white p-5 ring-1 ring-slate-200 dark:bg-slate-900 dark:ring-white/10">
                            <h3 class="font-semibold text-slate-900 dark:text-white">{post.title}</h3>
                            <p class="mt-0.5 text-xs text-slate-500">{post.date}</p>
                            <p class="mt-2 text-sm leading-6 whitespace-pre-line text-slate-600 dark:text-slate-400">{post.body}</p>
                        </article>
                    )}
                </For>
            </section>
        </div>
    );
}
<script>
    let { results, search = $bindable(), title = $bindable(), body = $bindable(), errors, publish, link } = $props();
</script>

<div class="space-y-8">
    <header class="rounded-2xl bg-orange-50 p-6 ring-1 ring-orange-200 dark:bg-orange-500/10 dark:ring-orange-500/20">
        <p class="text-sm font-semibold text-orange-700 dark:text-orange-300">This is a Svelte page</p>
        <h1 class="mt-1 text-2xl font-semibold tracking-tight text-slate-900 dark:text-white">The whole page is one .svelte file</h1>
        <p class="mt-2 text-sm leading-6 text-slate-600 dark:text-slate-400">
            Its class, <code>App\Nitro\Pages\Demo\SveltePage</code>, is routed with <code>Route::nitro()</code> and loads
            the posts in <code>mount()</code>. Its view is <code>resources/views/svelte/demo/page.svelte</code>: there is
            no Blade view. Search runs in the browser; publishing is a <code>#[Server]</code> method.
        </p>
        <nav class="mt-4 flex flex-wrap gap-2 text-sm">
            <a {...link('/demo/vue', 'prefetch')} class="demo-switch">Vue page</a>
            <a {...link('/demo/react', 'prefetch')} class="demo-switch">React page</a>
            <a {...link('/demo/solid', 'prefetch')} class="demo-switch">Solid page</a>
            <a {...link('/docs/javascript-pages')} class="demo-switch">Back to the docs</a>
        </nav>
    </header>

    <form class="space-y-3 rounded-2xl bg-white p-5 ring-1 ring-slate-200 dark:bg-slate-900 dark:ring-white/10" onsubmit={(e) => { e.preventDefault(); publish(); }}>
        <h2 class="text-sm font-semibold text-slate-900 dark:text-white">Write a post</h2>
        <input bind:value={title} placeholder="Title" aria-label="Title" class="demo-input">
        {#if errors.title}<p class="text-xs text-red-600">{errors.title[0]}</p>{/if}
        <textarea bind:value={body} rows="3" placeholder="What happened?" aria-label="Body" class="demo-input"></textarea>
        {#if errors.body}<p class="text-xs text-red-600">{errors.body[0]}</p>{/if}
        <button disabled={publish.processing} class="demo-button">{publish.processing ? 'Publishing…' : 'Publish'}</button>
    </form>

    <section class="space-y-3">
        <input bind:value={search} type="search" placeholder="Search posts" aria-label="Search posts" class="demo-input">
        {#if results.length === 0}<p class="text-center text-sm text-slate-500">No posts match your search.</p>{/if}
        {#each results as post (post.id)}
            <article class="rounded-2xl bg-white p-5 ring-1 ring-slate-200 dark:bg-slate-900 dark:ring-white/10">
                <h3 class="font-semibold text-slate-900 dark:text-white">{post.title}</h3>
                <p class="mt-0.5 text-xs text-slate-500">{post.date}</p>
                <p class="mt-2 text-sm leading-6 whitespace-pre-line text-slate-600 dark:text-slate-400">{post.body}</p>
            </article>
        {/each}
    </section>
</div>

Each is routed like any page:

routes/web.php
Route::nitro('/demo/vue', VuePage::class);
Route::nitro('/demo/react', ReactPage::class);
Route::nitro('/demo/solid', SolidPage::class);
Route::nitro('/demo/svelte', SveltePage::class);

See them running

These are the pages at /demo/vue, /demo/react, /demo/solid and /demo/svelte. Search, write a post, then follow the links between them: each one is a visit to a page in another framework, not a reload.

The layout stays Blade

A page renders inside its layout, and the layout is Blade whatever the page's view is: your <head> with @nitroHead, the header and footer, @nitroScripts. The page's view goes where $slot is. #[Layout] chooses the layout, and #[Title] the title, as for a Blade page (see Choosing a layout).

$this->head() in mount() sets the description, the link preview and other tags, as on The page's head. They are in the first HTML, and a visit puts the new page's tags in place of the last page's, whatever either page's view is.

Deferred properties

$this->defer() in mount() sends the page without a slow property, and the page asks for it once it is shown (see Page data). Until it arrives, the view has the property's default, null, so show something meanwhile:

PHP Server
public ?int $orders = null;

public function mount(): void
{
    $this->defer('orders', fn () => Order::count());
}
<p>Orders: {{ orders ?? 'Loading…' }}</p>
<p>Orders: {orders ?? 'Loading…'}</p>
<p>Orders: {props.orders ?? 'Loading…'}</p>
<p>Orders: {orders ?? 'Loading…'}</p>

What the visitor does while it loads is kept: a click that changed another property stays changed.

Visits between frameworks

A nitro:navigate link in Blade, or link() and visit() in a JavaScript view, visits a page whatever its view is: from Blade to Vue, from Vue to React, and back. When both pages have the same layout, the layout stays and only the page changes. The address and the title follow, and back and forward return.

If a framework is registered with lazy(), the visit loads it with the page's view before showing the page, so a site can have one React page without every other page downloading React (see Loading a framework where it's used).

Tested as one

Nitro's browser tests load a page with each of the four views and visit it from a Blade page: what mount() set, the title, head()'s tags, a deferred property, a browser method and a #[Server] method from the view, and back.

Next steps