Skip to content
ExponaBLOG
SVELTE & SVELTEKIT · DAY 065 / 100

Svelte without SvelteKit: a hands-on guide for engineers

Use Svelte in any page.

Expona Admin7 min read

A pragmatic approach to incremental UI modernization

Most of us inherit large codebases built without modern frontend frameworks. Migrating to SvelteKit means rewriting your entire application—a bet many teams can't afford. But what if you could introduce Svelte gradually, component by component, into your existing PHP Laravel app, Django backend, or plain Express server? Svelte without SvelteKit: a hands-on guide for engineers shows you how to embed lightweight, reactive components wherever you need them, without the framework overhead or deployment upheaval.

This approach has served us well in production: we've shipped Svelte widgets into legacy systems handling millions of transactions, kept bundle sizes under 50KB, and avoided the "rewrite in progress" trap that derails teams for quarters. Today we'll walk through the mechanics, patterns, and pitfalls.

Why Svelte outside SvelteKit matters

SvelteKit is excellent for greenfield projects or apps where you own the full stack. But many of us work on:

  • Monolithic backends with server-rendered views (Rails, Laravel, Django)
  • Legacy jQuery or vanilla JavaScript frontends that still work fine overall
  • Microservices where the UI layer is separate from the backend team's concerns
  • Embedded admin panels or dashboards bolted onto existing products

In these scenarios, pulling in SvelteKit adds complexity: you're adding a Node.js build pipeline, runtime server costs, potential conflict with your existing backend routing, and deployment coordination. Svelte without SvelteKit lets you keep your current architecture and surgically insert reactivity where it creates the most value—usually in interactive forms, real-time dashboards, or heavily stateful UIs.

Vite + Svelte for widgets

The quickest path is Vite with the Svelte plugin. Vite gives you fast dev reloading and minimal configuration; the Svelte plugin handles compilation.

Set up a new project:

typescript
npm create vite@latest my-widget -- --template svelte
cd my-widget
npm install

Your vite.config.js will look minimal:

typescript
import { defineConfig } from 'vite'
import svelte from 'vite-plugin-svelte'

export default defineConfig({
  plugins: [svelte()],
  build: {
    lib: {
      entry: 'src/main.js',
      name: 'MyWidget',
      fileName: (format) => `my-widget.${format}.js`
    }
  }
})

The key insight: set build.lib to output a library, not an app. This produces a single bundle you can drop into any HTML page. For a real example, imagine you're building a new booking calendar for an older travel site. Your Svelte component handles date selection, availability logic, and price calculation. The rest of the site—hotel listings, reviews, payment—stays untouched.

In your src/main.js:

typescript
import App from './App.svelte'

export default App

Build with npm run build. You'll get dist/my-widget.umd.js and dist/my-widget.es.js`. Use the UMD build for maximum compatibility. Then in your legacy HTML:

typescript
<div id="booking-widget"></div>
<script src="/path/to/my-widget.umd.js"></script>
<script>
  const app = new MyWidget({
    target: document.getElementById('booking-widget'),
    props: {
      hotelId: 42,
      onBook: (dates) => console.log('Booked:', dates)
    }
  })
</script>

That's it. Your Svelte component mounts into a div and accepts props from the parent page. If you have multiple widgets on the same page, each one mounts independently. Bundle size is often 30–60KB gzipped depending on complexity.

Embed components in existing apps

The real power comes when you can hydrate multiple Svelte components on a server-rendered page without rewriting the entire frontend.

