Sharing Svelte Components: SvelteKit to Astro Migration
Published: 2026-07-12
A migration like this doesn’t have to happen in phases. You can build the whole Astro version on a branch, test it, and cut over in one merge — the old site keeps serving pages either way. I’ve done exactly that on this blog before, when I swapped out its UI library.
This time I chose to migrate in place, and the reason is the subject of this post: SvelteKit and Astro both render Svelte components, so the two apps could share them.
One src/lib/components/ directory, imported by SvelteKit and Astro alike, for the whole migration. The header, menu, footer, and theme switcher each lived in exactly one file, so every phase could merge to main on its own — no second copy to keep in sync, no long-lived branch drifting away from the site that was still live.
Sharing the files turned out to be easy, because almost nothing in them was SvelteKit-specific in the first place. The interesting part is the small handful of places that were — and how few edits it took to make those framework-agnostic. Let’s walk through them.
One SvelteKit import stood in the way
The theme system is a Svelte 5 rune-based singleton: it reads a saved preference from localStorage, resolves auto against prefers-color-scheme, and sets data-theme on the document. The only thing in it that knew it was running under SvelteKit was a single import.
import { browser } from '$app/environment';
$app/environment’s browser boolean is SvelteKit’s way of telling us whether code is running on the client or during a server render. Astro has no such module, so this line would break the moment Astro tried to compile the file. The fix is a single line — I replaced it with a plain check that means the same thing everywhere:
const browser = typeof window !== 'undefined';
Notice there’s no framework in that line at all. It’s true in the browser, false during any server render, and it reads identically to SvelteKit and Astro. With that one swap, the theme singleton became shareable — and the rest of the components had no SvelteKit-specific imports to begin with, so they carried across untouched.
State that can’t cross an island boundary
Here’s where the two frameworks stop being interchangeable. In SvelteKit, the header and mobile menu share a single piece of state — drawerOpened — owned by the layout and threaded down with bind:.
<!-- SvelteKit +layout.svelte -->
<script lang="ts">
let drawerOpened = $state(false);
</script>
<Menu bind:drawerOpened>
<Header bind:drawerOpened />
{@render children()}
</Menu>
That works because one layout component owns both children in a single tree. Astro breaks that assumption. In Astro, interactive components are islands — each hydrated in isolation — and two separate islands can’t share a $state rune through bind:, because there’s no common Svelte parent holding it. Mount <Header> and <Menu> as two islands and drawerOpened has nowhere to live.
I had two ways out: invent a cross-island state channel (a shared store module, custom events), or move the boundary so the problem disappears. I moved the boundary. Rather than split the two components, I wrapped them into one — NavShell.svelte — that reproduces the layout’s exact nesting and owns drawerOpened locally.
<!-- NavShell.svelte -->
<script lang="ts">
let drawerOpened = $state(false);
</script>
<Menu bind:drawerOpened>
<Header bind:drawerOpened />
{@render children?.()}
</Menu>
Then Astro hydrates that single component as one island.
<NavShell client:load>
<slot />
</NavShell>
What matters here is that nothing new was invented. drawerOpened still lives in one Svelte component with bind: working normally — I just relocated the island boundary so the whole nav sits inside one island instead of being split across two. No new state pattern, and the same accessibility behavior (focus capture, Escape to close, backdrop click) carried over unchanged.
One detail made this seamless: @astrojs/svelte v9 wraps an Astro <slot /> in Svelte’s createRawSnippet, handing it to the component as an ordinary children snippet. So the {@render children?.()} above renders whatever page content Astro slots in, just as {@render children()} did under SvelteKit. The forwarded content arrives as static server-rendered HTML — which is fine here, since the page bodies are non-interactive markdown anyway.
The one script that must not become a module
There’s one script that must run before the page paints: the theme-flash preventer. It reads the saved theme from localStorage and sets data-theme on <html> synchronously, so a reader who chose a dark theme never sees a flash of light before hydration catches up.
Astro’s default handling breaks this. It processes every <script> in a .astro file, bundling it as a deferred ES module — and a deferred module runs after the document parses, which is too late to prevent the flash. The one-word fix is is:inline.
<script is:inline>
// reads localStorage, sets data-theme — must run before first paint
</script>
is:inline tells Astro to leave the script alone: no bundling, no type="module", no defer. Astro leaves the contents unprocessed and emits the script exactly where it was authored, so it runs synchronously in the <head> before the body renders. The thing to watch for is that without is:inline the script still works — it just runs late enough that the flash it exists to prevent comes right back.
A quieter trap: the long-lived dev server
One thing that never showed up in a component but is worth a sentence, because it cost me real confusion. Midway through, an astro dev server that had been running for over a day started throwing errors pointing at code I’d just touched — when the actual cause was that it had live-reloaded through a dependency swap (pulling SvelteKit’s own packages out) without a full restart. Vite’s partial config reload doesn’t safely survive a node_modules change underneath it.
The structural fix was a one-liner in package.json.
"postinstall": "astro dev stop"
Now any pnpm install kills a stray dev server rather than letting it drift into a corrupted state. When something breaks right after a dependency change, restarting the dev server is worth trying first, not last.
What sharing the files bought me
One set of component files paid off in a concrete way. Because SvelteKit and Astro imported the identical files, each phase’s pull request could merge to main — with SvelteKit still serving production, right up until the cutover — and there were never two diverging copies of a header to reconcile at the end. A component that needed fixing was fixed once, and both frameworks saw it.
Sharing the components came down to surprisingly few framework-specific edits: one import swap, one component boundary moved, one script attribute, one postinstall line. Everything else was already portable — it just took building on the assumption that it would be. The markdown pipeline was another story, and porting its KaTeX rendering took a detour of its own.
Further Reading
- Astro’s Svelte integration — how
@astrojs/svelterenders Svelte components and passes slotted content through as achildrensnippet. - Astro Islands — the partial-hydration model, and why interactive components are isolated rather than sharing one tree.
- Migrating from SvelteKit (Astro docs) — the official reference for equivalents between the two frameworks.