This article, originally published on Rails Designer, was taken from the book: JavaScript for Rails Developers. It is shortened and adapted for the web.
Browsing any modern SaaS site or app and you have very likely seen a widget in the bottom corner. Like a chat or a documentation look-up dialog.
In this article, I want to show you an example on how you can build such JavaScript widget that users can embed on their own site or app. It can be a great starting point for a new SaaS business (feel free to send stock options my way 🤑).
If you purchase the professional package of the JavaScript for Rails Developers book, check out the bundled resources. It includes the resource for a complete JavaScript widget, along with a Rails app. This widget communicates with the Rails back-end through WebSocket/Action Cable. The code in this article is the foundation for that resource.
(This is the included resource of the Javascript for Rails Developers book. It shows the widget of the left and the Rails app that powers it on the right.)
The widget built in this article can also be found in this repo.
The Basics
For widgets like these, I like to use Vite—a modern JavaScript build tool and development server that provides a fast and nice developer experience.
First, create a package.json file:
// package.json { "name": "chat-widget", "private": true, "version": "0.0.1", "type": "module", "scripts": { "dev": "vite", "build": "vite build" }, "devDependencies": { "rollup-plugin-terser": "^7.0.2", "vite": "^6.2.6" }, "dependencies": {} }
Simple! Now, the Vite config:
// vite.config.js import { resolve } from "path"; import { defineConfig } from "vite"; import { terser } from "rollup-plugin-terser"; export default defineConfig({ build: { lib: { entry: resolve(__dirname, "src/widget.js"), fileName: "v1/embed", formats: ["es"], }, rollupOptions: { plugins: [terser()], output: { compact: true } } }, })
Check out Vite’s documentation if you want to dig into what these options do. But in short, these two files give you:
-
yarn dev
— the default command for local development -
yarn build
— generates a production-ready JavaScript file you can serve via a CDN (e.g. https://cdn.example.com/v1/embed.js)
To make development even easier, let’s also create an index.html file:
<!doctype html> <html lang="en"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Basic widget</title> </head> <body> <h1>JavaScript Embed</h1> <div id="container"></div> <script> window.WIDGET_CONFIG = { selector: "#container" }; (function(){var script=document.createElement("script");script.type="module";script.src="src/widget.js";document.head.appendChild(script);})(); </script> </body> </html>
Now, if you run yarn dev
and open http://localhost:5173/, you'll see an empty page with the <h1>
-text “JavaScript Embed”.
Widget functionality
Let's build the actual widget:
// src/widget.js class Widget { constructor(selector) { this.element = document.querySelector(selector) if (!this.element) { throw new Error(`[Widget] Element with selector "${selector}" not found`) } } } function initWidget(selector) { const widget = new Widget(selector) return widget.init() } window.initWidget = initWidget if (window.WIDGET_CONFIG && window.WIDGET_CONFIG.selector) { initWidget(window.WIDGET_CONFIG.selector) } export { Widget }
Here's what it does:
- It defines a function
initWidget
that takes a selector string (e.g. #container). - It creates a new Widget instance using that selector and calls its
init()
method. - It assigns
initWidget
towindow.initWidget
, making it globally available. - If both
window.WIDGET_CONFIG
andwindow.WIDGET_CONFIG.selector
are present,initWidget
is automatically invoked with the provided selector.
This sets up a foundation for a plug-and-play widget your users can easily embed.
Injecting HTML
Let's now extend the widget to render some actual HTML on the page:
+ import Container from "./widget/container.js" class Widget { // … + init() { + const container = new Container() + this.element.innerHTML = container.render() + + return this + } // … }
As you can see, the Widget class acts more like a conductor than a soloist. It delegates tasks to other components, keeping the overall structure clean, modular, and easy to maintain.
Let’s now create that Container class, which is responsible for returning the HTML that gets injected into the page—based on this line: this.element.innerHTML = container.render()
.
// src/widget/container.js export default class Container { render() { return [this.#form, this.#messages].join("") } // private get #form() { return ` <form method="POST"> <textarea name="message" placeholder="Type your message…"></textarea> <button type="sumbit">Send</button> </form> ` } get #messages() { return ` <ul id="messages"></ul> ` } }
This should be self-explanatory: two private getter methods return chunks of HTML. The render
method combines them using .join("")
, which is a neat way to concatenate multiple strings.
Now, when you navigate to http://localhost:5173/, you should see a form with a textarea
and a submit
button. No need to refresh the page—Vite handles that for you with Hot Module Reloading.
For the last step in this article, let’s allow messages typed into the textarea
to be appended to the message list. Lets create a new class to handle this logic and hook it up inside the src/widget.js file:
// src/widget.js import Container from "./widget/container.js" + import Messages from "./widget/messages.js" class Widget { constructor(selector) { this.element = document.querySelector(selector) if (!this.element) { throw new Error(`[Widget] Element with selector "${selector}" not found`) } } init() { const container = new Container() this.element.innerHTML = container.render() + new Messages(this.element).setupEvents() return this } } // …
From the code above, you can already tell that we’ll need a setupEvents
method in the new Messages class:
// src/widget/messages.js export default class Messages { constructor(containerElement) { this.container = containerElement } setupEvents() { this.#form.addEventListener("submit", (event) => this.#create(event)) } }
This sets up a submit event listener on the form. #form
is a private getter method, and #create
is also a private method that we’ll define next:
export default class Messages { //… setupEvents() {} + // private + + #create(event) { + event.preventDefault() + + const textarea = this.#form.querySelector("textarea") + const text = textarea.value.trim() + const message = document.createElement("li") + + message.textContent = text + textarea.value = "" + + this.#messages.appendChild(message) + this.#messages.scrollTop = this.#messages.scrollHeight + } + + get #messages() { + return this.container.querySelector("#messages") + } + + get #form() { + return this.container.querySelector("form") + } }
Now, whenever you type something and click Send, the message gets added to the list and the textarea
clears itself. Cool, right?
These are just the very basics. Imagine extending the #create
method to send the message using the Fetch API (I explore the Fetch API in this article) and storing it in your Rails app’s database. Or doing the reverse: pulling messages from the database to display in the widget.
As your widget's features grow, keep the logic split into focused, small classes. It keeps the codebase easy to maintain. When your widget is production-ready, run yarn build
to generate a bundled JavaScript file suitable for hosting on a CDN.
If you get the professional package of JavaScript for Rails Developers, be sure to check out the bundled resources. It includes a minimal but fully functional chat widget, complete with:
- persistent message storage in the database;
- message fetching;
- WebSocket integration for real-time updates without refresh;
- persistent channels via localStorage (great for continuing chat across tabs);
- Turbo Broadcast support to append messages from the Rails app.
Top comments (5)
pretty cool stuff, honestly examples like this make things feel way less out of reach - you think structure like this actually helps you keep projects from turning into total spaghetti later?
I've built a few widgets like this for my own SaaS' and helped others successfully do the same using this exact structure. But in the end it all boils down to personal preference.
Really like how you broke down the modularity here, makes adding features feel way less daunting.
Would love to hear how you handled Action Cable integration once messages go real-time?
Would you like to see the next steps to connect to a Rails app? Let me know below. 🛎️
Some comments may only be visible to logged-in visitors. Sign in to view all comments.