Svelte Logo Svelte Logo

Realizing DHH’s vision with modern front-end technologies.

Why Integrate Svelte?

Svelte offers the simplest and most elegant soulution to building reactive, high-performance front-end components.

  • Seamless Integration
    • Works flawlessly with Hotwired/Turbo
    • Enhances Hotwire’s capabilities
  • Developer-Friendly
    • Simple to learn, intuitive, and powerful
    • Lightning-fast performance
  • Compared to Single Page Apps (SPAs)
    • Full-stack development delivers maximum value:
    • Unified testing from database to frontend
    • Single-source system delivery
    • For the most HTML Hotwired is enough
  • Compared to Hotwired
    • Stimulus is not a tool for frontend-apps
    • Svelte eliminates redundant HTML initial state logic
    • Consolidates component logic into a single file
    • Offloads rendering to JavaScript by Frontend, Server side Rendering only where necessary, reducing server load
  • Compared to React/Vue
    • No virtual DOM, resulting in leaner packages and faster performance
    • See Rich Harris’ Rethinking Reactivity (3:50–6:40) for a compelling comparison
    • Easier to learn
    • While React and Vue have larger communities, Svelte’s ecosystem is robust and growing, ideal for Rails integration

Svelte empowers Rails’ full-stack vision with modern, efficient front-end integration.

Features

  • ✍️ Sophisticated error messages
  • 🚀 Cero-config deployment
  • 🤝 Fully integrated with assets:precompile
  • 😍 Contributor friendly

Known Issues

see issues

Svelte on Rails 👍

Rock-solid and seamless integration of Svelte Components into Rails views, based on vite_rails.

By default, and when installed together with @hotwired/turbo-rails, it renders
svelte components on the first request server side («SSR») and for subsequent
requests it provides a empty tag which is mounted on the frontend
by the associated npm package @csedl/svelte-on-rails.

This way svelte works perfectly together with turbo. You will never notice
this unpleasant «blink» on the frontend while the whole process is maximum
performance optimized.

Thanks to shakacode
and ElMassimo for inspiring and helping me with their gems.

STATUS: This gem is in the early stages of development, but is ready for use.
It has nearly 100% test coverage, with all tests passing. It has been in use since May/June 2025
on the systems of my two biggest customers.

If you have issues, please open one, and contributors are welcome!

Requirements

  • actual node installed on the server
  • tested on
    • ruby 3.2.2 and rails 7.1
    • ruby 2.7.5 and rails 6.1
    • vite@6 (v7 not supported, see issues)
    • turbolinks and hotwired/turbo
  • vite_rails (the installer will install it by option –full or –vite)
  • svelte@5, vite-plugin-svelte@5 (see: how to install svelte on rails/vite)
  • turbo (recommended / how to install turbo on rails)
  • if you use special packages (like pug) that requires commonjs, you may need
  • npm on latest versions
  • actual node installed.
    • if nvm is installed it gets the path to the node-binary from there.
    • When .nvmrc is present on projects root, it is respected
    • If node is not included on the PATH you can configure your node path by environment variable SVELTE_ON_RAILS_NODE_BIN

Installation from cero ⚙️

rails new my-test-app --skip-javascript

within the app add the gem

bundle add svelte-on-rails

and run the installer

rails g svelte_on_rails:install --full

You have done it! 👍

Start the server and you will see a svelte hello world component rendered on the browser.

Explanation:

The --full contains:

  • --vite
    • adds vite_rails gem and running the installer
  • --haml
    • adds the gem and converts existing views
  • svelte_on_rails
    • This is not a option, this is always done: Adds a config file and installs @csedl/svelte-on-rails by npm
  • --turbo
    • installs @hotwired/turbo-rails and adds import statement to application.js
  • --svelte
    • adds or updates svelte
  • --hello-world
    • adds a hello world component

You can also use the options you want instead of using --full.
And there is the option --force that would not ask you whether it should overwrite existing files, for automated processes like testing.

This means, if you want all, except haml, you can run:

rails g svelte_on_rails:install --vite --turbo --svelte --hello-world

The installer is written carefully: It does not overwrite a file without asking
and tells you all what he is doing.

At the end, you just have to (re-) start your server
and you will see a Svelte Hello World component.

This hello world has a second page where the most common import statements are demonstrated,
separated for server and client side rendering.

Then, you can run

rails svelte_on_rails:remove_hello_world

and start coding.

Installation on a existing app ⚙️

Required: vite_rails must be installed, it wants a app/frontend folder.

Check Svelte Installation: install svelte on rails

Within the app, add the gem

bundle add svelte-on-rails

and run the minimal installer (you are free to add any options from above)

rails g svelte_on_rails:install

