Symfony and Twig are excellent for server-side rendered pages. But what if individual components need reactive interactivity — an audio player with waveform visualization, a booking form with live availability checking, or a filterable song list? In one of my projects, I integrated Svelte 5 with Vite into an existing Symfony project for exactly this purpose. Here's how it works.
Why Svelte Instead of Vue or React?
Svelte compiles to pure JavaScript at build time — no virtual DOM, no framework overhead at runtime. For a website that is primarily server-side rendered and only needs a few interactive islands, this is ideal:
- Small bundle size: No framework code is shipped to the client
- No hydration problem: Svelte components are mounted into existing DOM elements
- TypeScript support: First-class since Svelte 5
The Architecture: Two-Phase Build
The core of the integration: SCSS is compiled separately (as before), and Vite only handles TypeScript + Svelte:
{
"scripts": {
"build": "npm run vite && npm run post-vite",
"vite": "vite build",
"post-vite": "node templates/assets/node/build-scss.js && node templates/assets/node/copy-assets.js && node templates/assets/node/compress.js"
}
}
Phase 1: vite build compiles TypeScript and Svelte components to public/assets/js/app.js with a manifest file for Symfony's cache busting.
Phase 2: Node scripts handle SCSS compilation, asset copying and Brotli/gzip compression.
Vite Configuration
import { defineConfig } from 'vite';
import { svelte } from '@sveltejs/vite-plugin-svelte';
import symfonyPlugin from 'vite-plugin-symfony';
import path from 'path';
export default defineConfig({
plugins: [
symfonyPlugin(),
svelte()
],
root: 'templates/assets/ts/src/',
build: {
outDir: path.resolve(__dirname, 'public/assets/js'),
emptyOutDir: true,
manifest: true,
rollupOptions: {
output: {
entryFileNames: 'app.js',
chunkFileNames: '[name].js',
assetFileNames: '[name].[ext]',
},
input: {
app: path.resolve(
__dirname,
'templates/assets/ts/src/main.ts'
)
}
}
}
});
vite-plugin-symfony is the bridge: it generates an entrypoints.json that Symfony can read to insert the correct file names with content hashes in Twig templates.
Svelte 5 Runes: The New Reactivity Model
Svelte 5 has fundamentally reworked the reactivity system. Instead of the magic $: labels, there are now explicit Runes:
| Svelte 4 | Svelte 5 | Purpose |
|---|---|---|
export let prop |
let { prop } = $props() |
Receive props |
let count = 0 |
let count = $state(0) |
Reactive state |
$: doubled = count * 2 |
let doubled = $derived(count * 2) |
Computed values |
$: { console.log(count) } |
$effect(() => { console.log(count) }) |
Side effects |
Here's a concrete example from the project — a song list with genre filter:
<script lang="ts">
import { onMount } from 'svelte';
import SongCard from './SongCard.svelte';
interface Song {
slug: string;
title: string;
genre: string;
file: string;
}
let { locale = 'de', showFilters = false }:
{ locale?: string; showFilters?: boolean } = $props();
let songs: Song[] = $state([]);
let loading: boolean = $state(true);
let selectedGenre: string = $state('');
let genres: string[] = $derived(
[...new Set(songs.map((s) => s.genre))].sort()
);
let filteredSongs: Song[] = $derived(
songs.filter((s) => {
if (selectedGenre && s.genre !== selectedGenre) return false;
return true;
})
);
onMount(async () => {
const res = await fetch('/api/songs/' + locale);
songs = await res.json();
loading = false;
});
</script>
Mount Instead of Constructor
In Svelte 5, new Component({target}) is replaced by mount(). The entry point (main.ts) shows how Svelte components are mounted into existing Twig templates:
import { mount } from 'svelte';
import SongList from './SongList.svelte';
document.addEventListener('DOMContentLoaded', () => {
const locale = document.documentElement.lang || 'de';
const songListTarget = document.getElementById('song-list-app');
if (songListTarget) {
mount(SongList, {
target: songListTarget,
props: {
locale: songListTarget.dataset.locale || locale,
showFilters: songListTarget.dataset.showFilters === 'true',
},
});
}
});
In the Twig template, only an empty container with data-* attributes is rendered:
<div id="song-list-app"
data-locale="{{ app.request.locale }}"
data-show-filters="true">
</div>
Hybrid Approach: Not Everything Needs Svelte
An important point: not every interaction needs a reactive framework. For simple DOM manipulation, plain TypeScript is sufficient. In my project, alongside the Svelte components there are also pure TS modules:
- consentPlayer.ts: GDPR-compliant loading of Mixcloud/YouTube embeds after user click
- bookingFormValidation.ts: Form validation with AbortController for request cancellation
// AbortController for race-condition-free availability checking
let currentAbortController: AbortController | null = null;
function checkAvailability(): void {
if (currentAbortController) {
currentAbortController.abort();
}
currentAbortController = new AbortController();
fetch('/api/check-availability?date=' + dateValue, {
signal: currentAbortController.signal
})
.then((res) => res.json())
.then((data) => { /* UI updaten */ })
.catch((error: Error) => {
if (error.name === 'AbortError') return;
console.error(error);
});
}
Playwright Tests
For E2E tests we use Playwright. It works excellently with Svelte components, since Playwright waits for the real DOM:
import { test, expect } from '@playwright/test';
test('song cards render after API load', async ({ page }) => {
await page.goto('/');
// Wait for async loading (WaveSurfer initializes)
await page.waitForFunction(
() => {
const btn = document.querySelector('.song-card-play-btn');
return btn && !btn.hasAttribute('disabled');
},
{ timeout: 15000 }
);
const cards = page.locator('.song-card');
await expect(cards.first()).toBeVisible();
});
test('only one song plays at a time', async ({ page }) => {
await page.goto('/');
await page.waitForSelector('.song-card-play-btn:not([disabled])');
const firstBtn = page.locator('.song-card-play-btn').first();
const secondBtn = page.locator('.song-card-play-btn').nth(1);
const firstVinyl = page.locator('.mini-vinyl').first();
await firstBtn.click();
await expect(firstVinyl).toHaveClass(/spinning/);
await secondBtn.click();
// First song stops automatically via CustomEvent
await expect(firstVinyl).not.toHaveClass(/spinning/);
});
Conclusion
The combination of Symfony + Svelte 5 + Vite yields a powerful setup: server-side rendering for SEO and fast load times, reactive Svelte islands for interactive components, and Vite for lightning-fast builds. The two-phase build may seem unusual at first, but it allows the existing SCSS build to remain untouched while integrating Svelte incrementally.
Kommentare
Kommentare werden von Remark42 bereitgestellt. Beim Laden werden Daten an unseren Kommentar-Server übertragen.