Nitro

JavaScript views

Svelte

Write a component's view as a Svelte 5 component. The component stays a PHP class: its properties, its methods compiled to run in the browser, its #[Server] methods and their rules. The view takes them from $props() by their PHP names, and they are reactive like any Svelte props.

On this page

A Svelte view is a .svelte file your Vite builds with @sveltejs/vite-plugin-svelte. Its props are the Nitro component: every property and #[Computed] value, every method, and what the component knows about itself (validation messages, unsaved changes, uploads, a call's status). Runes, blocks, snippets, context and transitions work as they always do. Nitro takes care of state, the server and navigation, so there is no load function, no API route and no store to set up.

Svelte 5

Nitro's Svelte adapter uses Svelte's public API only: mount, hydrate, unmount, getContext, $state and svelte/server's render. A Svelte release needs nothing from Nitro. The examples here use runes.

Setting it up

  1. Install Svelte and its Vite plugin

    npm install svelte @sveltejs/vite-plugin-svelte
    
  2. Add the plugins, and register your views

    nitroSvelte() takes your views as import.meta.glob() gives them; each view loads when a page first shows it. Svelte's plugin also compiles Nitro's adapter, which is written with runes.

    // vite.config.js
    import { defineConfig } from 'vite';
    import laravel from 'laravel-vite-plugin';
    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(),
            svelte(),
        ],
    });
    
    // resources/js/app.js
    import { nitroSvelte } from 'nitro/svelte';
    
    nitroSvelte(import.meta.glob(['../views/svelte/**/*.svelte', './svelte/**/*.svelte']));
    
  3. Load them before Nitro starts

    Put @vite before @nitroScripts in your layout, so the views are registered when the components start.

    <head>
        {{-- Your views first: they register with Nitro before its components start. --}}
        @vite('resources/js/app.js')
    </head>
    <body>
        {{ $slot }}
    
        @nitroScripts
    </body>
    

Views live in resources/views/svelte or resources/js/svelte. Subfolders work: #[View(svelte: 'blog/editor')] is blog/editor.svelte. This is plain Svelte, not SvelteKit: Laravel does the routing, and Nitro the pages.

A component with a Svelte view

The class is an ordinary Nitro component. #[View(svelte: 'post-editor')] says its view is post-editor.svelte:

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

namespace App\Nitro\Components\Svelte;

use App\Models\Post;
use Illuminate\Support\Facades\Gate;
use Nitro\Attributes\Computed;
use Nitro\Attributes\Locked;
use Nitro\Attributes\Server;
use Nitro\Attributes\Validate;
use Nitro\Attributes\View;
use Nitro\Component;
use Nitro\Upload;

#[View(svelte: 'post-editor')]
class PostEditor extends Component
{
    #[Locked]
    public int $postId;

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

    public string $body = '';

    /** @var list<string> */
    public array $tags = [];

    #[Validate('nullable|image|max:2048')]
    public ?Upload $cover = null;

    public string $savedAt = '';

    public function mount(Post $post): void
    {
        $this->postId = $post->id;
        $this->title = $post->title;
        $this->body = $post->body;
        $this->tags = $post->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;
        }
    }

    public function removeTag(int $index): void
    {
        array_splice($this->tags, $index, 1);
    }

    #[Server]
    public function save(): string
    {
        $this->validate();
        $post = Post::findOrFail($this->postId);
        Gate::authorize('update', $post);

        $post->update([
            'title' => $this->title,
            'body' => $this->body,
            'tags' => $this->tags,
            'cover' => $this->cover?->store('covers', 'public') ?? $post->cover,
        ]);

        $this->cover = null;
        $this->savedAt = now()->format('H:i');
        $this->dispatch('post-saved', id: $post->id);

        return "Saved at {$this->savedAt}";
    }
}

The view takes what it uses from $props():

resources/views/svelte/post-editor.svelte
<script>
    let { title = $bindable(), body = $bindable(), tags, words, savedAt, addTag, removeTag, save, errors, unsaved } = $props();

    function submit(event) {
        event.preventDefault();
        save();
    }

    function addFromInput(event) {
        if (event.key !== 'Enter') return;
        event.preventDefault();
        addTag(event.currentTarget.value);
        event.currentTarget.value = '';
    }
