Portfolio

The how and why migrating a legacy e-commerce frontend to Astro + Svelte

Islands architecture applied to e-commerce. What I'd do, how I'd do it, and the mess I'd expect along the way.

The other day I was browsing an online store from a major retailer. I click on a pair of shoes, and I wait.. The page takes 5 seconds to become interactive. I open devtools (dev reflex, can’t help it..) and what do I see? 2.01MB of compressed JavaScript. For a product page.

2MB for a pair of shoes

A product page is essentially a structured document with a few interaction points. 90% of the page is display: images, text, specifications and maybe some reviews.. Data you read, not data you manipulate.

The only places where something actually happens on the user side is the purchase area (picking a variant, adding to cart..), search, and maybe an image carousel. Everything else is rendering from a fixed catalog.

But an SPA framework assumes that everything needs a runtime. A title, a price, an add-to-cart button, everything goes through the same pipeline. Each page ships the full framework, and therefore the hydration of every component.

Result: a product page heavier and slower than gmail.

In practice it looks like this: a product page where every component goes through the runtime, even the ones that just display text.

Header hydrated
Logo
Navigation
Search interactive
Cart interactive
Product Page hydrated
Gallery images · zoom
Info
Title · Price
Description
Reviews
BuyBox size · add to cart · shipping interactive
Footer links · newsletter · legal hydrated
App-first framework (SPA or MPA) · 2MB of JS · Everything goes through the runtime, even static content

How we got here

I’d guess there’s an e-commerce platform behind this that was already rendering HTML server-side. It probably wasn’t fast, but at least the browser received HTML and little JavaScript. Then someone wanted to “modernize” by building an SPA on top. The site didn’t get faster, it just got a full client-side rendering pipeline added to it. You’re paying the cost of a full application framework to render what is essentially a brochure.

Tracking scripts and everything else pile on top, but the problem isn’t the layers. It’s the foundation they’re piling on. If that foundation is already a full runtime that hydrates the entire page and ships code the page doesn’t even use, every addition starts from an already high baseline.

Going headless is the first step, but it’s not enough if the frontend remains an SPA. Everyone’s trying to reduce client-side JavaScript, but it’s retrofit on an app-first model, the runtime still travels to the client. What if we rethought the foundation?

Islands architecture

Islands architecture flips the problem. By default it’s static HTML, zero JavaScript. Interactivity is an explicit opt-in, only the components that need it hydrate. No global runtime, no client-side router, no hydration of the entire tree.

The tradeoff is state, each navigation reloads the page and client state disappears. But in e-commerce the states that matter (cart, session, store) already live on the server, each page reads them back.

Astro lets you pick the framework for the interactive parts. If you pick one that compiles without a runtime or virtual DOM, each island ships only its own code. SolidJS and Svelte come to mind. My personal preference is SolidJS (it’s what runs this site), but for an enterprise e-commerce migration Svelte is probably the better pick: larger ecosystem, more resources, easier to hire for.

I have some context with this approach. In 2020 I was contributing to ElderJS, a Svelte framework that did partial hydration before islands architecture existed as a term. I was involved in the plugin system, worked on reducing payload size and hydration footprint, and built elderjs-plugin-i18n. That’s what got me interested in the first place: understanding what partial hydration meant in practice, and why shipping less JavaScript was the right model for content sites.

The result on a product page: a fraction of the current JavaScript. In e-commerce this is directly tied to revenue. Every extra second of load time drops conversion. Core Web Vitals feed Google’s ranking signals, faster pages rank better, attract more organic traffic, convert more. Nobody notices a fast page. Everyone notices a slow one.

Header static
Logo
Navigation
Search island
Cart island
Product Page static
Gallery images · zoom island
Info
Title · Price
Description
Reviews
BuyBox island
Size selector
Add to cart
Stock · shipping
Footer links · newsletter · legal static
Islands architecture · Only islands ship JS

The model is there. Now how do you get there without breaking everything.

Migration plan

The migration should be incremental, we don’t want a big bang. The risk of breaking something is real, and the cost of a rollback is high if you have to roll back the entire site. Progressive migration means migrating one part at a time, validating it, then moving to the next.

The principle is simple: start with what doesn’t touch business sensitive parts. Lowest risk first, working your way up to the components that depend on backend work and purchase flows. Each phase validates the next.

