Skip to content

Hydration

Svelte’s compiler-generated custom elements cannot hydrate on their own. When an element upgrades, its constructor attaches a shadow root, which per spec clears any declarative shadow root the server delivered, and then mounts the component from scratch. Server-rendered DOM was paint-and-throw-away: good for first paint and SEO, but every node was re-created the moment JavaScript arrived.

Components built with @svebcomponents/build hydrate instead. The server-rendered shadow DOM is adopted in place: existing nodes are kept, component styles are reused rather than re-injected, and the component is fully reactive afterwards.

Hydration is on by default for every SSR-enabled component. There is nothing to configure, but it helps to know how it works and where its limits are today.

Everything is built on documented Svelte extension points, not forked compiler output:

  1. During the build, auto-options injects Svelte’s official customElement.extend option, wiring in a wrapper class from @svebcomponents/ssr/hydration.
  2. The wrapper intercepts the attachShadow call in the generated element’s constructor and claims the declarative shadow root before it would be cleared.
  3. On connect, the wrapper hydrates the claimed shadow root with Svelte’s public hydrate() API instead of mounting.
  4. Both sides render the component through the same internal HydrationHost component, so the server markup structurally matches what the client expects: the generated SSR entry uses a server-compiled copy, and the client hydrates against it.
  5. The same symmetry protects the element inside hydrating Svelte host apps such as SvelteKit. The SSR wrapper and the browser wrapper render structurally identical <svelte:element> fragments, selected per environment through export conditions rather than a runtime branch, so the host’s hydration claims the server-rendered element instead of re-creating it. Re-creating it would discard the shadow root before the element could hydrate.
  6. Rich, non-attribute props the server rendered with are serialized into the shadow output and restored before hydrating, so host frameworks that only re-supply props after their own asynchronous hydration do not cause a mismatch.

Anything the wrapper cannot hydrate falls back to Svelte’s untouched mount path, which is exactly how all custom elements behaved before hydration existed:

SituationBehaviour
No declarative shadow root (the element was never server-rendered)Plain client mount
The component declares slots (see below)Plain client mount
Re-connection after teardown (removed long enough for Svelte to destroy it)Fresh mount
Hydration mismatchSvelte logs a warning, clears the shadow root, re-mounts

The worst outcome is a re-render, never a broken component.

Components that declare <slot> elements currently fall back to the mount path. They render correctly, but re-render on upgrade instead of adopting the server DOM. In dev mode the element logs a console.info when this happens, so the fallback is never mistaken for broken hydration.

Svelte 5 still compiles <slot> elements in custom-element mode through a legacy transformation that creates slot elements imperatively, with no hydration awareness. That transformation is expected to disappear with Svelte 6, at which point literal <slot> elements flow through Svelte’s normal, hydration-aware element path and this limitation should lift.

createEventDispatcher events are not forwarded through the hydration host. Events dispatched via $host(), the idiomatic pattern for custom elements, bubble natively and work unchanged.

exported component functions are not exposed as element methods on hydrated hosts.

Hydration support adds roughly 4–5 KB gzipped to a self-contained client bundle: Svelte’s hydration internals plus the wrapper. Builds that share the host application’s Svelte runtime through the svelte condition pay less. If a component is only ever used client-side, hydratable: false reclaims it.

Per package, in svebcomponents.config.ts:

import { defineConfig } from "@svebcomponents/build";
export default defineConfig({
hydratable: false,
});

Per component, declare your own extend in <svelte:options customElement={{ ... }}>. auto-options never injects over one you wrote, so the component keeps Svelte’s default mount behaviour unless your extension implements hydration itself.