Restart the server, add a hello world component app/frontend/javascript/HelloWorld.svelte like

  export let title


<h1>Svelte {title}</h1>

Add it to the view

&lt;%= svelte_component('HelloWorld', title: 'Hello World') %&gt;

And you should see “Svelte Hello World” on the browser! 👍 🤗

Explanation

this Minimal installer does:

  • add app/frontend/initializers/svelte.js
  • Adds a import statement for that initializer to application.js
  • add app/frontend/ssr/ssr.js
  • add config/svelte_on_rails.yml
  • add vite-ssr.config.ts
  • add command npm run build:ssr to package.json
  • installs or updates npm packages to the latest:
    • @csedl/svelte-on-rails
    • typescript
    • @types/node

Troubleshooting

In the first step, the installer runs the backend parts that are unlikely to fail.
Then it installs the npm packages that are more likely to fail because of dependencies.
If so, just check that all the
packages are installed on the latest versions and you should be fine. 🤓

The most importand rule is to firstly check all npm packages installed and passing before changing your view logik.

Check if it all works

Server Side Rendering (SSR) is a parallel pipeline to client side rendering.
Both should return the same HTML. And your global styles should be applied same way
for both cases. For normal use cases this is.

For check the ssr pipeline you can pass the options ssr: true and hydrate: false
to the view helper. This way you will see a «dead» backend rendered HTML with no javascript applied.

For check the client side pipeline you can pass the option ssr: false and
hydrate: true to the view helper.

If both are looking similar, you are good to go. Then, remove theese options, the defaults are
ssr: :auto and hydrate: true.

Import statements

The most importand import statements that are served by this gem are included in the
hello world component and by that they are also within the testing scope. So you can
be sure that they are working. If importand statements are missing there, pelase
tell me.

Among others, working statements are:

  • import svg from '../example.svg?raw'
  • “ (svelte component with typescript syntax)
  • import { someFunction } from '../customJavascript.js';
  • import Child from './Child.svelte';

Precompile assets

Usual vite has a vite.config.ts file, that is used for the client side precompilation.

By running this installer it adds vite-ssr.config.ts and a npm runner so that you can do npm run build:ssr
which does the server side precompilation.

The same job is triggered alongside rails assets:precompile for production environments.

On development, when watch_changes is configured, the precompilation is triggered
after any *.svelte file within the configured components_folder changed.

Option ssr: :auto

ssr: :auto is the default option, as set on config file and can be overridden on the view helper.