A common pattern: your backend renders HTML (Django templates, ERB, Blade, Handlebars—doesn't matter). You mark specific sections for Svelte:

typescript
<!-- server-rendered HTML -->
<div class="product-page">
  <h1><%= @product.name %></h1>
  <p><%= @product.description %></p>

  <!-- This part becomes interactive -->
  <div id="cart-controls" data-product-id="<%= @product.id %>" data-price="<%= @product.price %>"></div>
</div>

In your client-side bundle (main.js served by your backend as a regular script):

typescript
import CartControls from './CartControls.svelte'

// Find all cart control widgets on the page
document.querySelectorAll('[id^="cart-controls"]').forEach(el => {
  const productId = el.dataset.productId
  const price = parseFloat(el.dataset.price)

  new CartControls({
    target: el,
    props: {
      productId,
      price,
      onAddToCart: (quantity) => {
        fetch('/api/cart', {
          method: 'POST',
          body: JSON.stringify({ productId, quantity })
        })
      }
    }
  })
})

This approach scales: your server handles routing, session management, and non-interactive content. Svelte handles only the UI interactions you care about. A team at an e-commerce company we know manages 200+ product pages this way—each one server-rendered for SEO, but with client-side cart, filters, and reviews all in Svelte.

Custom elements with customElement option

Sometimes you want even less ceremony. Web Components (custom elements) let you define components as regular HTML tags. Svelte can compile to custom elements automatically.

In your Svelte component, add the `customElement` option:

typescript
<svelte:options customElement="my-counter" />

<script>
  let count = 0

  export function increment() {
    count++
  }
</script>

<button on:click={() => count++}>
  Count: {count}
</button>

Build it with Vite. Now use it as a native HTML tag anywhere, no JavaScript glue code needed:

typescript
<my-counter></my-counter>
<script src="/path/to/my-widget.js"></script>

Pass props via attributes; listen to events on the element itself:

typescript
<my-counter
  initial-count="5"
></my-counter>

<script>
  const counter = document.querySelector('my-counter')
  counter.addEventListener('change', (e) => {
    console.log('New count:', e.detail)
  })
  counter.increment() // call methods directly
</script>

This is powerful for plugin systems or design systems where you want zero framework assumptions. A team running a SaaS platform with third-party integrations uses Svelte custom elements for partner widgets—partners just include a script tag and drop a `<partner-widget>` element anywhere on their page.

One caveat: custom elements bundle the entire Svelte runtime into each component. If you have multiple Svelte custom elements on the same page, you'll load the runtime multiple times. Use a shared bundle strategy if you plan to scale beyond one or two components.

Great for gradual adoption

The real value of Svelte without SvelteKit is the adoption curve. You can migrate incrementally:

  1. Phase 1 (Week 1-2): Replace one complex widget (shopping cart, form wizard, real-time chart) with Svelte. Measure bundle size, load time, and team feedback.
  2. Phase 2 (Week 3-4): Add a few more widgets. Establish naming conventions, prop passing patterns, and event handling standards so other engineers can maintain them.
  3. Phase 3 (Month 2+): If it works well, consider moving more interactive sections. If not, you've only modified a small piece of your app.
  4. Phase 4 (Future): When you've migrated the bulk of interactive UI and the team is confident, you can revisit SvelteKit for new features or plan a full migration if it makes business sense.

This beats the "big rewrite" approach because stakeholders see value each week. Engineers can learn Svelte on the job. If you hit technical issues, you can roll back a widget without rolling back the entire application.

Common mistakes

Bundling the runtime multiple times. If you embed several separate Svelte apps on the same page using different builds, you'll include Svelte's runtime in each bundle. Solution: use a shared entry point or custom elements with runtime deduplication.

Forgetting to export props. In Svelte, props must be explicitly declared with `export` in the script block. If you try to pass a prop without exporting it, nothing happens—it's a silent failure that confuses newcomers.

Mixing server and client state carelessly. Your server renders HTML with initial data. Your Svelte component hydrates with that data. If you mutate server state (e.g., inventory count) from the client without API round-trips, you'll desync. Always use fetch or WebSockets for truth-telling.

Ignoring CSS scoping. Svelte scopes styles to components by default, which is great. But if your legacy app has global styles, they'll still apply to Svelte components. Test style isolation early, or use CSS resets if needed.

Oversizing the bundle. It's easy to `npm install` a large dependency tree. Audit your dependencies. A 200KB widget defeats the purpose of gradual adoption. Aim for under 100KB gzipped per widget.

Key takeaways

  • Svelte without SvelteKit is practical for teams with existing backend-heavy applications—use Vite + Svelte plugin to build library bundles you can embed anywhere.
  • Hydrate server-rendered pages by mounting Svelte components into `div` elements; pass data via props and parent scope via data attributes.
  • Custom elements (`customElement` option) remove JavaScript glue code and work well for plugin systems or design systems, but watch out for runtime duplication.
  • Gradual adoption lets you de-risk the migration: ship one widget, prove the pattern, scale from there. This beats the big rewrite and keeps your team's velocity high.
  • Common pitfalls include bundling the runtime multiple times, forgetting prop exports, desynchronizing server/client state, and letting bundles grow too large. Test and audit early.
  • This pattern has worked in production across e-commerce, SaaS, and travel platforms—it's battle-tested for real-world constraints.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles