Svelte Integration
Astro ships zero JavaScript by default, but real apps need pockets of interactivity. The @astrojs/svelte integration lets you author those pockets as Svelte components and hydrate them selectively as islands. You write .svelte files exactly as you would in a SvelteKit project, drop them into .astro pages, and Astro handles the bundling, scoping, and partial hydration. The result is a static-first page that ships interactive Svelte only where you ask for it.
Installing the integration
The fastest path is the astro add command, which installs the package, registers it in your config, and adds the required peer dependencies in one step.
npx astro add svelte
This installs @astrojs/svelte and svelte, then writes the integration into astro.config.mjs. If you prefer to wire it up manually:
npm install @astrojs/svelte svelte
// astro.config.mjs
import { defineConfig } from 'astro/config';
import svelte from '@astrojs/svelte';
export default defineConfig({
integrations: [svelte()],
});
Astro supports Svelte 5 out of the box, including runes ($state, $derived, $effect). Existing Svelte 4 components continue to work without changes.
Rendering a Svelte island
A Svelte component is just a .svelte file. Place it under src/components/ and import it into any .astro page. By default the component renders to static HTML at build time and ships no JavaScript — it only becomes interactive when you add a client:* directive.
---
// src/components/Counter.svelte uses Svelte 5 runes
---
<script>
let { start = 0 } = $props();
let count = $state(start);
</script>
<button onclick={() => count++}>
Clicked {count} {count === 1 ? 'time' : 'times'}
</button>
---
// src/pages/index.astro
import Counter from '../components/Counter.astro';
import CounterSvelte from '../components/Counter.svelte';
---
<html lang="en">
<body>
<h1>Welcome</h1>
<!-- Static: rendered to HTML, no JS shipped -->
<CounterSvelte start={5} />
<!-- Interactive island: hydrates in the browser -->
<CounterSvelte client:load start={5} />
</body>
</html>
Props pass from Astro into Svelte as plain serializable values. They are JSON-serialized when crossing the server/client boundary, so functions and class instances cannot be passed to a hydrated island.
Without a
client:*directive, the component is rendered once on the server and the<button>will not respond to clicks. This is the zero-JS-by-default behavior — opt in to interactivity deliberately.
Choosing a hydration strategy
The client:* directive controls when and whether the island’s JavaScript loads. Pick the lightest strategy that meets the need.
| Directive | When it hydrates | Best for |
|---|---|---|
client:load | Immediately on page load | Above-the-fold, critical UI |
client:idle | When the browser is idle | Lower-priority widgets |
client:visible | When the element scrolls into view | Below-the-fold islands |
client:media={query} | When a media query matches | Mobile-only or desktop-only UI |
client:only="svelte" | Client-side only, skips SSR | Components that touch window/document |
---
import Chart from '../components/Chart.svelte';
---
<Chart client:visible data={[1, 2, 3]} />
Use client:only="svelte" when a component depends on browser-only globals and cannot render on the server. The "svelte" value tells Astro which renderer to use on the client.
Sharing state with Svelte stores
Each hydrated island is its own isolated runtime, so two <Counter client:load /> instances do not share state automatically. To share reactive state across islands — and even across components written in different frameworks — use a Svelte store defined in a standalone .ts module.
// src/stores/cart.ts
import { writable, derived } from 'svelte/store';
export const items = writable<string[]>([]);
export const count = derived(items, ($items) => $items.length);
export function addItem(name: string) {
items.update((list) => [...list, name]);
}
<!-- src/components/CartButton.svelte -->
<script>
import { addItem } from '../stores/cart';
</script>
<button onclick={() => addItem('Astro sticker')}>Add to cart</button>
<!-- src/components/CartBadge.svelte -->
<script>
import { count } from '../stores/cart';
</script>
<span class="badge">{$count}</span>
---
import CartButton from '../components/CartButton.svelte';
import CartBadge from '../components/CartBadge.svelte';
---
<CartBadge client:load />
<CartButton client:load />
Because both islands import the same store module, Vite bundles a single shared instance and clicking the button updates the badge. Note that the store only lives in the browser — its value resets on each full page navigation. For state that must persist across pages, hydrate the store from localStorage inside an onMount callback.
Sharing stores works only between islands hydrated on the same page. Server-rendered (non-hydrated) Svelte components run during build and never see runtime store updates.
TypeScript and scoped styles
Svelte’s <style> blocks are scoped to the component automatically, matching Astro’s own scoping model — no extra configuration needed. For typed props, use the lang="ts" attribute on the script and standard Svelte 5 $props() typing.
<script lang="ts">
interface Props {
label: string;
disabled?: boolean;
}
let { label, disabled = false }: Props = $props();
</script>
<button {disabled}>{label}</button>
Best practices
- Render Svelte components without a
client:*directive whenever the markup is purely presentational — keep the page zero-JS. - Prefer
client:visibleorclient:idleoverclient:loadfor anything below the fold to minimize main-thread work. - Pass only serializable props to hydrated islands; functions and class instances will not survive the JSON boundary.
- Centralize cross-island state in standalone store modules rather than passing props through multiple layers.
- Use
client:only="svelte"for components that readwindow,document, or other browser-only APIs to avoid SSR errors. - Pin compatible
@astrojs/svelteandsvelteversions together; the integration tracks Svelte’s major releases closely.