By passing the ssr: :auto option to the view helper,
it checks if the request is an initial request (request header X-Turbo-Request-ID is present // configurable by non_ssr_request_header):

On Initial-Request, it adds the attribute data-svelte-on-rails-initialize-action="hydrate" and
returns a server side rendered component that will be hydrated on the frontend by the npm package.

Otherwise it adds mount instead of hydrate, and renders a empty element, but provided with the
necessary attributes for the npm package.

More details to this point you can find on the npm package.

This works perfectly with hotwired/turbo because the javascript is only
loaded on very first load to the frontend, then the most work is done
in frontend and the server is relieved, except on initial request.
You will see no unpleasant «blink» on the page.

Turbolinks

If you are working on turbolinks, you can config the header or set something like

document.addEventListener('turbolinks:request-start', (event) =&gt; {
    var xhr = event.data.xhr
    xhr.setRequestHeader("X-Turbo-Request-ID", "any-content-for-skip-svelte-ssr")
});

to your javascript file.

Tip: Performance optimisation for dropdowns

For example, with dropdowns, you will never see the unpleasant «blink» effect because
the Svelte component is not visible for the first moment after rendering.
Server-side rendering is unnecessary here. You can pass ‘ssr: false’ to the view helper.
This relieves the server and reduces loading time.

Tip: Testing

Consider setting ssr: false for testing only for performance reasons.
Yo can override this on a attribute on the view-helper for specific components.
But, in normal cases it should not be neccessary testing ssr explicitly.

ActiveRecord helpers

Adds the #to_svelte helper to your models, example:

@book.to_svelte(
  :name,
  :calculation_method,
  author: [:name],
  editions: [
    :date, 
    offset: 2, 
    limit: 1
  ]
)

would result in something like this:

{
  "values" =&gt; {
    "name" =&gt; "Learning Ruby",
    "calculation_method": "any-result",
    "author" =&gt; {
      "name" =&gt; "Michael Hartl"
    },
    "editions" =&gt; [
      {
        date: "2025-02-03"
      }
    ]
  },
  "book_labels" =&gt; {
    "name" =&gt; "Name", # translated by human_attribute_name..
    "calculation_method" =&gt; "Calculation method",
    "author" =&gt; "Author"
  },
  "author_labels": {
    "name" =&gt; "Name" 
  },
  "edition_labels" =&gt; {
    "date" =&gt; "Date"
  }
}

This should ease transferring data you need within the component mostly.

The same method is applicable for:

  • The model itself
    • It returns only the labels: Same result like above, but without the values key
  • ActiveRecord::Relation
    • Same result like above, but values then is a Array.

If a association returns a empty result, the labels are still calculated.

offset and limit are reserved keys, so, columns with the same name would be ignored.

Caching:

Caching Capability is not implemented on this method, you easily can wrap it by Redis

If used on the cached_svelte_component view helper,
the component’s attributes are used to generate a checksum, which serves as the
cache key for efficient storage and retrieval. So, this method is meant to
make it easier to exactly filter out only the information that is needed
on the component.

Caching

Caching only is relevant for ssr

When using the cached_svelte_component helper you must have the redis gem installed.

This caches on a key like svelte-on-rails:development:SvelteOnRailsHelloWorld.svelte-1xq5tnu-User1:fscyhz-18bm76a which includes:

  • Namespace, if configured, otherwise the default is gem-name and environment
  • component filename
  • checksum of the file-path
  • cache_key if given as argument to the view-helper
    • can be a array of active-record objects or strings or a single element instead of a array
  • checksum of the last modification timestamp of the component (only when watch_changes is set to true)
  • checksum of the given attributes

Configuration

Like usually you can configure your cache store on your environment by something like:

  config.cache_store = :redis_cache_store, { url: 'redis://localhost:6379/2',
                                             expires_in: 90.minutes,
                                             namespace: 'my-example-app' }

And you can override this by

redis_cache_store:
  expires_in: 2.hours

on the svelte-on-rails config file or pass the expires_in as argument to the view helper.

Check if it works

Pass debug: true to the helper and you will see on the logs how your configuration works.

ActionCable vs. TurboStream

There are two ways that the server can talk to the client over Websocket:

  • ActionCable transmits directly to the frontends javascript
  • TurboStreams is a wrapper around ActionCable
    • You always need a html part for communication by secured channels
    • Makes sense when you want to transfer confidential data or separate onto privileged user channels
    • Has compatibility issues with Rails-UJS

SvelteOnRails::ActionCable

Setup

Add app/channels/svelte_on_rails_channel.rb

class SvelteOnRailsChannel &lt; ApplicationCable::Channel
  def subscribed
    stream_from "svelte_on_rails_channel"
  end

  def unsubscribed
    # Any cleanup needed when channel is unsubscribed
  end
end

config

action_cable:
  channel: "svelte_on_rails_channel"

javascript

npm i @rails/actioncable

Add to application.js

import { createConsumer } from "@rails/actioncable"
import { SvelteOnRails, dispatchSvelteStreamEvent, actionCableDebugLog } from '@csedl/svelte-on-rails'
SvelteOnRails.debug = true

const consumer = createConsumer()

consumer.subscriptions.create("SvelteOnRailsChannel", {
    connected() {
        actionCableDebugLog("Connected to SvelteOnRailsChannel")
    },
    disconnected() {
        actionCableDebugLog("Disconnected from SvelteOnRailsChannel")
    },
    received(data) {
        actionCableDebugLog("Received:", data)
        dispatchSvelteStreamEvent(data)
    }
})

What dispatchSvelteStreamEvent does

  • Without the attribute component given, it searches for all elements with the class svelte-component and fires the event channel-action
    • When selector is given, it searches for all matching elements within each component.
    • The event can be overriden by the argument event

Usage

app/frontend/javascript/components/folder/MyComponent.svelte

&lt;script&gt;
  import {addComponentStreamListener} from '@csedl/svelte-on-rails/src/componentStreamListener'

  function handleCableEvent(event) {
      console.log('Event received by Turbo Stream', event.detail)
  }


<h1>Test TurboStreams Channel</h1>

The addComponentStreamListener adds the eventListener stream-action on the wrapping Element.
The «wrapping Element» is the Element from the view helper svelte_component with the class svelte-component.

Now you can dispatch events on the component by:

SvelteOnRails::ActionCable.dispatch(
  'folder/MyComponent',
  { message: "greetings from Server: äöü🤣🌴🌍漢字" }
)

And you will find the object, with the message key on the browser logs.

Without any arguments, just by SvelteOnRails::ActionCable.dispatch it would fire the stream-action event on all components.

The #dispatch_by_selector does not go over the component, it searches for any matching selector just
on the whole document and fires the given event there.

SvelteOnRails::TurboStream

Setup

Please setup the turbo-rails gem and follow the chapter Come alive with Turbo Streams, which mainly is:

bundle add turbo-rails
rails turbo:install

make sure that import "@hotwired/turbo-rails" is on your application.js and set the view helper
&lt;%= turbo_stream_from 'authenticated' if current_user %&gt; to your view.

If a channel (e.g.: authenticated) is active and you have an HTML element with a HTML-ID (e.g.: svelte-on-rails-stream-actions-box),
you can test it by:

     Turbo::StreamsChannel.send(
        "broadcast_append_to",
        'authenticated',
        target: 'svelte-on-rails-stream-actions-box',
        content: "<div>Turbo-Streams are working!</div>"
      )

When this works you are good to go.

Configs

Check the regarding keys and their commends on the config file.
From there, you just need:

turbo_stream:
  target_html_id: 'svelte-on-rails-stream-actions-box'
  channel: 'public'

Minimal Usage Example

And call this by:

    SvelteOnRails::TurboStream.dispatch

What it does

  • A Stimulus controller is pushed to all subscribers of the configured channel.
    • You can override the channel name by passing channel to the method.

Usage Example, more detailed

If you want to fire a event on a specific element within the component, you do not need addComponentStreamListener.
Just do something like:

    function logStreamMessage(event) {
        console.log(event.detail.message)
    }

Show Contents

within the svelte component.

Then, call it by:

      SvelteOnRails::TurboStream.dispatch(
        channel: 'my-custom-stream',
        component: '/javascript/components/TurboStreamsChannel',
        selector: '.counter-button',
        event: 'click',
        event_detail: { message: "Greetings from Server: äöü🤣🌴🌍漢字" }
      )

The #dispatch_by_selector does not go over the component, it searches for any matching selector just
on the whole document and fires the given event there.

More rake tasks

This tasks are more for testing/playground purposes

rails svelte_on_rails:add_hello_world
rails svelte_on_rails:remove_hello_world

Deploy

For example, by deploying with capistrano, you may add a step like

before 'deploy:assets:precompile', 'deploy:npm:install'
namespace :deploy do
  namespace :npm do
    desc 'Install Node.js dependencies'
    task :install do
      on roles(:app) do
        within release_path do
          execute :npm, 'install'
        end
      end
    end
  end
end

to deploy.rb for making sure the ssr compilation is working.

Contributors Guide

Contributors welcome!

Download the code and run the tests

Download the source code from the repository, and within the project folder run:

rake svelte_on_rails:create_contributor_configs_file

and define a generated_test_app_folder_path (required) for apps, generated for the testings.

For development of the npm package @csedl/svelte-on-rails (optional) please download the source code of the
npm package and set local_npm_package_path on the config file to the path to the npm package on your local machine.
This will cause the installer, to install the npm package from a local path instead from the npm registry.

Then run the tests and start contributing.

RUN THE FIRST TEST (!): Testing is complex here because of the design based on the installer test.
On Problems, i always run the Installer &gt; destroy and create rails app &gt; FIRST TEST &gt; [...] check if javascript works.
If there are problems, open the generated app on a IDE and check errors there.

When this passes, all the others passing mostly.

At the end of the most tests it leaves the rails server running, so that you can see the result on localhost:3000.

NOTE: Theese tests are dependend on your environment, including the running ruby version!
I am working on rvm. If you work on a different environment, some (not many) changes may be necessary.
Thats your part :)

