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
-
Install Svelte and its Vite plugin
npm install svelte @sveltejs/vite-plugin-svelte -
Add the plugins, and register your views
nitroSvelte()takes your views asimport.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'])); -
Load them before Nitro starts
Put
@vitebefore@nitroScriptsin 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:
<?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():
<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:
<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:
<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
$bindableproperty: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 whatnitro:modeldoes 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).
<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 |
<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:
<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()andgetMimeType(), as on the server. - Refused files: a file the
nitro.uploads.rulesrefuse puts the message inerrors.cover, and the property keeps what it had. The promise resolves withnull. - 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.
Links and visits
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):
<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:
<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}>.
<!-- 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:
<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.
<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:
// 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()oronMount(), 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 baredata-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:andanimate:work inside the view. Blade'snitro:transitionis 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.