</script>

<form class="editor" onsubmit={submit}>
    <input bind:value={title} placeholder="Title">
    {#if errors.title}<p class="error">{errors.title[0]}</p>{/if}

    <textarea bind:value={body} rows="8"></textarea>
    <p class="muted">{words} words</p>

    <ul class="tags">
        {#each tags as tag, index (tag)}
            <li>{tag} <button type="button" onclick={() => removeTag(index)}>×</button></li>
        {/each}
    </ul>
    <input placeholder="Add a tag" onkeydown={addFromInput}>

    <button disabled={save.processing}>{save.slow ? 'Saving…' : 'Save'}</button>
    {#if unsaved.length}<span class="muted">Unsaved changes</span>{/if}
    {#if savedAt}<span class="muted">Saved at {savedAt}</span>{/if}
</form>

Place it like any component, from a Blade page or layout. The class's folder is part of its name:

Blade
<nitro:svelte.post-editor :post="$post" />

Nothing else is needed. title is $bindable, so bind:value writes the property as you type, and what reads title updates. addTag() runs in the browser, compiled from the PHP. save() runs on the server, checks its rules there, and comes back with what it changed.

Nothing to import

$props() is a Svelte rune, built into the compiler, so the view imports nothing, from Nitro or from Svelte, to read the component. useNitro() from nitro/svelte is for components deeper inside the view, to reach the Nitro component without passing props down (see below).

The props

Example What it is
Properties title , tags The values, by their PHP names. Arrays are arrays or objects, models are objects with their attributes, uploads are Upload objects
#[Computed] values words Worked out again when what they read changes
Browser methods addTag(tag) Run in the browser. They return the method's value
#[Server] methods save() Run on the server. They return a promise of the method's value, and carry the call's status
errors errors.title?.[0] The validation messages, by field, as $errors has them in Blade
unsaved unsaved.length The properties changed in the browser that the server hasn't seen yet
uploads uploads.cover?.progress Each property's upload while it runs: uploading , progress (0 to 100) and error
set(path, value) set('form.email', value) Writes a property, or a key under it
upload(path, files) upload('cover', files) Uploads files into an Upload property
dispatch(event, ...) dispatch('saved', id) Sends an event to the components listening for it
link(href, ...modifiers) {...link('/posts')} The attributes of a nitro:navigate link
visit(url) visit('/posts') Goes to a page without reloading

Runes and the view's own state

Destructuring $props() keeps the values reactive, as in any Svelte 5 component: when the component changes, what reads that value updates, and nothing else does. Values worked out from props are $derived, work that follows them is $effect, and the view's own state is $state:

Svelte
<script>
    let { title = $bindable(), words, save, errors } = $props();

    /** The view's own state, as in any Svelte component. */
    let said = $state('');

    /** Props are reactive: what reads them follows the component. */
    let readingTime = $derived(Math.max(1, Math.round(words / 200)));

    $effect(() => {
        document.title = title ? `Editing: ${title}` : 'New post';
    });

    async function submit(event) {
        event.preventDefault();
        said = (await save()) ?? '';
    }
</script>

<form onsubmit={submit}>
    <input bind:value={title}>
    {#if errors.title}<p class="error">{errors.title[0]}</p>{/if}
    <p>{words} words, about {readingTime} min to read</p>

    <button disabled={save.processing}>Save</button>
    <p>{said}</p>
</form>

Keep in $state what belongs to the view alone: whether a menu is open, the text of a tag not yet added. What the server should see, or another component should hear about, belongs on the PHP class.

Changing the component

There are three ways, and they all write the property at once, in the browser:

  • Bindings on a $bindable property: bind:value, bind:checked, bind:group, on the property or a key deep inside it (bind:value={form.email}, bind:value={line.quantity} in an {#each}). Assigning the prop (form = {...}) writes it too. It is what nitro:model does in Blade.
  • set(path, value) writes a property, or a key inside one by its dotted path, without declaring it bindable: set('form.email', '').
  • A browser method, which is the PHP you wrote, compiled: addTag(tag), removeLine(index).
Svelte
<script>
    let { form = $bindable(), lines = $bindable(), set, addLine, removeLine } = $props();

    function reset() {
        /** An assignment to a $bindable prop writes the property, as bind:value does. */
        form = { email: '', gift: false };
    }
</script>

<!-- bind: on a $bindable property, or a key deep inside one. -->
<input bind:value={form.email}>
<label>
    <input type="checkbox" bind:checked={form.gift}>
    It's a gift
</label>

{#each lines as line, index (line.sku)}
    <div>
        {line.name}
        <input type="number" bind:value={line.quantity}>
        <button onclick={() => removeLine(index)}>Remove</button>
    </div>
{/each}

<!-- Or the PHP's own method, compiled to run here, or set() by path. -->
<button onclick={() => addLine('SKU-42')}>Add a line</button>
<button onclick={() => set('form.email', '')}>Clear the email</button>
<button onclick={reset}>Start again</button>

Declare what you bind with $bindable()

As with any Svelte component, a prop is written back only when it is $bindable: let { title = $bindable() } = $props(). Without it, bind:value={title} or title = '...' changes the view's own copy of the prop, and the component never hears of it.

The server sees these changes with the next #[Server] call, which sends them along. Until then they are unsaved. A browser method that changes several properties updates the view once, not once per property.

A method is called with the arguments you give it: onclick={save} works as well as onclick={() => save()}. The event Svelte passes on its own is left out, since a PHP method can't take one.

Calling the server

A #[Server] method returns a promise of its return value. While the call runs, the method itself carries its status, reactive like the rest of the props:

True while In Blade
save.processing The call is running nitro:loading
save.slow It has been running for 200 ms nitro:loading.delay
save.failed The request itself failed: an error on the server, or no connection
save.error Why it failed, as a message
Svelte
<script>
    let { save } = $props();
    let notice = $state('');

    async function submit() {
        try {
            /** The method's return value; null when a rule failed (errors has the messages). */
            const said = await save();
            notice = said ?? 'Please fix the highlighted fields.';
        } catch {
            /** The request itself failed: the server's error, or no connection. save.error says why. */
            notice = `Not saved: ${save.error}`;
        }
    }
</script>

<button disabled={save.processing} onclick={submit}>
    {#if save.slow}Still saving…{:else if save.processing}Saving{:else}Save{/if}
</button>
{#if save.failed}<p class="error">{save.error}</p>{/if}
<p>{notice}</p>

Rules that fail are not a failed call. When $this->validate() refuses the data, the promise resolves with null, and errors has the messages by field. Only a request that fails, such as a 500, a 403 or no network, rejects the promise and sets failed.

Svelte's {#await} block works with the promise too, when you'd rather show its result where it lands: {#await saving then said}{said}{/await}.

Validation messages

errors holds the messages by field, including nested ones (errors['lines.0.quantity']). They come from the server when a #[Server] method validates, and are cleared for a field once it passes: {#if errors.title}<p>{errors.title[0]}</p>{/if}.

Unsaved changes

unsaved lists the properties changed in the browser that the server hasn't seen yet. It is empty again once a #[Server] call has taken them: {#if unsaved.length}Unsaved changes{/if}.

Uploading files

A property of type Upload (or an array with #[Uploads], for several files) takes files. upload('cover', files) sends them as soon as they are chosen, and the property becomes the uploaded file. uploads.cover follows the upload while it runs:

Svelte
<script>
    let { cover, upload, uploads, errors } = $props();
</script>

<label class="cover">
    <span>Cover image</span>
    <input type="file" accept="image/*" onchange={(e) => upload('cover', e.currentTarget.files)}>
</label>

{#if uploads.cover?.uploading}
    <progress max="100" value={uploads.cover.progress}></progress>
{/if}
{#if errors.cover}<p class="error">{errors.cover[0]}</p>{/if}

{#if cover}
    <figure>
        <img src={cover.temporaryUrl()} alt="">
        <figcaption>{cover.getClientOriginalName()}, {Math.round(cover.getSize() / 1024)} KB</figcaption>
    </figure>
{/if}
  • A preview: cover.temporaryUrl() is a URL the browser can show for an image.
  • What it is: getClientOriginalName(), getClientOriginalExtension(), getSize() and getMimeType(), as on the server.
  • Refused files: a file the nitro.uploads.rules refuse puts the message in errors.cover, and the property keeps what it had. The promise resolves with null.
  • Several files: for an #[Uploads] array, upload('photos', files) sets the list.
  • Keeping them: the file is stored for good only when a #[Server] method calls $this->cover->store(...), after its rules (image, max:2048) pass.

link(href, ...modifiers) gives the attributes of a nitro:navigate link: spread them on an <a>, and the visit happens without a reload. (Svelte can't write nitro:navigate.keypress as an attribute name, and drops an attribute with no value, which is why it is a function.) The modifiers are the ones a Blade link takes: 'keypress', 'preserve-scroll', 'transition', and 'prefetch' or 'prefetch.visible' (with its times: 'prefetch.150ms'), which adds nitro:prefetch. To go somewhere from code, call visit(url):

Svelte
<script>
    let { postId, link, visit } = $props();
</script>

<nav>
    <a {...link('/posts')}>All posts</a>
    <a {...link(`/posts/${postId}/preview`, 'prefetch.visible')}>Preview</a>
</nav>

<select onchange={(e) => visit(`/posts?status=${e.currentTarget.value}`)}>
    <option value="draft">Drafts</option>
    <option value="published">Published</option>
</select>

Events

dispatch('event', ...params) sends an event to every component that listens for it with #[On('event')], whatever its view is written in: Blade, Vue, React, Solid or Svelte. To react to an event, add an #[On] method to the component's class. It runs in the browser, changes the properties, and the view updates.

An event dispatched without ->to() also reaches the window, so <svelte:window> can listen for it too, and Svelte removes the listener with the view. That's useful for a toast or an animation that needs no PHP:

Svelte
<script>
    let { dispatch } = $props();
    let lastSaved = $state(null);
</script>

<!-- An event another component dispatched (without ->to()), heard in the view's own code: its values in order. -->
<svelte:window onpost-saved={(event) => (lastSaved = event.detail.params[0])} />

<button onclick={() => dispatch('preview-requested', 42)}>Preview</button>
{#if lastSaved}<p>Post {lastSaved} was just saved.</p>{/if}

Svelte components inside the view

The view is the root of a Svelte tree, so it can render any Svelte component: your own, or a library's. A component anywhere inside the view reaches the Nitro component with useNitro(), without passing props down. It returns the reactive object the view's props come from, so read from it in place (nitro.tags); a value destructured from it is a copy that won't follow. Bindings on it write the component, as the view's do: <input bind:value={nitro.title}>.

Svelte
<!-- resources/views/svelte/post-editor.svelte -->
<script>
    import TagList from '../../js/components/TagList.svelte';
</script>

<TagList />

<!-- resources/js/components/TagList.svelte: an ordinary Svelte component, inside the view -->
<script>
    import { useNitro } from 'nitro/svelte';

    /** Read from it in place: nitro.tags follows the component, a copy taken from it would not. */
    const nitro = useNitro();
</script>

<ul>
    {#each nitro.tags as tag, index (tag)}
        <li>{tag} <button onclick={() => nitro.removeTag(index)}>×</button></li>
    {/each}
</ul>

Nitro components go around a Svelte view, not inside it

A Svelte view can't render <nitro:...> components. Compose them from Blade: a Blade page or component can hold several components with Svelte, Vue, React, Solid and Blade views side by side, and they talk through events and stores.

Context

Each component with a Svelte view is its own Svelte app, and the view is at its top. What the view puts in context with setContext(), every component inside it can get:

Svelte
<script>
    import { setContext } from 'svelte';
    import { createTheme } from '../../js/theme.svelte.js';
    import Toolbar from '../../js/components/Toolbar.svelte';

    let { title = $bindable() } = $props();

    /** The view is the root of its tree: what it puts in context, every component inside can get. */
    setContext('theme', createTheme());
</script>

<Toolbar />
<input bind:value={title}>

For state that several views on a page share, export it from a .svelte.js module (a $state object) and import it in each view, or use Nitro's stores, which the server and other engines see too.

The PHP in the .svelte file

A small component can live in one file. Put its class in a <php> block at the top of the .svelte file. Nitro makes it a class named after the view, and Nitro's Vite plugin leaves the block out of what Svelte compiles. Component and Nitro's attributes need no use; anything else does.

resources/views/svelte/newsletter.svelte
<php>
use App\Models\Subscriber;

new class extends Component {
    #[Validate('required|email')]
    public string $email = '';

    public bool $done = false;

    #[Server]
    public function subscribe(): void
    {
        $this->validate();
        Subscriber::firstOrCreate(['email' => $this->email]);
        $this->done = true;
    }
};
</php>

<script>
    let { email = $bindable(), done, subscribe, errors } = $props();
</script>

{#if done}
    <p>Thanks: you're on the list.</p>
{:else}
    <form onsubmit={(e) => (e.preventDefault(), subscribe())}>
        <input type="email" bind:value={email} placeholder="you@example.com">
        <button disabled={subscribe.processing}>Subscribe</button>
        {#if errors.email}<p class="error">{errors.email[0]}</p>{/if}
    </form>
{/if}

Place it by the view's name, <nitro:newsletter />. An error in the block names the line in the .svelte file.

Server rendering

With server rendering on, the page's first HTML has the Svelte view rendered in it, and the browser hydrates it. Svelte's plugin compiles the views for the server when Vite builds the render service, so nothing changes in your Vite config. Add the views to the service:

JavaScript
// resources/js/ssr.js
import { serve } from 'nitro/ssr';
import { renderSvelte } from 'nitro/svelte/server';

serve({
    svelte: renderSvelte(import.meta.glob(['../views/svelte/**/*.svelte', './svelte/**/*.svelte'], { eager: true })),
});
  • Code that needs the browser (window, localStorage, measuring an element) goes in $effect() or onMount(), which run only in the browser, as in any server-rendered Svelte app.
  • On the server, a view only reads. It can't call methods or set() while it renders.
  • A view that fails to render on the server is left to the browser, which renders it as it would without server rendering.
  • Write attribute values out: data-open="true", not a bare data-open. Svelte's browser build drops an attribute with no value, so the server's HTML and the browser's would differ.

Coming from Blade

Blade Svelte
nitro:model="title" bind:value={title} (with title = $bindable() )
nitro:model.blur="title" value={title} onchange={(e) => set('title', e.currentTarget.value)}
nitro:click="addTag('svelte')" onclick={() => addTag('svelte')}
nitro:submit="save" onsubmit={(e) => (e.preventDefault(), save())}
nitro:loading ( nitro:target="save" ) {#if save.processing}
nitro:loading.delay {#if save.slow}
nitro:loading.attr="disabled" disabled={save.processing}
nitro:dirty {#if unsaved.length}
@error('title') {{ $message }} @enderror {errors.title?.[0]}
nitro:model on a file input onchange={(e) => upload('cover', e.currentTarget.files)}
<a href="/posts" nitro:navigate> <a {...link('/posts')}>
$dispatch('saved', $id) dispatch('saved', id)
$visit('/posts') visit('/posts')
{{ $this->words }} {words}

Good to know

  • The view's root is rendered inside the component's element, a <div> Nitro puts on the page. A view's <style> is scoped as usual.
  • Transitions: Svelte's transition: and animate: work inside the view. Blade's nitro:transition is for Blade views.
  • TypeScript: <script lang="ts"> works as usual. The props aren't typed for you yet; describe them with an interface of your own.
  • A visit to a page loads its Svelte views' code before the page shows, so the page appears with them rendered, not filling in after it.

Next steps