The current test cases including (among others):

create a completely new rails app, running the full installer and check if a hello World component is visible and javascript is working.
run assets:precompile within a rails app and check if the gem does its precompiling too.

Understanding the process

Why not client and server rendering in one process?

That was my idea! application.js which is the usual entry point for the client
side could live on the same assets and manifest like svelte components that are
compiled as chunks which each is its own entry point. This failed:

  • The vite-plugin-ruby did not support this: it constrained all to one entry point.
  • See how fat the vite-ssr.config.ts is. For the client side this is not necessary.

At the end, I decided to split the process. For now it is cleaner. But that is not the last decision.

Why not compiling server side purely by rollup?

Advantages would be much slimmer packages and faster compilation. On the first
step i did this and i backed up a working and tested code on the branch rollup-ssr.

I decided to use Vite to bring the client and server side rendering
closer together.

But this, too, is not the last decision.

For now we proceed with vite.

How does it work now?

Client side rendering is done by vite like usual.

Server side rendering is triggered, similar to vite_rails
on assets:precompile, and, if watch_changes is configured,
which is default for development, it is triggered
on every change of a *.svelte file within the configured components_folder.

On the server side only the *.svelte files are served. Theyr included
assets are linked to the client side assets folder, which is mapped by manifest.json.

Then, vite has two output folders: vite-dev for development and vite for production.
Within vite-ssr.config.ts, by the RAILS_ENV variable, is decided which one is used.

Licence

License is MIT