Svelte 5 landed and quietly broke a lot of muscle memory. Not because it removed things — slots still work — but because it added something better, and now you have to actually think about which tool to reach for. If you’ve been writing Svelte for a while, you know the slotted component pattern by heart. If you’re coming from React, render props are burned into your brain. Snippets are Svelte’s answer to both, and they’re genuinely good, but they’re not a silver bullet.
This isn’t a "snippets are amazing, migrate everything" post. It’s a map of the territory so you can make a real decision per use case.
The official Svelte 5 migration guide and RFC live on GitHub.
The Problem Slots Were Solving (And Where They Fall Apart)
Slots in Svelte 3/4 let a parent inject markup into a child component. Dead simple for basic cases:
<!-- Card.svelte (Svelte 4) -->
<div class="card">
<slot name="header" />
<div class="body">
<slot />
</div>
<slot name="footer" />
</div>
<!-- Usage -->
<Card>
<h2 slot="header">My Title</h2>
<p>Some content here.</p>
<button slot="footer">Close</button>
</Card>
Clean, declarative, zero boilerplate. For layout components — cards, modals, sidebars — slots are exactly right. The mental model is spatial: you’re filling regions of a template.
Where slots crack is when the child needs to hand data back up to the parent’s injected markup. Scoped slots handle this, but the syntax is a mess that confuses everyone at least once:
<!-- List.svelte (Svelte 4) -->
<ul>
{#each items as item}
<slot {item} />
{/each}
</ul>
<!-- Usage - the "let:" syntax trips people up -->
<List {items} let:item>
<li>{item.name}</li>
</List>
let:item is a valid but weird syntax. TypeScript support for scoped slots was bolted on and never felt native. You couldn’t easily pass multiple scoped slots. You couldn’t reuse a slot definition. And you definitely couldn’t store a slot in a variable and pass it around as data.
That’s the ceiling slots hit.
Render Props: The React Import Nobody Wanted to Admit Using
Render props in React are a pattern where you pass a function as a prop, and the component calls that function to get the JSX it should render. This is great for headless/unstyled components that own behavior but not presentation.
In Svelte 4, you could fake this by passing a component as a prop:
<!-- DataGrid.svelte (Svelte 4 hack) -->
<script>
export let columns;
export let RowComponent; // pass an entire component
</script>
{#each rows as row}
<svelte:component this={RowComponent} {row} />
{/each}
It works, but <svelte:component> is a runtime thing with overhead, you lose static analysis, and passing props through is awkward. The TypeScript story is basically nonexistent — RowComponent is typed as ComponentType which tells you almost nothing about what props it actually expects.
This is why some teams dragged render props from React despite Svelte not really wanting them.
Snippets: What Actually Changed
Svelte 5 ships {#snippet} and {@render}. A snippet is a reusable chunk of markup that can accept parameters, be passed as a prop, and be rendered anywhere with {@render}.
<!-- Basic snippet definition and use, same file -->
{#snippet greeting(name)}
<p>Hello, {name}!</p>
{/snippet}
{@render greeting('world')}
That’s local to a single component. The interesting part is that snippets are first-class values — they can be passed as props:
<!-- Parent -->
<script>
import DataGrid from './DataGrid.svelte';
</script>
{#snippet rowTemplate(row)}
<tr>
<td>{row.id}</td>
<td class="font-bold">{row.name}</td>
<td class:highlight={row.value > 100}>{row.value}</td>
</tr>
{/snippet}
<DataGrid {rows} row={rowTemplate} />
<!-- DataGrid.svelte -->
<script>
import type { Snippet } from 'svelte';
interface Row { id: number; name: string; value: number; }
let { rows, row }: { rows: Row[]; row: Snippet<[Row]> } = $props();
</script>
<table>
<tbody>
{#each rows as r}
{@render row(r)}
{/each}
</tbody>
</table>
This is render props, done properly. The type Snippet<[Row]> tells TypeScript exactly what parameters the snippet accepts. The parent keeps full control over rendering. The child owns the data loop and none of the presentation.
The children Snippet
The default slot becomes children automatically. Any content you put between component tags is available as props.children:
<!-- Button.svelte -->
<script>
let { children, onclick, variant = 'primary' } = $props();
</script>
<button class="btn btn-{variant}" {onclick}>
{@render children()}
</button>
<!-- Usage -->
<Button onclick={handleSave}>
<Icon name="save" /> Save Changes
</Button>
No <slot /> in sight. children is just a Snippet<[]> with no parameters, and you render it with {@render children()}. Named slots become named snippet props — you define a snippet in the parent and pass it by name.
A Real-World Example: The Dreaded Data Table
Nothing stress-tests component composition like a configurable data table. Let’s build one that handles sorting state internally but leaves all rendering to the consumer.
<!-- SortableTable.svelte -->
<script lang="ts">
import type { Snippet } from 'svelte';
type Row = Record<string, unknown>;
interface Column<T extends Row> {
key: keyof T;
label: string;
sortable?: boolean;
}
let {
rows,
columns,
rowSnippet,
emptySnippet,
}: {
rows: Row[];
columns: Column<Row>[];
rowSnippet: Snippet<[Row, number]>; // row + index
emptySnippet?: Snippet<[]>;
} = $props();
let sortKey = $state<string | null>(null);
let sortDir = $state<'asc' | 'desc'>('asc');
function toggleSort(key: string) {
if (sortKey === key) {
sortDir = sortDir === 'asc' ? 'desc' : 'asc';
} else {
sortKey = key;
sortDir = 'asc';
}
}
let sorted = $derived(() => {
if (!sortKey) return rows;
return [...rows].sort((a, b) => {
const av = a[sortKey!];
const bv = b[sortKey!];
const cmp = av < bv ? -1 : av > bv ? 1 : 0;
return sortDir === 'asc' ? cmp : -cmp;
});
});
</script>
<table class="data-table">
<thead>
<tr>
{#each columns as col}
<th
class:sortable={col.sortable}
class:sorted={sortKey === col.key}
onclick={() => col.sortable && toggleSort(String(col.key))}
>
{col.label}
{#if col.sortable && sortKey === col.key}
<span class="sort-icon">{sortDir === 'asc' ? '↑' : '↓'}</span>
{/if}
</th>
{/each}
</tr>
</thead>
<tbody>
{#if sorted().length === 0}
{#if emptySnippet}
{@render emptySnippet()}
{:else}
<tr><td colspan={columns.length}>No data.</td></tr>
{/if}
{:else}
{#each sorted() as row, i}
{@render rowSnippet(row, i)}
{/each}
{/if}
</tbody>
</table>
The consumer:
<script lang="ts">
import SortableTable from './SortableTable.svelte';
const users = [
{ id: 1, name: 'Alice', role: 'admin', active: true },
{ id: 2, name: 'Bob', role: 'viewer', active: false },
];
const columns = [
{ key: 'name', label: 'Name', sortable: true },
{ key: 'role', label: 'Role', sortable: true },
{ key: 'active', label: 'Status' },
];
</script>
{#snippet userRow(row, i)}
<tr class:even={i % 2 === 0}>
<td class="font-medium">{row.name}</td>
<td><span class="badge badge-{row.role}">{row.role}</span></td>
<td>
{#if row.active}
<span class="text-green-600">Active</span>
{:else}
<span class="text-red-500">Inactive</span>
{/if}
</td>
</tr>
{/snippet}
{#snippet noUsers()}
<tr>
<td colspan="3" class="text-center py-8 text-gray-400">
No users match your filters.
</td>
</tr>
{/snippet}
<SortableTable
{users}
{columns}
rowSnippet={userRow}
emptySnippet={noUsers}
/>
Try doing this cleanly with Svelte 4 scoped slots. It works eventually, but it’s more ceremony and the TypeScript types are fragile.
The Decision Framework
Here’s how I actually decide:
Use slots (the old way) when:
- You’re maintaining a Svelte 3/4 component and migration cost isn’t justified
- It’s a pure layout component with no data flowing from child → injected markup
- The team is mid-migration and mixing both in one file adds confusion
Use children and named snippets as props when:
- You’re writing new Svelte 5 components — full stop, this is the default
- You want TypeScript to actually help you
- The composition pattern is anything beyond "put content here"
Use snippets passed as props (render props style) when:
- The child component drives state or behavior (sort, pagination, selection, drag) and the parent drives presentation
- You need to render the same snippet in multiple places
- You’re building a headless component library
Keep using <svelte:component> when:
- The component itself (not just its output) needs to be dynamic — A/B tested, plugin-loaded, or conditionally different at a type level
- You genuinely don’t know the component shape at compile time
Gotchas
Snippets don’t cross component boundaries automatically. A snippet defined in a parent is not magically available in a grandchild. You have to explicitly pass it down. This is actually a feature — it makes data flow obvious — but it surprises people expecting slot-like fallthrough.
children is not optional by default. If you destructure children from $props() and someone uses your component without any child content, children is undefined and {@render children()} throws. Guard it:
{#if children}
{@render children()}
{/if}
Or type it as optional: children?: Snippet<[]>.
Snippets can’t be async. You can’t await inside a snippet. If you need async rendering, handle the promise in the component’s $state and pass the resolved value into the snippet as a parameter.
Local snippets shadow prop snippets. If you define a {#snippet row(...)} locally inside the same file that also receives a row snippet prop, the local one wins. Name them carefully.
Slots and snippets don’t mix inside one component. A Svelte 5 component can use either the slot model or the snippet model for a given "hole", not both. If you’re migrating incrementally, pick a side per component.
Production-Ready Patterns
Provide snippet defaults
Don’t force consumers to always pass a snippet for non-critical slots. Accept an optional snippet and render a sensible fallback:
<script>
let { children, header } = $props();
</script>
<div class="panel">
<header>
{#if header}
{@render header()}
{:else}
<h2>Untitled</h2>
{/if}
</header>
<main>{@render children()}</main>
</div>
Type snippet parameters precisely
Vague types kill DX. Be explicit:
import type { Snippet } from 'svelte';
type CellSnippet = Snippet<[{ value: string; rowIndex: number; colKey: string }]>;
When the consumer writes their snippet, TypeScript will auto-complete the parameter shape. This is the main reason to prefer snippets over <svelte:component> for render-delegation.
Snippet composition with intermediate variables
Snippets are values. You can conditionally assign them:
<script>
import type { Snippet } from 'svelte';
let { compact, fullRow, compactRow }: {
compact: boolean;
fullRow: Snippet<[item: Item]>;
compactRow: Snippet<[item: Item]>;
} = $props();
// Pick the right template at runtime
const activeRow = compact ? compactRow : fullRow;
</script>
{#each items as item}
{@render activeRow(item)}
{/each}
Clean, no conditionals inside the loop, works with TypeScript.
Migration Checklist: Slots → Snippets
If you’re moving an existing Svelte 4 component:
- Replace
<slot />with{@render children()}and addchildrento$props() - Replace
<slot name="X" />with{@render X?.()}and addX?: Snippet<[]>to props - Replace scoped
<slot {value} />with{@render X(value)}and type the snippet asSnippet<[typeof value]> - In the consumer: wrap named slot content in
{#snippet X()}...{/snippet}and pass as a prop attribute - Replace
let:valueon the consumer side with a snippet parameter - Run TypeScript — it will catch mismatches immediately
One gotcha during migration: if you’re in a Svelte 5 project but consuming a Svelte 4 library component that uses slots, the slot syntax still works from the consumer side. Svelte 5 is backward-compatible here. You only need to migrate components you own.
When Render Props Still Win
Even in Svelte 5, there are cases where passing a whole component (the classic render-prop spirit) is the right call. If you’re building something like a chart library where the axis label component needs to have its own local state, reactive subscriptions, or lifecycle hooks, a snippet won’t cut it — snippets are markup templates, not component instances. You’ll still use <svelte:component> or simply accept a component constructor as a prop.
The line is: snippets are for markup delegation, components are for behavior delegation. If the thing you’re passing needs to own state, it’s a component. If it just needs to describe how to render data the parent hands it, it’s a snippet.
Svelte 5 snippets genuinely solve the scoped-slot problem cleanly and make render-prop style composition idiomatic instead of a workaround. The upgrade path from slots is straightforward. The TypeScript gains are immediate and real.
Reach for children and named snippet props as your default for new components. Keep <slot /> when migrating cost outweighs benefit. Reserve <svelte:component> for the cases that actually need runtime component polymorphism. And when you find yourself duplicating layout logic across components with slightly different rendering, that’s your signal to pull the rendering out as a snippet prop.