From safest to riskiest · From leverage to backend work

One thing to know going in: during the migration, the site will be ugly under the hood. Two CSS systems on every page, components implemented twice.. This is normal. It’s the cost of not doing a big bang. The question isn’t if you’ll have this mess, it’s how long you stay in it.

The most fragile part is the seam between the two systems. Let’s say, a user adds a product from the legacy BuyBox, then the cart badge in the new layout needs to update. This flow crosses two frontends. No unit test can covers that, so E2E tests on critical flows that cross the boundary are the minimum safety net.

And if a migrated page regresses or breaks something, you need per-page rollback at the routing level. Send that one URL back to the legacy without touching the rest. The infrastructure for this is the first thing to put in place.

Phase 1 · Cohabitation

Before migrating anything, the two systems need to coexist. Same URL, same page but two frontends. The most straightforward way to do this is a reverse proxy that decides which system renders what.

Let’s say a user goes to store.com/product/shoes. The reverse proxy receives the request, routes to Astro for one part of the page, to the legacy for the other. The assembled page arrives at the browser as a whole, the user has no idea two systems participated.

CDN rewrite rules (Vercel, Cloudflare, Netlify) can handle the routing too, but a reverse proxy gives more control.

User store.com/product/shoes
Reverse proxy routing rules
Astro layout · header · footer
Legacy SPA content · BuyBox
One URL, two systems · The proxy decides who renders what

The proxy assembles the page from fragments, the header from Astro, the product content from the legacy, stitched together before the response reaches the browser. Nginx does this with SSI, CDNs with ESI.

This is also what makes rollback possible: change a routing rule and the page goes back to legacy. No redeployment, no downtime.

Once this is in place, the two systems can coexist and the migration can start. Now, what do we migrate first?

Phase 2 · Static markup

It’s time to introduce Astro in this project without breaking anything. Everything that doesn’t need JavaScript gets migrated first. Layout, content, display components. The goal is to move as much as possible to Astro’s server-rendered HTML before touching anything interactive.

Layout first

The layout is the best starting point: it’s identical on every page and once migrated, the whole site benefits. The interactive parts of the layout (e.g: search, cart badge) stay on legacy for now, they’ll move in the next phase with the other islands.

Header Astro
Logo
Nav links
Search legacy
Cart badge legacy
Product content Legacy
Gallery
Info + BuyBox
Footer Astro
Links
Newsletter legacy
Only static markup migrated · All interactive parts still on legacy

Page content

Then the page content. Start with the least interactive and least critical pages. The first deployments will surface issues you didn’t see with layout alone: routing gaps, design token inconsistencies, build pipeline surprises.. Better to discover those on pages that don’t directly cost revenue.

Things to watch

Shared design tokens (colors, typography, spacing) keep the migrated pages visually consistent with the legacy ones. Both systems consume the same source, even if the CSS implementation differs.

For SEO, URLs must not change. Measure Core Web Vitals before and after each migrated page. If LCP, CLS or INP regress, fix before moving on.

With the static markup in place, the page is lighter but not yet interactive. Time to bring in the islands.

Phase 3 · Interactive components

The static markup is migrated. Now it’s time to add interactivity back, but only where it’s needed. This is the core of the islands model: each interactive component becomes an island that hydrates independently.

Hydration directives

Astro gives you control over when each island becomes interactive. Not everything needs to load immediately.

client:load hydrates the component as soon as the page loads. For things the user needs right away: search, cart badge, burger menu..

client:visible waits until the component scrolls into view. For things below the fold: image carousel, reviews accordion, newsletter form.. Zero JavaScript cost until the user actually sees them.

client:idle waits until the browser has nothing better to do. For low-priority stuff: GDPR popup, analytics widgets..

Header HTML
Logo
Navigation
Search client:load
Cart badge client:load
Product Page HTML
Gallery carousel · zoom client:visible
Info
Title · Price
Description
Reviews accordion client:visible
BuyBox legacy
Footer HTML
Links
Newsletter client:visible
GDPR popup client:idle
Analytics client:idle
Each component only pays for its hydration moment

Island boundaries

If two elements need to react to each other, they belong in the same island. The size selector changes the price and the stock? Same island. If they just need to read the same data independently, they can be separate islands querying the same source.

