From Gatsby to Web Components: What I Learned Migrating a 5-Year-Old Site

A retrospective on moving a 5 years old project from Gatsby + React to Vite + Lit and Web Components.


Background

Five years ago, Gatsby was a hot web framework and I wanted to play with it. My wife wanted to share her journey and lessons learned while we planned our wedding together. I designed and built brideandshine.ro and she wrote the content. After 3 years, I didn't renew the domain and the project went offline. But the code remained on GitHub.

Landing page of brideandshine website

Over the past year I started using more and more AI coding agents as part of my dev work, but lately I needed a new pet project.

I wanted to bring brideandshine back online, but it wouldn't start. Most of the time I use web components and web standards in my work, which I appreciate for standing the test of time.

So I was thinking, should I upgrade the framework or replace it entirely?

I chose web components and the result can be checked at brideandshine.vercel.app. To do the migration AI coding agents helped.

Here's what I learned.

The Numbers First

The site didn't change after migration, for a unexperienced user it is identical

2021 (Gatsby + React) 2026 (Vite + Lit and web components)
node_modules size ~600 MB 116 MB
Lock file package entries ~1,929 414
JS bundle (gzipped) ~300–500 KB 37 KB
git diff baseline 58,472 deletions / 5,088 insertions

Almost all of those deletions were lock files and build config.

Lesson 1: Framework Churn Is a Hidden Tax

Gatsby v2 needed an upgrade path just to run. This is the hidden cost of heavy frameworks: you pay maintenance fees on code you didn't write, for problems you don't have.

Web standards don't work this way. customElements.define(), ES modules, and <template> are part of the browser platform. They don't release breaking major versions. A component written to the Custom Elements spec today will still work in five years.

Lesson 2: Match the Tool to the Problem

Gatsby pulls in webpack, Babel, React, GraphQL, a plugin ecosystem just to build a simple site. It was designed for large content-driven sites with CMS integrations. Using it for 20 static pages is like renting a warehouse to store a bicycle.

Vite + Lit has almost no runtime overhead. Lit itself compiles to ~6 KB. The entire app ships as a 37 KB gzipped bundle.

Rule of thumb: if your "data layer" is just strings written into source files, you don't need GraphQL.

Lesson 3: The Component Model Transfers

The map betwen React components to Lit is not that different. Here is how it looks:

2021 | React + styled-components:

class Article extends React.Component {
  render() {
    return (
      <Container img={this.props.image}>
        <div className="imageContainer">...</div>
        <div className="description">
          <p className="title">{this.props.title}</p>
          <span>{this.props.meta}</span>
        </div>
      </Container>
    )
  }
}

2026 | Lit web component:

class BsArticle extends LitElement {
  static properties = {
    image: { type: String },
    title: { type: String },
    meta:  { type: String },
  }

  render() {
    return html`
      <div class="article-card">
        <div class="imageContainer">...</div>
        <div class="description">
          <p class="title">${this.title}</p>
          <span>${this.meta}</span>
        </div>
      </div>
    `
  }
}

customElements.define("bs-article", BsArticle)

The key difference: Lit's static properties reflects to/from HTML attributes, making the component usable in any framework or in plain HTML with no framework at all. React props are a React-specific convention.

Lesson 4: CSS-in-JS Was a Framework Workaround

styled-components solved a real problem: scoping styles to components when your only tool is React's virtual DOM. The cost was styles shipping as JavaScript, parsed at runtime, adding ~30 KB to the bundle.

With web components, you have two native options:

For this migration, light DOM was the pragmatic choice. The existing global.css used dozens of class names that would have broken inside a Shadow DOM boundary. Moving all those styles to CSS custom properties was out of scope.

The lesson: Shadow DOM is the right long-term answer for portable, reusable components. But it requires designing your CSS architecture around it from the start. Light DOM is the safe migration path.

Lesson 5: Magic Routing vs. Explicit Routing

Gatsby's approach routes are inferred from the filesystem. src/pages/buchetul.js becomes /buchetul. Site title is fetched via a GraphQL query at build time.

@vaadin/router's approach every route is declared explicitly:

router.setRoutes([
  { path: '/',           component: 'bs-page-home' },
  { path: '/primiipasi', component: 'bs-page-primii-pasi' },
  // ...all 19 routes
])

The explicit version is more code. It's also immediately readable to anyone who hasn't seen the project before, requires no build-time magic to understand, and works the same in dev and production.

Lesson 6: Libraries That Patch the DOM Gap

// 2021 - updating <title> in React required a library
import { Helmet } from 'react-helmet'
<Helmet><title>Primii Pași | Bride and Shine</title></Helmet>

// 2026 - it's just the DOM
connectedCallback() {
  document.title = `${this.pageTitle} | Bride and Shine`
  document.querySelector('meta[name="description"]')
    .setAttribute('content', this.pageDescription)
}

react-helmet exists because React's virtual DOM doesn't naturally manage <head>. Web components have no such gap, they are DOM. Many popular React libraries (portals, refs, head management) exist to bridge the gap between React's abstraction and the actual browser. When you work with the platform instead of around it, those libraries disappear.

What I Gave Up

Honest accounting: the migration came with real trade-offs.

Whether those trade-offs are acceptable depends on the site. For a low-traffic blog, probably fine. For an SEO-dependent business, you'd want to address them, either with a static site generator that outputs real HTML from web components, or with a rendering layer in front of the SPA.

The framework migration cleaned up the JavaScript. The CSS was still a five-year-old. Here's what it took to update that too.

Lesson 7: Design Tokens Pay Off Immediately

The first thing I added to the new global.css was a :root block:

:root {
  --color-primary:       #16166d;
  --color-primary-light: #4f4fc9;
  --color-accent:        #ff5e4d;
  --color-text:          #1a1a2e;
  --color-text-muted:    #6b7280;

  --space-xs: 0.5rem;
  --space-sm: 1rem;
  --space-md: 1.5rem;
  --space-lg: 2rem;
  --space-xl: 3rem;

  --font-sans: 'Montserrat', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;

  --shadow-card:       0 1px 3px rgba(0,0,0,0.08), 0 4px 12px rgba(0,0,0,0.08);
  --shadow-card-hover: 0 4px 12px rgba(0,0,0,0.12), 0 12px 32px rgba(0,0,0,0.12);

  --transition: 200ms ease;
}

Before this, the colour #16166d appeared in four separate rules. Changing the brand colour meant a search-and-replace. Now it's a single line.

The spacing tokens were even more valuable: before, padding values were scattered as 16px, 24px, 32px, 48px - inconsistent and unitless. Replacing them with var(--space-sm) through var(--space-xl) forced consistency and made the rhythm visible at a glance.

Rule of thumb: if a value appears more than once in your CSS, it belongs in a custom property.

Lesson 8: iOS Safari Has Its Own Rules

The hero background uses a fixed-position full-viewport image:

.hero::before {
  position: fixed;
  inset: 0;
  background-attachment: fixed;
}

background-attachment: fixed creates a parallax-style effect on desktop, the image stays still as the content scrolls. On iOS Safari it doesn't just fail gracefully, it renders the background as a blank white area, making the site unusable on iPhone.

The fix uses a feature query rather than a user-agent sniff:

/* iOS Safari supports -webkit-touch-callout; other browsers don't */
@supports not (-webkit-touch-callout: none) {
  .hero::before {
    background-attachment: fixed;
  }
}

The default (no background-attachment) works everywhere. The enhancement applies only where it actually works. This is the progressive enhancement model applied at the CSS level.

The Takeaway

A heavy framework feels productive at the start because it makes decisions for you. But those decisions compound: every plugin, every abstraction, every "magic" behavior is a future maintenance cost.

Web components sit closer to the platform. The code is more explicit, the bundle is smaller, and the skills transfer to any project without bringing the framework along. Five years from now, I expect to open this codebase and have it still build on the first try.