Not everything needs an island either. A link with analytics tracking doesn’t need hydration. A collapsible section can work with a <details> element. A <script> with event delegation handles a lot of lightweight interactions without shipping a framework. If it doesn’t have state, it probably doesn’t need an island.

SSR is still a valid option. A search results page doesn’t need an island, it’s a form that submits and the server returns a new page. The search bar with autocomplete needs one but the results page does not.

Every component is a tradeoff. Island, SSR, plain HTML, vanilla script.. Even with the islands model, it’s a decision to make for each part of the page.

Once the boundaries are defined, a new question comes up: how do these islands talk to each other?

Cross-system communication

This is probably the biggest mindset shift. In an SPA, state flows through the component tree. Everything is connected, everything is reactive. In islands, each island is isolated. Communication has to be explicit.

During the migration, the legacy still owns parts of the page. It doesn’t know about Svelte stores or reactive state. The lowest common denominator is a custom event on the document. The user adds a product to the cart from the legacy BuyBox, the cart badge in the Astro header needs to update. One dispatchEvent, one listener.

Legacy BuyBox → "add to cart"
CustomEvent
Astro layout Cart badge updates
No shared store · No nanostores · Just the DOM

Once the legacy is gone and all islands are Astro, the options open up. Nanostores is built exactly for this: shared state between framework-agnostic islands with reactivity. A natural upgrade from custom events when island-to-island communication gets more complex.

But during migration, custom events are the right starting point. The legacy can fire one without any dependency.

At this point, everything is migrated except one thing. The component that actually makes money.

Phase 4 · The BuyBox

The BuyBox (price, variants, add to cart, shipping) is the component that converts. And probably the hardest piece to migrate.

Why it’s hard

Store-specific pricing is often tied to the legacy session system. The cart depends on the same session. Shipping options, stock availability, everything in the BuyBox talks to the backend in ways that were designed for the old frontend. Exposing all of that as clean APIs that Astro could consume server-side requires backend work that probably isn’t done.

If the two systems can’t share the same cart session, a user who adds a product on a migrated page and navigates to the legacy checkout loses their items. This is often the most blocking issue in headless migrations.

While migrating

The good news: the BuyBox can just stay on the legacy. The reverse proxy is already handling the cohabitation, the BuyBox works, the session is handled. No need to rush it.

Header Astro
Product Page Astro
Gallery island
Description · Reviews
BuyBox legacy
Price
Shipping
Add to cart
Footer Astro
Everything migrated except the BuyBox · It stays on legacy, the proxy handles it

Until now, we didn’t touch it and that was fine. The proxy handled the cohabitation, the legacy owned the BuyBox, no risk. But now we want to migrate it, and that means the backend needs to be ready.

When it does get migrated

Once the backend exposes the right APIs, the BuyBox becomes a client:load island.

Header Astro
Product Page Astro
Gallery island
Description · Reviews
BuyBox client:load
Variant selector
Price · Stock
Add to cart
Footer Astro
BuyBox migrated · One island, client:load · The last legacy component is gone

Two options:

A server island that fetches the price on demand, personalized per user. The rest of the page is cached at the CDN, only the BuyBox is dynamic. Or astro:actions managing the cart server-side with an httpOnly cookie. Islands query it independently, mutations invalidate the cache.

The variant selector, the price, the stock, they all must live in the same island because they’re tightly coupled. One Svelte component for the entire BuyBox, one client:load. The content around it (description, specs, breadcrumbs) stays static.

Once the BuyBox is migrated, the legacy frontend has no reason to exist. Time to clean up.

Phase 5 · Cleanup

The last legacy component is migrated. Legacy JS bundle, CSS, patch stylesheets, proxy rules.. Everything that was only there for the cohabitation can go. Same approach as the migration: remove one thing, deploy, check. Not a big bang cleanup either.

One framework, one CSS system, each page ships only the JavaScript its islands need. The product page now loads a fraction of time it used to.

Conclusion

The product page with 2MB of JavaScript started with a simple observation: most of what’s on screen doesn’t need a runtime. The architecture exists to fix that. The migration path is concrete, phase by phase, from reverse proxy to cleanup. The tradeoffs are real but they’re manageable.

If you’re staring at a legacy frontend that ships a full framework to render a catalog, the question isn’t whether islands architecture makes sense (it does), it’s whether you and your users can afford to wait.