<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>Forem: Zil Norvilis</title>
    <description>The latest articles on Forem by Zil Norvilis (@zilton7).</description>
    <link>https://forem.com/zilton7</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F352433%2F2aff65b6-dba1-4f8c-8aaf-0bc84763ae23.jpg</url>
      <title>Forem: Zil Norvilis</title>
      <link>https://forem.com/zilton7</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/zilton7"/>
    <language>en</language>
    <item>
      <title>n8n vs ByteChef: Which Automation Engine Should You Self-Host?</title>
      <dc:creator>Zil Norvilis</dc:creator>
      <pubDate>Sun, 24 May 2026 18:10:41 +0000</pubDate>
      <link>https://forem.com/zilton7/n8n-vs-bytechef-which-automation-engine-should-you-self-host-1i1m</link>
      <guid>https://forem.com/zilton7/n8n-vs-bytechef-which-automation-engine-should-you-self-host-1i1m</guid>
      <description>&lt;p&gt;Automating the "glue work" of a SaaS business is a survival skill for a solo developer. You need to sync Stripe payments to your database, send Discord alerts for new sign-ups, and maybe trigger AI summaries for incoming emails. &lt;/p&gt;

&lt;p&gt;For a long time, &lt;strong&gt;n8n&lt;/strong&gt; has been the undisputed king of self-hosted automation. It’s the tool I’ve recommended for years because it lets you escape the high costs of Zapier while keeping your Rails monolith clean. &lt;/p&gt;

&lt;p&gt;But recently, a new challenger called &lt;strong&gt;ByteChef&lt;/strong&gt; has started gaining traction in the open-source community. It promises a more "developer-first" approach to low-code integrations. &lt;/p&gt;

&lt;p&gt;If you are looking to set up an automation server on a $5 VPS this weekend, which one should you choose? Here is the breakdown.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. n8n: The Versatile Giant
&lt;/h2&gt;

&lt;p&gt;n8n is the "Photoshop" of automation. It is built on Node.js and uses a beautiful, flexible node-based interface where you can drag and drop connections in any direction.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Strength: The Ecosystem&lt;/strong&gt;&lt;br&gt;
In 2026, n8n has over 400 native integrations. Whether you need to talk to a niche Lithuanian shipping API or a major player like OpenAI, n8n probably already has a node for it. &lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Coder’s Edge: JavaScript Nodes&lt;/strong&gt;&lt;br&gt;
If a built-in node doesn't do exactly what you want, you can drop in a "Code Node" and write raw JavaScript. Since n8n runs on Node.js, you have access to the entire NPM ecosystem.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// A simple n8n code node example&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;items&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;$input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/ /g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;-&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The Catch:&lt;/strong&gt; Because n8n is so flexible, complex workflows can eventually look like a "bowl of spaghetti" with wires crossing everywhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. ByteChef: The Structured Alternative
&lt;/h2&gt;

&lt;p&gt;ByteChef is the newer kid on the block. While n8n focuses on the "Canvas" feel, ByteChef is built with a focus on &lt;strong&gt;Integration Management&lt;/strong&gt;. It’s designed for people who want to build and manage hundreds of different integrations without losing their mind.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Strength: Multi-Step Logic and Performance&lt;/strong&gt;&lt;br&gt;
ByteChef is built on a Java-based stack (Spring Boot), which makes it incredibly performant for heavy data processing. It feels more "rigid" than n8n, but in a way that encourages better organization. It’s less of a "playground" and more of an "engine."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Coder’s Edge: Component Building&lt;/strong&gt;&lt;br&gt;
ByteChef allows you to build your own components using a very structured schema. If you are an engineer who likes to define strict inputs and outputs for your automations, ByteChef's approach will feel very professional.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Catch:&lt;/strong&gt; The community is much smaller. You might find yourself having to write custom connectors for services that n8n already supports out of the box.&lt;/p&gt;

&lt;h2&gt;
  
  
  Side-by-Side Comparison
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Feature&lt;/th&gt;
&lt;th&gt;n8n&lt;/th&gt;
&lt;th&gt;ByteChef&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Language&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Node.js (JavaScript)&lt;/td&gt;
&lt;td&gt;Java / TypeScript&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;UI Vibe&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Infinite Canvas (Freeform)&lt;/td&gt;
&lt;td&gt;Structured Workflows&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Integrations&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;400+ (Huge)&lt;/td&gt;
&lt;td&gt;Growing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Self-Hosting&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Very Easy (Docker)&lt;/td&gt;
&lt;td&gt;Easy (Docker)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Custom Code&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;JavaScript everywhere&lt;/td&gt;
&lt;td&gt;TypeScript / Java&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Why I’m Sticking with n8n (For Now)
&lt;/h2&gt;

&lt;p&gt;As a solo developer using the "One-Person Framework" philosophy, &lt;strong&gt;n8n is still the winner for 2026.&lt;/strong&gt; &lt;/p&gt;

&lt;ol&gt;
&lt;li&gt; &lt;strong&gt;Community Support:&lt;/strong&gt; If you have a weird error in an n8n workflow, a quick Google search usually finds a forum post with the solution. ByteChef is still building that knowledge base.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;The "Vibe" Factor:&lt;/strong&gt; n8n's visual debugging is incredible. You can see the data moving through the wires in real-time. When you're "Vibe Coding" with AI assistance, being able to &lt;em&gt;see&lt;/em&gt; the failure point instantly is a huge time-saver.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Low Overhead:&lt;/strong&gt; If you are already a web developer, you probably know enough JavaScript to fix anything in n8n. Learning the ByteChef component structure feels like a bigger "homework" assignment.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Choose n8n&lt;/strong&gt; if you want the most reliable, well-documented, and flexible tool available. It is perfect for 99% of solo-dev automation needs.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Choose ByteChef&lt;/strong&gt; if you are building an automation-heavy product where you need to manage complex, high-performance data pipelines and you prefer a more "engineered" feel over a "drawing" feel.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Regardless of which tool you pick, the most important thing is to &lt;strong&gt;stop writing custom Ruby scripts for third-party integrations.&lt;/strong&gt; Get those tasks out of your Rails app and into a dedicated automation engine.&lt;/p&gt;

</description>
      <category>productivity</category>
      <category>automation</category>
      <category>webdev</category>
      <category>devops</category>
    </item>
    <item>
      <title>Distributed Uniqueness: Implementing Twitter Snowflake IDs in Rails 8</title>
      <dc:creator>Zil Norvilis</dc:creator>
      <pubDate>Sat, 23 May 2026 17:50:24 +0000</pubDate>
      <link>https://forem.com/zilton7/distributed-uniqueness-implementing-twitter-snowflake-ids-in-rails-8-56oh</link>
      <guid>https://forem.com/zilton7/distributed-uniqueness-implementing-twitter-snowflake-ids-in-rails-8-56oh</guid>
      <description>&lt;p&gt;When you run &lt;code&gt;rails generate model&lt;/code&gt;, Rails defaults to using a standard auto-incrementing integer for your primary keys. Your first user is &lt;code&gt;ID: 1&lt;/code&gt;, your second is &lt;code&gt;ID: 2&lt;/code&gt;, and so on.&lt;/p&gt;

&lt;p&gt;For a long time, this was fine. But as you grow, auto-incrementing IDs become a problem for two reasons:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt; &lt;strong&gt;Security:&lt;/strong&gt; A competitor can see you only have 500 orders by looking at the URL &lt;code&gt;/orders/500&lt;/code&gt;. They can even "scrape" your entire database by just incrementing the ID in the URL.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Distributed Systems:&lt;/strong&gt; If you ever need to scale to multiple databases or allow offline creation (like in a mobile app), two different databases might try to assign &lt;code&gt;ID: 501&lt;/code&gt; to different things at the same time.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Many developers switch to &lt;strong&gt;UUIDs&lt;/strong&gt; to fix this. But UUIDs are huge, they are slow to index in Postgres, and they look ugly in URLs (&lt;code&gt;/users/550e8400-e29b-41d4-a716-446655440000&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;In 2026, the "Goldilocks" solution for the solo developer is the &lt;strong&gt;Snowflake ID&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is a Snowflake ID?
&lt;/h2&gt;

&lt;p&gt;Originally created by Twitter, a Snowflake ID is a 64-bit integer that is &lt;strong&gt;guaranteed to be unique&lt;/strong&gt; without needing a central "counter" in the database.&lt;/p&gt;

&lt;p&gt;The magic is in how the 64 bits are broken down:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;41 bits&lt;/strong&gt; for a timestamp (gives you ~69 years of IDs).&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;10 bits&lt;/strong&gt; for a "Worker ID" (identifies which server generated the ID).&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;12 bits&lt;/strong&gt; for a sequence number (allows 4,096 IDs per millisecond, per server).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Because the first part of the ID is time, Snowflake IDs are &lt;strong&gt;roughly time-ordered&lt;/strong&gt;. This makes them much faster for databases to index than random UUIDs.&lt;/p&gt;

&lt;h2&gt;
  
  
  STEP 1: The Ruby Implementation
&lt;/h2&gt;

&lt;p&gt;You don't need to write the bit-shifting logic yourself. There is a great, lightweight gem called &lt;code&gt;snowflake_id&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Add it to your &lt;code&gt;Gemfile&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;gem&lt;/span&gt; &lt;span class="s1"&gt;'snowflake_id'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can now generate an ID anywhere in Ruby:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="no"&gt;SnowflakeId&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;generator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;next_id&lt;/span&gt;
&lt;span class="c1"&gt;# =&amp;gt; 1782345678912345678 (A clean, big integer)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  STEP 2: Making it the Primary Key in Rails
&lt;/h2&gt;

&lt;p&gt;To use Snowflake IDs as your primary keys, we need to tell Rails to stop using auto-increment and instead generate a Snowflake ID before saving the record.&lt;/p&gt;

&lt;p&gt;First, create a &lt;strong&gt;Concern&lt;/strong&gt; to make this reusable across all your models.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# app/models/concerns/has_snowflake_id.rb&lt;/span&gt;
&lt;span class="k"&gt;module&lt;/span&gt; &lt;span class="nn"&gt;HasSnowflakeId&lt;/span&gt;
  &lt;span class="kp"&gt;extend&lt;/span&gt; &lt;span class="no"&gt;ActiveSupport&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Concern&lt;/span&gt;

  &lt;span class="n"&gt;included&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="c1"&gt;# Set the ID before the record is created in the DB&lt;/span&gt;
    &lt;span class="n"&gt;before_create&lt;/span&gt; &lt;span class="ss"&gt;:assign_snowflake_id&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="kp"&gt;private&lt;/span&gt;

  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;assign_snowflake_id&lt;/span&gt;
    &lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt; &lt;span class="o"&gt;||=&lt;/span&gt; &lt;span class="no"&gt;SnowflakeId&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;generator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;next_id&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  STEP 3: The Migration
&lt;/h2&gt;

&lt;p&gt;When you generate a new model, you need to tell the migration &lt;em&gt;not&lt;/em&gt; to use auto-increment.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# db/migrate/20260101000000_create_posts.rb&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;CreatePosts&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;ActiveRecord&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Migration&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mf"&gt;8.0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;change&lt;/span&gt;
    &lt;span class="c1"&gt;# id: false stops the default auto-increment&lt;/span&gt;
    &lt;span class="n"&gt;create_table&lt;/span&gt; &lt;span class="ss"&gt;:posts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;id: &lt;/span&gt;&lt;span class="kp"&gt;false&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
      &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;primary_key&lt;/span&gt; &lt;span class="ss"&gt;:id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:bigint&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;primary_key: &lt;/span&gt;&lt;span class="kp"&gt;true&lt;/span&gt;
      &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt; &lt;span class="ss"&gt;:title&lt;/span&gt;
      &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;timestamps&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then, in your model:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Post&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;ApplicationRecord&lt;/span&gt;
  &lt;span class="kp"&gt;include&lt;/span&gt; &lt;span class="no"&gt;HasSnowflakeId&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Why this is a Win for Solo Devs
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Cleaner URLs
&lt;/h3&gt;

&lt;p&gt;Your URLs go from &lt;code&gt;/posts/123&lt;/code&gt; (insecure) or &lt;code&gt;/posts/550e8400...&lt;/code&gt; (ugly) to &lt;code&gt;/posts/4829104928172349&lt;/code&gt;. It looks professional and hides your business volume from prying eyes.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Native Performance
&lt;/h3&gt;

&lt;p&gt;Because it's just a &lt;code&gt;bigint&lt;/code&gt; (a 64-bit integer), PostgreSQL handles it natively and extremely fast. It takes up half the space of a UUID, which keeps your database smaller and your backups faster.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Frontend Friendly
&lt;/h3&gt;

&lt;p&gt;Since it’s just a number, JavaScript handles it easily (though be careful with very large numbers in JS - always return them as strings in your JSON API to avoid precision loss!).&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;Snowflake IDs give you the best of both worlds: the distributed uniqueness of a UUID and the performance of an integer. &lt;/p&gt;

&lt;p&gt;If you are building a new Rails 8 app today, consider ditching auto-increment on day one. It’s one of those architectural choices that makes your app feel like a "real" enterprise product from the very first commit.&lt;/p&gt;

</description>
      <category>rails</category>
      <category>ruby</category>
      <category>database</category>
      <category>architecture</category>
    </item>
    <item>
      <title>The Vibe Coder’s Survival Guide: Concepts You Can’t Just Prompt Away</title>
      <dc:creator>Zil Norvilis</dc:creator>
      <pubDate>Wed, 20 May 2026 23:16:59 +0000</pubDate>
      <link>https://forem.com/zilton7/the-vibe-coders-survival-guide-concepts-you-cant-just-prompt-away-21d8</link>
      <guid>https://forem.com/zilton7/the-vibe-coders-survival-guide-concepts-you-cant-just-prompt-away-21d8</guid>
      <description>&lt;p&gt;We are living in a wild time for software development. With tools like Cursor, Windsurf, and ChatGPT, you can "vibe" your way into a working application without knowing how to write a single line of CSS or Ruby from scratch. &lt;/p&gt;

&lt;p&gt;But I have noticed a pattern: &lt;strong&gt;Vibe coding works until it doesn't.&lt;/strong&gt; &lt;/p&gt;

&lt;p&gt;The moment the AI makes a mistake, or your app gets a weird error, you hit a wall. If you don't understand the underlying concepts, you can't tell the AI how to fix the mess it made. You become a "Prompt Monkey" - just typing instructions and hoping for the best.&lt;/p&gt;

&lt;p&gt;To be a truly successful Vibe Coder (or what I call an &lt;strong&gt;Editor-in-Chief&lt;/strong&gt;), you need to understand 5 core concepts. You don't need to memorize the syntax, but you need to understand the "Why."&lt;/p&gt;

&lt;h2&gt;
  
  
  LEVEL 1: State (The Memory)
&lt;/h2&gt;

&lt;p&gt;The most important question in any app is: &lt;em&gt;"What does the computer remember right now?"&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Imagine your app is a person. &lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Local State:&lt;/strong&gt; This is like a thought in the person's head. If they go to sleep (refresh the page), they forget it. &lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Global/Database State:&lt;/strong&gt; This is like a note written in a notebook. Even if the person dies (the server restarts), the information is still there.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Why this matters for Vibes:&lt;/strong&gt; If you ask the AI to "Change the user's name," but you don't specify if it should change it in the &lt;strong&gt;Browser&lt;/strong&gt; or the &lt;strong&gt;Database&lt;/strong&gt;, the AI might choose the wrong one. You’ll see the name change on the screen, but it will revert back the moment you refresh.&lt;/p&gt;

&lt;h2&gt;
  
  
  LEVEL 2: Control Flow (The Path)
&lt;/h2&gt;

&lt;p&gt;AI is great at writing logic, but it doesn't always understand the "Path" a user takes. Control flow is just a fancy way of saying "If this happens, do that."&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;subscribed?&lt;/span&gt;
  &lt;span class="n"&gt;show_video&lt;/span&gt;
&lt;span class="k"&gt;else&lt;/span&gt;
  &lt;span class="n"&gt;redirect_to_pricing_page&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Why this matters for Vibes:&lt;/strong&gt; When your app has a bug, it’s usually because the "Path" is broken. You need to be able to look at the code and say: &lt;em&gt;"Wait, the AI is checking if the user is an admin BEFORE it checks if the user is even logged in."&lt;/em&gt; Identifying the order of logic is a human job.&lt;/p&gt;

&lt;h2&gt;
  
  
  LEVEL 3: The Request/Response Cycle (The Mailman)
&lt;/h2&gt;

&lt;p&gt;This is where most Vibe Coders get stuck. They try to run a database query inside a JavaScript file that lives in the browser. &lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Golden Rule:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Frontend (The Browser):&lt;/strong&gt; This is the "Face." It handles what things look like and clicking buttons. It cannot talk to the database directly.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Backend (The Server/Rails):&lt;/strong&gt; This is the "Brain." It has the keys to the vault (The Database).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Why this matters for Vibes:&lt;/strong&gt; If the AI gives you code that doesn't work, check where it put the code. If it’s trying to send an email from a file in &lt;code&gt;app/javascript&lt;/code&gt;, it will fail because the browser isn't allowed to send emails. You have to tell the AI: &lt;em&gt;"Move this logic to the controller."&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  LEVEL 4: Data Shapes (Arrays and Hashes)
&lt;/h2&gt;

&lt;p&gt;You don't need to know how to sort an array, but you need to know what they look like.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Array:&lt;/strong&gt; A simple list. &lt;code&gt;["Apple", "Banana", "Orange"]&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Hash (or Object):&lt;/strong&gt; A dictionary with keys. &lt;code&gt;{ name: "Zil", country: "Lithuania" }&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Why this matters for Vibes:&lt;/strong&gt; When the AI says "Error: undefined method [] for NilClass," it usually means it expected a Hash but got nothing. You need to be able to look at the data coming from an API and see if the "Shape" matches what your code expects.&lt;/p&gt;

&lt;h2&gt;
  
  
  LEVEL 5: Persistence (The Database)
&lt;/h2&gt;

&lt;p&gt;As a Rails dev, I love ActiveRecord. It makes the database feel like Ruby. But you still need to understand that the database is just a giant Excel spreadsheet.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Models:&lt;/strong&gt; The name of the tab in the spreadsheet (e.g., "Users").&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Rows:&lt;/strong&gt; One specific user.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Columns:&lt;/strong&gt; The name, email, and password.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Why this matters for Vibes:&lt;/strong&gt; Sometimes the AI will suggest code that uses a column that doesn't exist yet (e.g., &lt;code&gt;user.avatar&lt;/code&gt;). You need to realize: &lt;em&gt;"Oh, I haven't run a migration to add the avatar column to the spreadsheet yet."&lt;/em&gt; The AI can't always see your database schema perfectly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary: The Hybrid Workflow
&lt;/h2&gt;

&lt;p&gt;Vibe coding is a superpower, but every superhero needs a mentor. &lt;/p&gt;

&lt;ol&gt;
&lt;li&gt; &lt;strong&gt;Vibe the feature:&lt;/strong&gt; Let the AI build the first version.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Audit the State:&lt;/strong&gt; Did it save to the DB or just the screen?&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Check the Path:&lt;/strong&gt; Is the logic in the right order?&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Verify the Location:&lt;/strong&gt; Is this a Frontend task or a Backend task?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you understand these 5 things, you stop being a passenger and start being the pilot. You can build much more complex apps because you know how to debug the "Vibe."&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>beginners</category>
      <category>ai</category>
      <category>productivity</category>
    </item>
    <item>
      <title>The Perfect Zsh Setup: Oh My Zsh on CachyOS/Arch</title>
      <dc:creator>Zil Norvilis</dc:creator>
      <pubDate>Tue, 19 May 2026 23:16:53 +0000</pubDate>
      <link>https://forem.com/zilton7/the-perfect-zsh-setup-oh-my-zsh-on-cachyosarch-1ndg</link>
      <guid>https://forem.com/zilton7/the-perfect-zsh-setup-oh-my-zsh-on-cachyosarch-1ndg</guid>
      <description>&lt;p&gt;If you want a terminal that tells you if a command is valid, predicts what you’re about to type, and shows your Git branches, follow this clean-slate guide.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Install the Foundations
&lt;/h2&gt;

&lt;p&gt;Before installing the framework, we need the "Holy Trinity" of Zsh plugins and a font that can handle icons.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Install the core plugins and Git&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;pacman &lt;span class="nt"&gt;-S&lt;/span&gt; zsh-syntax-highlighting zsh-autosuggestions git ttf-jetbrains-mono-nerd
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  2. Install Oh My Zsh
&lt;/h2&gt;

&lt;p&gt;Run the official script to install the framework:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;sh &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  3. The "Master" Configuration (&lt;code&gt;.zshrc&lt;/code&gt;)
&lt;/h2&gt;

&lt;p&gt;This is the most important part. We need to tell Zsh to load the plugins from the system folders (where Arch installs them) and hook up Ruby.&lt;/p&gt;

&lt;p&gt;Open your config: &lt;code&gt;nano ~/.zshrc&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Delete everything inside and paste this optimized block:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1. Path to Oh My Zsh&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;ZSH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="s2"&gt;/.oh-my-zsh"&lt;/span&gt;

&lt;span class="c"&gt;# 2. Theme (Agnoster is the best for Git branch visibility)&lt;/span&gt;
&lt;span class="c"&gt;# Note: Requires a Nerd Font in terminal settings to see arrows/icons&lt;/span&gt;
&lt;span class="nv"&gt;ZSH_THEME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"agnoster"&lt;/span&gt;

&lt;span class="c"&gt;# 3. Core Plugins&lt;/span&gt;
&lt;span class="nv"&gt;plugins&lt;/span&gt;&lt;span class="o"&gt;=(&lt;/span&gt;git ruby rbenv &lt;span class="nb"&gt;sudo &lt;/span&gt;archlinux&lt;span class="o"&gt;)&lt;/span&gt;

&lt;span class="c"&gt;# 4. Load Oh My Zsh&lt;/span&gt;
&lt;span class="nb"&gt;source&lt;/span&gt; &lt;span class="nv"&gt;$ZSH&lt;/span&gt;/oh-my-zsh.sh

&lt;span class="c"&gt;# 5. Developer Tools (rbenv for Ruby)&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;PATH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="s2"&gt;/.rbenv/bin:&lt;/span&gt;&lt;span class="nv"&gt;$PATH&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;eval&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;rbenv init -&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="c"&gt;# 6. Pro Features (Syntax Highlighting &amp;amp; Autosuggestions)&lt;/span&gt;
&lt;span class="c"&gt;# We source these manually to ensure they work perfectly on Arch/Cachy&lt;/span&gt;
&lt;span class="nb"&gt;source&lt;/span&gt; /usr/share/zsh/plugins/zsh-syntax-highlighting/zsh-syntax-highlighting.zsh
&lt;span class="nb"&gt;source&lt;/span&gt; /usr/share/zsh/plugins/zsh-autosuggestions/zsh-autosuggestions.zsh

&lt;span class="c"&gt;# 7. Helpful Aliases&lt;/span&gt;
&lt;span class="nb"&gt;alias &lt;/span&gt;&lt;span class="nv"&gt;yay&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'paru'&lt;/span&gt;
&lt;span class="nb"&gt;alias &lt;/span&gt;&lt;span class="nv"&gt;cls&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'clear'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  4. Final Polish (Icons &amp;amp; Colors)
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Fix the Icons
&lt;/h3&gt;

&lt;p&gt;If you see squares instead of arrows/branches:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Open your Terminal (Konsole/Alacritty) &lt;strong&gt;Settings&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Go to &lt;strong&gt;Appearance&lt;/strong&gt; -&amp;gt; &lt;strong&gt;Font&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Select &lt;strong&gt;JetBrainsMono Nerd Font&lt;/strong&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Apply Changes
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;source&lt;/span&gt; ~/.zshrc
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Why this setup is better:
&lt;/h2&gt;

&lt;h3&gt;
  
  
  ✅ Green/Red Syntax
&lt;/h3&gt;

&lt;p&gt;Type &lt;code&gt;ls&lt;/code&gt;  -  it turns &lt;strong&gt;green&lt;/strong&gt; because the command exists.&lt;br&gt;
Type &lt;code&gt;asdf&lt;/code&gt;  -  it turns &lt;strong&gt;red&lt;/strong&gt; because the command is invalid. No more "command not found" surprises.&lt;/p&gt;

&lt;h3&gt;
  
  
  ✅ Inline Ghost Suggestions
&lt;/h3&gt;

&lt;p&gt;As you type, you'll see a &lt;strong&gt;gray preview&lt;/strong&gt; of your last used command. Just hit the &lt;strong&gt;Right Arrow&lt;/strong&gt; key to complete it. It's like autocomplete for your terminal.&lt;/p&gt;

&lt;h3&gt;
  
  
  ✅ Smart Git Branches
&lt;/h3&gt;

&lt;p&gt;The moment you &lt;code&gt;cd&lt;/code&gt; into a project folder, the prompt changes to show exactly which &lt;strong&gt;branch&lt;/strong&gt; you are on and if you have uncommitted changes.&lt;/p&gt;

&lt;h3&gt;
  
  
  ✅ rbenv Integration
&lt;/h3&gt;

&lt;p&gt;Ruby versions are automatically handled. If you have a &lt;code&gt;.ruby-version&lt;/code&gt; file in a project, the shell switches to it immediately.&lt;/p&gt;




&lt;h3&gt;
  
  
  Pro-Tip for CachyOS Users:
&lt;/h3&gt;

&lt;p&gt;CachyOS sometimes looks for your config in &lt;code&gt;~/.config/zsh/.zshrc&lt;/code&gt;. If your changes don't show up, run this to sync them:&lt;br&gt;
&lt;code&gt;mkdir -p ~/.config/zsh &amp;amp;&amp;amp; cp ~/.zshrc ~/.config/zsh/.zshrc&lt;/code&gt;&lt;/p&gt;

</description>
      <category>cachyos</category>
      <category>archlinux</category>
      <category>ohmyzsh</category>
      <category>zsh</category>
    </item>
    <item>
      <title>Completing the Puzzle: Adding Signup and Password Resets to Rails 8 Auth</title>
      <dc:creator>Zil Norvilis</dc:creator>
      <pubDate>Mon, 18 May 2026 23:17:29 +0000</pubDate>
      <link>https://forem.com/zilton7/completing-the-puzzle-adding-signup-and-password-resets-to-rails-8-auth-2im5</link>
      <guid>https://forem.com/zilton7/completing-the-puzzle-adding-signup-and-password-resets-to-rails-8-auth-2im5</guid>
      <description>&lt;p&gt;If you’ve tried the new &lt;code&gt;rails generate authentication&lt;/code&gt; command in Rails 8, you know it’s a breath of fresh air. It gives you a solid, secure foundation for sessions and login without the "black box" magic of Devise.&lt;/p&gt;

&lt;p&gt;However, the generator is intentionally minimal. It gives you the "Login" and "Logout" logic, but it leaves two big holes: &lt;strong&gt;How do users sign up?&lt;/strong&gt; and &lt;strong&gt;How do they reset their password?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Because the auth code now lives in your &lt;code&gt;app/&lt;/code&gt; folder, adding these features is straightforward. You don't have to learn a gem's DSL; you just write standard Rails code. Here is how to complete your authentication system in two phases.&lt;/p&gt;




&lt;h2&gt;
  
  
  Phase 1: The Signup Flow (Registrations)
&lt;/h2&gt;

&lt;p&gt;Signing up a user is just a standard "Create" action in ActiveRecord. We just need a controller and a view.&lt;/p&gt;

&lt;h3&gt;
  
  
  STEP 1: The Controller
&lt;/h3&gt;

&lt;p&gt;Generate a registrations controller:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;rails generate controller registrations
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Update the code to create a user and immediately log them in by starting a session:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# app/controllers/registrations_controller.rb&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;RegistrationsController&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;ApplicationController&lt;/span&gt;
  &lt;span class="c1"&gt;# Allow guests to see this page!&lt;/span&gt;
  &lt;span class="n"&gt;allow_unauthenticated_access&lt;/span&gt; &lt;span class="ss"&gt;only: &lt;/span&gt;&lt;span class="sx"&gt;%i[ new create ]&lt;/span&gt;

  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;new&lt;/span&gt;
    &lt;span class="vi"&gt;@user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;User&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;create&lt;/span&gt;
    &lt;span class="vi"&gt;@user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;User&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_params&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="vi"&gt;@user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;
      &lt;span class="c1"&gt;# The 'start_new_session_for' method comes from the &lt;/span&gt;
      &lt;span class="c1"&gt;# Authenticated concern generated by Rails 8&lt;/span&gt;
      &lt;span class="n"&gt;start_new_session_for&lt;/span&gt; &lt;span class="vi"&gt;@user&lt;/span&gt;
      &lt;span class="n"&gt;redirect_to&lt;/span&gt; &lt;span class="n"&gt;root_path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;notice: &lt;/span&gt;&lt;span class="s2"&gt;"Welcome! You have signed up successfully."&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt;
      &lt;span class="n"&gt;render&lt;/span&gt; &lt;span class="ss"&gt;:new&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;status: :unprocessable_entity&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="kp"&gt;private&lt;/span&gt;

  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;user_params&lt;/span&gt;
    &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:user&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;permit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:email_address&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:password&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:password_confirmation&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  STEP 2: The Routes and View
&lt;/h3&gt;

&lt;p&gt;Add the routes to your &lt;code&gt;config/routes.rb&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;get&lt;/span&gt;  &lt;span class="s2"&gt;"signup"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;to: &lt;/span&gt;&lt;span class="s2"&gt;"registrations#new"&lt;/span&gt;
&lt;span class="n"&gt;post&lt;/span&gt; &lt;span class="s2"&gt;"signup"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;to: &lt;/span&gt;&lt;span class="s2"&gt;"registrations#create"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In your view (&lt;code&gt;app/views/registrations/new.html.erb&lt;/code&gt;), use a standard form. Remember to use &lt;code&gt;email_address&lt;/code&gt; as the field name, as that is what the Rails 8 generator uses by default in the User model.&lt;/p&gt;




&lt;h2&gt;
  
  
  Phase 2: Password Resets (The Modern Way)
&lt;/h2&gt;

&lt;p&gt;In the old days, we had to add a &lt;code&gt;reset_password_token&lt;/code&gt; column to our database. In Rails 8, we can use the built-in &lt;strong&gt;Generates Token&lt;/strong&gt; API to create time-sensitive tokens without extra database columns.&lt;/p&gt;

&lt;h3&gt;
  
  
  STEP 1: Update the User Model
&lt;/h3&gt;

&lt;p&gt;Tell Rails that the User model can generate tokens for password resets.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# app/models/user.rb&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;User&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;ApplicationRecord&lt;/span&gt;
  &lt;span class="n"&gt;has_secure_password&lt;/span&gt;
  &lt;span class="n"&gt;has_many&lt;/span&gt; &lt;span class="ss"&gt;:sessions&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;dependent: :destroy&lt;/span&gt;

  &lt;span class="c1"&gt;# Generate a token that expires in 15 minutes&lt;/span&gt;
  &lt;span class="n"&gt;generates_token_for&lt;/span&gt; &lt;span class="ss"&gt;:password_reset&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;expires_in: &lt;/span&gt;&lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;minutes&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="n"&gt;password_salt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;last&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  STEP 2: The Passwords Controller
&lt;/h3&gt;

&lt;p&gt;We need two main actions: &lt;code&gt;create&lt;/code&gt; (to send the email) and &lt;code&gt;update&lt;/code&gt; (to change the password).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;rails generate controller passwords
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# app/controllers/passwords_controller.rb&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;PasswordsController&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;ApplicationController&lt;/span&gt;
  &lt;span class="n"&gt;allow_unauthenticated_access&lt;/span&gt;
  &lt;span class="n"&gt;before_action&lt;/span&gt; &lt;span class="ss"&gt;:set_user_by_token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;only: &lt;/span&gt;&lt;span class="sx"&gt;%i[ edit update ]&lt;/span&gt;

  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;create&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;User&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find_by&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;email_address: &lt;/span&gt;&lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:email_address&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
      &lt;span class="c1"&gt;# Send the email (We'll make the mailer in the next step)&lt;/span&gt;
      &lt;span class="no"&gt;PasswordsMailer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;deliver_later&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;
    &lt;span class="c1"&gt;# Always redirect to the same page so hackers can't "fish" for emails&lt;/span&gt;
    &lt;span class="n"&gt;redirect_to&lt;/span&gt; &lt;span class="n"&gt;new_session_path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;notice: &lt;/span&gt;&lt;span class="s2"&gt;"If that email exists, a reset link has been sent."&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;update&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="vi"&gt;@user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;permit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:password&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:password_confirmation&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
      &lt;span class="n"&gt;redirect_to&lt;/span&gt; &lt;span class="n"&gt;new_session_path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;notice: &lt;/span&gt;&lt;span class="s2"&gt;"Password has been reset. Please log in."&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt;
      &lt;span class="n"&gt;render&lt;/span&gt; &lt;span class="ss"&gt;:edit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;status: :unprocessable_entity&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;

  &lt;span class="kp"&gt;private&lt;/span&gt;

  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;set_user_by_token&lt;/span&gt;
    &lt;span class="c1"&gt;# Find the user using the token from the URL&lt;/span&gt;
    &lt;span class="vi"&gt;@user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;User&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find_by_token_for!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:password_reset&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:token&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
  &lt;span class="k"&gt;rescue&lt;/span&gt; &lt;span class="no"&gt;StandardError&lt;/span&gt;
    &lt;span class="n"&gt;redirect_to&lt;/span&gt; &lt;span class="n"&gt;new_password_path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;alert: &lt;/span&gt;&lt;span class="s2"&gt;"Invalid or expired token."&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  STEP 3: The Mailer
&lt;/h3&gt;

&lt;p&gt;Generate a mailer to send the reset link:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;rails generate mailer Passwords
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# app/mailers/passwords_mailer.rb&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;PasswordsMailer&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;ApplicationMailer&lt;/span&gt;
  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;reset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="vi"&gt;@user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;
    &lt;span class="c1"&gt;# Generate the token specifically for this email&lt;/span&gt;
    &lt;span class="vi"&gt;@token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;generate_token_for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:password_reset&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;mail&lt;/span&gt; &lt;span class="ss"&gt;to: &lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;email_address&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;subject: &lt;/span&gt;&lt;span class="s2"&gt;"Reset your password"&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In your mailer view, create the link:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight erb"&gt;&lt;code&gt;&lt;span class="cp"&gt;&amp;lt;%=&lt;/span&gt; &lt;span class="n"&gt;link_to&lt;/span&gt; &lt;span class="s2"&gt;"Reset Password"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;edit_password_url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;token: &lt;/span&gt;&lt;span class="vi"&gt;@token&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  STEP 4: The Routes
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;resources&lt;/span&gt; &lt;span class="ss"&gt;:passwords&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;param: :token&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Why this is better than a Gem
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;No Hidden Logic:&lt;/strong&gt; If your "Password Reset" email isn't sending, you can debug it in your own mailer file. You don't have to guess what a gem is doing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Speed:&lt;/strong&gt; You aren't loading extra middleware or heavy dependencies. &lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Customization:&lt;/strong&gt; Want to add an "SMS Reset" later? Or a "Security Question"? You own the controller, so you just add the code.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;The Rails 8 &lt;code&gt;authentication&lt;/code&gt; generator is a "starter kit," not a finished product. By adding a &lt;code&gt;RegistrationsController&lt;/code&gt; and using the &lt;code&gt;generates_token_for&lt;/code&gt; API, you get a full-featured, enterprise-grade auth system that is 100% under your control.&lt;/p&gt;

&lt;p&gt;Stop fighting the gems. Start writing the Ruby code.&lt;/p&gt;

</description>
      <category>rails</category>
      <category>ruby</category>
      <category>security</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Rails 8 Built-in Auth vs. Devise: Why the Default Finally Wins</title>
      <dc:creator>Zil Norvilis</dc:creator>
      <pubDate>Sun, 17 May 2026 23:23:33 +0000</pubDate>
      <link>https://forem.com/zilton7/rails-8-built-in-auth-vs-devise-why-the-default-finally-wins-2g2n</link>
      <guid>https://forem.com/zilton7/rails-8-built-in-auth-vs-devise-why-the-default-finally-wins-2g2n</guid>
      <description>&lt;p&gt;For almost 15 years, if you wanted to build a Rails app with a login system, the answer was always the same: &lt;strong&gt;Devise&lt;/strong&gt;. &lt;/p&gt;

&lt;p&gt;Devise is a legend. It is battle-tested, secure, and handles everything from "Forgot Password" to "Account Locking." But Devise also comes with a heavy price. It is a "black box" of magic. If you want to change the way a simple login redirect works, you often find yourself digging through 10 layers of documentation and overriding internal controllers that you don't even own.&lt;/p&gt;

&lt;p&gt;With the release of &lt;strong&gt;Rails 8&lt;/strong&gt;, the game has changed. Rails now has a built-in authentication generator. It isn't a "gem" that hides code from you; it is a tool that writes clean Ruby code directly into your app.&lt;/p&gt;

&lt;p&gt;Here is why Rails 8 authentication is finally better than Devise for the solo developer.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Ownership vs. Magic
&lt;/h2&gt;

&lt;p&gt;When you install Devise, you are adding a massive dependency. The logic for signing in, signing out, and sessions lives inside the gem’s folder, not your app.&lt;/p&gt;

&lt;p&gt;In Rails 8, you run one command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;rails generate authentication
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This command generates a &lt;code&gt;Session&lt;/code&gt; model, a &lt;code&gt;SessionsController&lt;/code&gt;, and an &lt;code&gt;Authenticated&lt;/code&gt; concern. &lt;strong&gt;The code lives in your &lt;code&gt;app/&lt;/code&gt; folder.&lt;/strong&gt; &lt;/p&gt;

&lt;p&gt;If you want to change how the login works, you don't have to look up "Devise overrides." You just open &lt;code&gt;app/controllers/sessions_controller.rb&lt;/code&gt; and change the code. There is no magic - just plain Ruby.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. No More "Gem Bloat"
&lt;/h2&gt;

&lt;p&gt;Devise is heavy. It brings in several other dependencies (like Warden) and adds a lot of routes and helpers to your app that you probably don't use. &lt;/p&gt;

&lt;p&gt;As a solo developer building a "One-Person Framework" app, you want your stack to be as lean as possible. Rails 8 auth uses what is already built into the framework: &lt;code&gt;has_secure_password&lt;/code&gt;. &lt;/p&gt;

&lt;p&gt;It uses the standard Rails way of handling cookies and sessions. Because it is native, it works perfectly with &lt;strong&gt;Turbo&lt;/strong&gt; and &lt;strong&gt;Mission Control&lt;/strong&gt; without any extra configuration.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Rate Limiting is Built-In
&lt;/h2&gt;

&lt;p&gt;One reason people stuck with Devise was for security features like "Lockable" (preventing brute force attacks).&lt;/p&gt;

&lt;p&gt;Rails 8 handles this at the routing level. The generator automatically adds rate limiting to your login actions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# app/controllers/sessions_controller.rb&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;SessionsController&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;ApplicationController&lt;/span&gt;
  &lt;span class="c1"&gt;# This stops bots from trying 1,000 passwords a minute&lt;/span&gt;
  &lt;span class="n"&gt;rate_limit&lt;/span&gt; &lt;span class="ss"&gt;to: &lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;within: &lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;minutes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;only: :create&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;with: &lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;redirect_to&lt;/span&gt; &lt;span class="n"&gt;new_session_url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;alert: &lt;/span&gt;&lt;span class="s2"&gt;"Try again later."&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;create&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;User&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;authenticate_by&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;permit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:email_address&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:password&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
      &lt;span class="n"&gt;start_new_session_for&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;
      &lt;span class="n"&gt;redirect_to&lt;/span&gt; &lt;span class="n"&gt;after_authentication_url&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt;
      &lt;span class="n"&gt;redirect_to&lt;/span&gt; &lt;span class="n"&gt;new_session_url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;alert: &lt;/span&gt;&lt;span class="s2"&gt;"Try again."&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You get professional-grade security without the overhead of a massive external library.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Easy to Upgrade to Passkeys
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://norvilis.com/killing-the-password-how-to-add-passkeys-to-your-rails-8-app/" rel="noopener noreferrer"&gt;As I wrote in a previous article&lt;/a&gt;, passwords are dying. In 2026, users want to log in with &lt;strong&gt;FaceID or TouchID (Passkeys)&lt;/strong&gt;. &lt;/p&gt;

&lt;p&gt;Upgrading Devise to support Passkeys is a nightmare because you have to fight Devise's internal session handling. &lt;/p&gt;

&lt;p&gt;Because the Rails 8 auth code is just a regular controller, adding Passkeys is simple. You just add a few lines to your &lt;code&gt;SessionsController&lt;/code&gt; to verify the hardware signature. You don't have to ask a gem for permission to change how your users log in.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary: Which one should you pick?
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Stick with Devise&lt;/strong&gt; if you are working on a massive legacy app that already uses it, or if you need very complex features like "Omniauth with 10 different providers" and don't want to write any code.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Use Rails 8 Auth&lt;/strong&gt; for every new project. &lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The "generator" approach is the ultimate win for the solo developer. It gives you a secure starting point, but lets you keep total control over the most important part of your app: the gateway to your users.&lt;/p&gt;

</description>
      <category>rails</category>
      <category>ruby</category>
      <category>security</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Build Once, Launch Ten Times: The Rails Engine SaaS Strategy</title>
      <dc:creator>Zil Norvilis</dc:creator>
      <pubDate>Sat, 16 May 2026 23:06:10 +0000</pubDate>
      <link>https://forem.com/zilton7/build-once-launch-ten-times-the-rails-engine-saas-strategy-101p</link>
      <guid>https://forem.com/zilton7/build-once-launch-ten-times-the-rails-engine-saas-strategy-101p</guid>
      <description>&lt;p&gt;If you are a solo developer with "Shiny Object Syndrome," you know the feeling. You have a great idea for a new SaaS on a Saturday morning. You run &lt;code&gt;rails new&lt;/code&gt;, and then... you spend the next two days setting up the same boring stuff you’ve built ten times before.&lt;/p&gt;

&lt;p&gt;You set up User Authentication. You configure Stripe or Paddle for billing. You build a "Team" model for multi-tenancy. You design a basic Sidebar and Navbar. &lt;/p&gt;

&lt;p&gt;By Monday morning, you are exhausted, and you haven't even touched the actual "unique" feature of your new idea.&lt;/p&gt;

&lt;p&gt;Most people solve this with &lt;strong&gt;Templates&lt;/strong&gt; (copy-pasting an old project or using a starter kit). But templates have a big problem: they are a snapshot in time. If you find a bug in your billing logic, you have to manually fix it in five different projects.&lt;/p&gt;

&lt;p&gt;In 2026, the "Pro" way to do this as a solopreneur is to build a &lt;strong&gt;Rails Engine&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is a Rails Engine?
&lt;/h2&gt;

&lt;p&gt;Think of a Rails Engine as a "Mini-App" that lives inside a Gem. Gems like &lt;strong&gt;Devise&lt;/strong&gt;, &lt;strong&gt;Sidekiq&lt;/strong&gt;, and &lt;strong&gt;Avo&lt;/strong&gt; are all engines. They provide their own routes, models, and views that plug into your main application.&lt;/p&gt;

&lt;p&gt;By building your own "SaaS Core" engine, you can update your billing logic or UI components in one place, and every project you’ve ever launched will get the update instantly.&lt;/p&gt;

&lt;p&gt;Here is how to build your own reusable SaaS starter kit.&lt;/p&gt;

&lt;h2&gt;
  
  
  STEP 1: Generate the Engine
&lt;/h2&gt;

&lt;p&gt;We want to create a "mountable" engine. This gives us an isolated namespace so our engine code doesn't accidentally overwrite code in our main app.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;rails plugin new saas_core &lt;span class="nt"&gt;--mountable&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This creates a new folder structure that looks like a tiny Rails app. &lt;/p&gt;

&lt;h2&gt;
  
  
  STEP 2: Add the "Boring" SaaS Logic
&lt;/h2&gt;

&lt;p&gt;Inside your engine, you should add everything that is "standard" for your business model. &lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Essentials:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt; &lt;strong&gt;Authentication:&lt;/strong&gt; Use the Rails 8 &lt;code&gt;generate authentication&lt;/code&gt; logic here.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Billing:&lt;/strong&gt; Add a &lt;code&gt;Subscription&lt;/code&gt; model and a service object to talk to Stripe or LemonSqueezy.&lt;/li&gt;
&lt;li&gt; &lt;strong&gt;Multi-tenancy:&lt;/strong&gt; Add an &lt;code&gt;Account&lt;/code&gt; or &lt;code&gt;Organization&lt;/code&gt; model that &lt;code&gt;Users&lt;/code&gt; belong to.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Because this is a mountable engine, your models will be namespaced. Instead of &lt;code&gt;User&lt;/code&gt;, you will have &lt;code&gt;SaasCore::User&lt;/code&gt;. This is great because it prevents naming conflicts when you integrate it into a new project.&lt;/p&gt;

&lt;h2&gt;
  
  
  STEP 3: Shared UI (The Design System)
&lt;/h2&gt;

&lt;p&gt;This is the biggest time-saver. Don't just share logic; share your &lt;strong&gt;UI components&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;I use &lt;strong&gt;ViewComponent&lt;/strong&gt; or &lt;strong&gt;Phlex&lt;/strong&gt; inside my engine to build a library of standard buttons, forms, and layouts.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# saas_core/app/components/saas_core/sidebar_component.rb&lt;/span&gt;
&lt;span class="k"&gt;module&lt;/span&gt; &lt;span class="nn"&gt;SaasCore&lt;/span&gt;
  &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;SidebarComponent&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;ViewComponent&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Base&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;initialize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;:)&lt;/span&gt;
      &lt;span class="vi"&gt;@user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now, every SaaS you build will have the exact same professional look and feel from minute one.&lt;/p&gt;

&lt;h2&gt;
  
  
  STEP 4: The Local Development Workflow
&lt;/h2&gt;

&lt;p&gt;As a solo dev, you don't want to publish your engine to a public server every time you make a change. You want to develop it side-by-side with your new SaaS idea.&lt;/p&gt;

&lt;p&gt;In your new SaaS project's &lt;code&gt;Gemfile&lt;/code&gt;, point to the local path of your engine:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# my_new_idea/Gemfile&lt;/span&gt;
&lt;span class="n"&gt;gem&lt;/span&gt; &lt;span class="s1"&gt;'saas_core'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;path: &lt;/span&gt;&lt;span class="s1"&gt;'../saas_core'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And in your routes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# my_new_idea/config/routes.rb&lt;/span&gt;
&lt;span class="n"&gt;mount&lt;/span&gt; &lt;span class="no"&gt;SaasCore&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Engine&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"/"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now, when you edit a file in the &lt;code&gt;saas_core&lt;/code&gt; folder, the changes show up instantly in &lt;code&gt;my_new_idea&lt;/code&gt;. &lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is the ultimate Solopreneur hack
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. The "Bug Once, Fix Everywhere" Rule
&lt;/h3&gt;

&lt;p&gt;If you realize your GDPR cookie banner is broken, you fix it in the engine. You run &lt;code&gt;bundle update&lt;/code&gt; in your 3 active SaaS projects, and they are all compliant again.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Zero-Friction Launching
&lt;/h3&gt;

&lt;p&gt;When you have a new idea, your &lt;code&gt;rails new&lt;/code&gt; process takes 10 seconds. You add your engine, and you already have a "Pro" plan, a login screen, and a beautiful dashboard. You can focus 100% of your energy on the &lt;strong&gt;Actual Idea&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Ownership
&lt;/h3&gt;

&lt;p&gt;You aren't relying on a third-party "SaaS Template" that might get abandoned. You own the core of your business. As you learn new tricks (like Rails 8 Page Morphing), you add them to your engine, and your entire "Empire" gets an upgrade.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;Stop starting from scratch. Stop copy-pasting messy code from your last project.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt; Build a &lt;strong&gt;Mountable Engine&lt;/strong&gt; for your SaaS "Plumbing."&lt;/li&gt;
&lt;li&gt; Include Auth, Billing, and Teams.&lt;/li&gt;
&lt;li&gt; Bundle your UI into ViewComponents.&lt;/li&gt;
&lt;li&gt; Develop locally using the &lt;code&gt;path:&lt;/code&gt; gem option.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The goal of the "One-Person Framework" is to make you as fast as a team of ten. Building your own Rails Engine is how you achieve that speed.&lt;/p&gt;

</description>
      <category>rails</category>
      <category>ruby</category>
      <category>saas</category>
      <category>architecture</category>
    </item>
    <item>
      <title>Ditching Redis: How to Handle WebSockets in Rails 8 with Solid Cable</title>
      <dc:creator>Zil Norvilis</dc:creator>
      <pubDate>Fri, 15 May 2026 23:06:08 +0000</pubDate>
      <link>https://forem.com/zilton7/ditching-redis-how-to-handle-websockets-in-rails-8-with-solid-cable-1jdb</link>
      <guid>https://forem.com/zilton7/ditching-redis-how-to-handle-websockets-in-rails-8-with-solid-cable-1jdb</guid>
      <description>&lt;h1&gt;
  
  
  Real-Time Rails Without Redis: A Guide to Solid Cable
&lt;/h1&gt;

&lt;p&gt;For years, adding a single real-time feature to a Rails app felt like signing a deal with the devil. &lt;/p&gt;

&lt;p&gt;You just wanted a simple notification bell to update without a page refresh, or a basic live chat. But the moment you decided to use ActionCable, you had to invite a completely new piece of infrastructure into your stack: &lt;strong&gt;Redis&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Managing Redis meant paying for another server, monitoring its memory usage, and ensuring your production environment was wired up correctly. For a solo developer trying to keep costs and complexity low, it was a massive headache.&lt;/p&gt;

&lt;p&gt;In Rails 8, the "One-Person Framework" philosophy struck again. DHH and the Rails team decided that requiring Redis just for basic WebSockets was a mistake. &lt;/p&gt;

&lt;p&gt;Enter &lt;strong&gt;Solid Cable&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Solid Cable replaces Redis by using your existing SQL database (PostgreSQL, MySQL, or SQLite) as the Pub/Sub backend for ActionCable. Here is how it works, and how to set it up in 3 simple steps.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Mental Model: How can a Database handle WebSockets?
&lt;/h2&gt;

&lt;p&gt;When you hear "Database-backed WebSockets," your first instinct as an engineer is probably panic. &lt;em&gt;"Won't polling the database 50 times a second completely crash my Postgres server?"&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A decade ago, the answer was yes. But hardware has evolved. &lt;/p&gt;

&lt;p&gt;Today's servers run on blazing-fast NVMe SSDs. Furthermore, Solid Cable is incredibly optimized. It doesn't do dumb, heavy table scans. It keeps messages in a lightweight table and uses an efficient pruning mechanism to delete old messages immediately after they are broadcasted. &lt;/p&gt;

&lt;p&gt;Unless you are building the next WhatsApp or a high-frequency trading platform, your standard PostgreSQL database (or even SQLite in WAL mode) can handle thousands of concurrent WebSocket connections without breaking a sweat.&lt;/p&gt;

&lt;h2&gt;
  
  
  STEP 1: Installation and Database Setup
&lt;/h2&gt;

&lt;p&gt;If you generate a brand new Rails 8 app, Solid Cable is already included. But if you are upgrading an older app or adding it manually, here is the process.&lt;/p&gt;

&lt;p&gt;Add the gem to your &lt;code&gt;Gemfile&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;gem&lt;/span&gt; &lt;span class="s2"&gt;"solid_cable"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run &lt;code&gt;bundle install&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Next, we need to create the table where Solid Cable will temporarily hold the WebSocket messages. Run the installation generator:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;bin/rails solid_cable:install
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This generates a configuration file and a database migration. Apply the migration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;bin/rails db:migrate
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;Note: Just like Solid Queue, Rails 8 encourages keeping this table in a separate database file or logical database (e.g., &lt;code&gt;cable&lt;/code&gt; instead of &lt;code&gt;primary&lt;/code&gt;) to keep your main application data isolated from the heavy read/write load of WebSockets.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  STEP 2: The Configuration (&lt;code&gt;cable.yml&lt;/code&gt;)
&lt;/h2&gt;

&lt;p&gt;Now we need to tell ActionCable to stop looking for Redis and start using our database. &lt;/p&gt;

&lt;p&gt;Open your &lt;code&gt;config/cable.yml&lt;/code&gt; file. You will see different environments. Update your &lt;code&gt;production&lt;/code&gt; (and &lt;code&gt;development&lt;/code&gt; if you want to test it locally) to use the new adapter:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# config/cable.yml&lt;/span&gt;
&lt;span class="na"&gt;development&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;adapter&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;solid_cable&lt;/span&gt;
  &lt;span class="na"&gt;polling_interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;0.1.seconds&lt;/span&gt;
  &lt;span class="na"&gt;message_retention&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1.day&lt;/span&gt;

&lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;adapter&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;test&lt;/span&gt;

&lt;span class="na"&gt;production&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;adapter&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;solid_cable&lt;/span&gt;
  &lt;span class="na"&gt;polling_interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;0.1.seconds&lt;/span&gt;
  &lt;span class="na"&gt;message_retention&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1.day&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is literally it for the infrastructure. You have successfully deleted Redis from your application. &lt;/p&gt;

&lt;h2&gt;
  
  
  STEP 3: Using It (The Code Stays the Same!)
&lt;/h2&gt;

&lt;p&gt;The absolute best part about Solid Cable is that it is just a backend adapter. Your frontend and backend Ruby code &lt;strong&gt;do not change at all&lt;/strong&gt;. You still use the exact same Hotwire and Turbo Stream methods you already know.&lt;/p&gt;

&lt;p&gt;Let's say we have a &lt;code&gt;Comment&lt;/code&gt; model, and we want it to show up on the user's screen in real-time.&lt;/p&gt;

&lt;p&gt;In the View, we listen to a channel:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight erb"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- app/views/posts/show.html.erb --&amp;gt;&lt;/span&gt;
&lt;span class="cp"&gt;&amp;lt;%=&lt;/span&gt; &lt;span class="n"&gt;turbo_stream_from&lt;/span&gt; &lt;span class="vi"&gt;@post&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"comments"&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"comments_list"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="cp"&gt;&amp;lt;%=&lt;/span&gt; &lt;span class="n"&gt;render&lt;/span&gt; &lt;span class="vi"&gt;@post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;comments&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In the Model, we tell it to broadcast:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# app/models/comment.rb&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Comment&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;ApplicationRecord&lt;/span&gt;
  &lt;span class="n"&gt;belongs_to&lt;/span&gt; &lt;span class="ss"&gt;:post&lt;/span&gt;

  &lt;span class="c1"&gt;# When a comment is created, send the HTML over Solid Cable!&lt;/span&gt;
  &lt;span class="n"&gt;after_create_commit&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; 
    &lt;span class="n"&gt;broadcast_append_to&lt;/span&gt; &lt;span class="n"&gt;post&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"comments"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; 
    &lt;span class="ss"&gt;target: &lt;/span&gt;&lt;span class="s2"&gt;"comments_list"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; 
    &lt;span class="ss"&gt;partial: &lt;/span&gt;&lt;span class="s2"&gt;"comments/comment"&lt;/span&gt; 
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Behind the scenes, when &lt;code&gt;broadcast_append_to&lt;/code&gt; is called, Rails writes a fast row to the &lt;code&gt;solid_cable_messages&lt;/code&gt; database table. The Solid Cable worker instantly picks it up, pushes it through the WebSocket connection to the browser, and then cleans up the database row.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;The entire Rails 8 era is defined by one word: &lt;strong&gt;Consolidation&lt;/strong&gt;. &lt;/p&gt;

&lt;p&gt;We got rid of Webpack by using Importmaps. We got rid of Node.js for frontend building. We got rid of Redis for background jobs with Solid Queue. And now, we have gotten rid of Redis for WebSockets with Solid Cable.&lt;/p&gt;

&lt;p&gt;Your entire production stack can now consist of exactly two things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A Linux Server (Running Docker/Kamal).&lt;/li&gt;
&lt;li&gt;A Database.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;By eliminating complex infrastructure, you free up your mental bandwidth to do the only thing that actually matters: building a product that people want to pay for.&lt;/p&gt;

</description>
      <category>rails</category>
      <category>webdev</category>
      <category>hotwire</category>
      <category>architecture</category>
    </item>
    <item>
      <title>The Magic of Turbo Frames: Infinite Pagination in Pure HTML</title>
      <dc:creator>Zil Norvilis</dc:creator>
      <pubDate>Fri, 15 May 2026 16:47:35 +0000</pubDate>
      <link>https://forem.com/zilton7/the-magic-of-turbo-frames-infinite-pagination-in-pure-html-1jo3</link>
      <guid>https://forem.com/zilton7/the-magic-of-turbo-frames-infinite-pagination-in-pure-html-1jo3</guid>
      <description>&lt;h1&gt;
  
  
  Building an Infinite Scroll in Rails 8 (Zero Custom JavaScript)
&lt;/h1&gt;

&lt;p&gt;Every modern web application eventually needs a feed. Whether it's a list of articles, a timeline of comments, or a gallery of products, users expect an "Infinite Scroll" experience. They scroll to the bottom, and the next batch of items magically appears.&lt;/p&gt;

&lt;p&gt;In the past, implementing this was a nightmare for backend developers. You had to:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Write JavaScript to listen to the window scroll event (and debounce it so it didn't crash the browser).&lt;/li&gt;
&lt;li&gt;Calculate if the user was 100 pixels away from the bottom of the screen.&lt;/li&gt;
&lt;li&gt;Fire an AJAX request.&lt;/li&gt;
&lt;li&gt;Parse a JSON response.&lt;/li&gt;
&lt;li&gt;Append new DOM elements to the page.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;It was messy and prone to bugs. &lt;/p&gt;

&lt;p&gt;If you are using Rails 8, you can throw all of that JavaScript away. By combining standard Rails pagination with a powerful feature of Hotwire called &lt;strong&gt;Lazy-Loaded Turbo Frames&lt;/strong&gt;, we can build a perfect infinite scroll in about 5 minutes, writing absolutely zero custom JS.&lt;/p&gt;

&lt;p&gt;Here is exactly how to do it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Strategy
&lt;/h2&gt;

&lt;p&gt;The concept is brilliantly simple. We are going to put our list of items inside a &lt;code&gt;&amp;lt;turbo-frame&amp;gt;&lt;/code&gt;. At the very bottom of that list, we will put &lt;em&gt;another&lt;/em&gt; turbo frame. &lt;/p&gt;

&lt;p&gt;This second frame will have an attribute called &lt;code&gt;loading="lazy"&lt;/code&gt;, and its &lt;code&gt;src&lt;/code&gt; will point to the URL of "Page 2". &lt;/p&gt;

&lt;p&gt;When the user scrolls down, and that empty frame enters the browser viewport, Turbo automatically fetches the URL and replaces the empty frame with the contents of Page 2 (which includes the next batch of items, plus a new lazy frame pointing to Page 3).&lt;/p&gt;

&lt;h2&gt;
  
  
  STEP 1: The Controller
&lt;/h2&gt;

&lt;p&gt;First, we need standard pagination in our controller. You can use the &lt;code&gt;pagy&lt;/code&gt; gem or the built-in Rails &lt;code&gt;limit/offset&lt;/code&gt;. For this example, let's use the popular &lt;code&gt;kaminari&lt;/code&gt; gem because it is very explicit.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# app/controllers/posts_controller.rb&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;PostsController&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;ApplicationController&lt;/span&gt;
  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;index&lt;/span&gt;
    &lt;span class="c1"&gt;# Load 10 posts at a time based on the ?page= parameter&lt;/span&gt;
    &lt;span class="vi"&gt;@posts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;order&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;created_at: :desc&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;page&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:page&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="nf"&gt;per&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  STEP 2: The View (The Magic Frame)
&lt;/h2&gt;

&lt;p&gt;Now, open your index view. This is where the magic happens. &lt;/p&gt;

&lt;p&gt;We need to wrap our content in a &lt;code&gt;&amp;lt;turbo-frame&amp;gt;&lt;/code&gt; that has a dynamic ID based on the current page number. If we are on Page 1, the ID is &lt;code&gt;posts_page_1&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight erb"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- app/views/posts/index.html.erb --&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;h1&amp;gt;&lt;/span&gt;The Infinite Feed&lt;span class="nt"&gt;&amp;lt;/h1&amp;gt;&lt;/span&gt;

&lt;span class="c"&gt;&amp;lt;!-- The main feed container --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"posts_feed"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;

  &lt;span class="c"&gt;&amp;lt;!-- The dynamic Turbo Frame for the current page --&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;turbo-frame&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"posts_page_&lt;/span&gt;&lt;span class="cp"&gt;&amp;lt;%=&lt;/span&gt; &lt;span class="vi"&gt;@posts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;current_page&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;

    &lt;span class="c"&gt;&amp;lt;!-- Render the actual posts --&amp;gt;&lt;/span&gt;
    &lt;span class="cp"&gt;&amp;lt;%&lt;/span&gt; &lt;span class="vi"&gt;@posts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;each&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;post&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"p-4 border-b"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;h3&amp;gt;&lt;/span&gt;&lt;span class="cp"&gt;&amp;lt;%=&lt;/span&gt; &lt;span class="n"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;title&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;/h3&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
    &lt;span class="cp"&gt;&amp;lt;%&lt;/span&gt; &lt;span class="k"&gt;end&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;

    &lt;span class="c"&gt;&amp;lt;!-- The Lazy-Loading Trigger --&amp;gt;&lt;/span&gt;
    &lt;span class="cp"&gt;&amp;lt;%&lt;/span&gt; &lt;span class="k"&gt;unless&lt;/span&gt; &lt;span class="vi"&gt;@posts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;last_page?&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;
      &lt;span class="c"&gt;&amp;lt;!-- This frame points to the NEXT page and loads lazily --&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;turbo-frame&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"posts_page_&lt;/span&gt;&lt;span class="cp"&gt;&amp;lt;%=&lt;/span&gt; &lt;span class="vi"&gt;@posts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;next_page&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt; 
                   &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="cp"&gt;&amp;lt;%=&lt;/span&gt; &lt;span class="n"&gt;posts_path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;page: &lt;/span&gt;&lt;span class="vi"&gt;@posts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;next_page&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt; 
                   &lt;span class="na"&gt;loading=&lt;/span&gt;&lt;span class="s"&gt;"lazy"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;

        &lt;span class="c"&gt;&amp;lt;!-- A simple loading spinner to show while fetching --&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;p&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"text-center text-gray-500 py-4 animate-pulse"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;Loading more...&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;

      &lt;span class="nt"&gt;&amp;lt;/turbo-frame&amp;gt;&lt;/span&gt;
    &lt;span class="cp"&gt;&amp;lt;%&lt;/span&gt; &lt;span class="k"&gt;end&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;

  &lt;span class="nt"&gt;&amp;lt;/turbo-frame&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  How It Actually Works (Step-by-Step)
&lt;/h2&gt;

&lt;p&gt;When a user visits &lt;code&gt;/posts&lt;/code&gt;, here is exactly what happens:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt; Rails renders Page 1. The ID of the outer frame is &lt;code&gt;posts_page_1&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt; At the bottom of the 10 items, there is an empty frame with &lt;code&gt;id="posts_page_2"&lt;/code&gt; and &lt;code&gt;loading="lazy"&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt; The user scrolls down. As soon as that empty frame becomes visible on the screen, Turbo sees the &lt;code&gt;loading="lazy"&lt;/code&gt; attribute and fires an HTTP request to &lt;code&gt;src="/posts?page=2"&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt; Rails receives the request and renders &lt;code&gt;index.html.erb&lt;/code&gt; for Page 2.&lt;/li&gt;
&lt;li&gt; Turbo receives the HTML response for Page 2. It looks for the frame named &lt;code&gt;posts_page_2&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt; It extracts the contents of &lt;code&gt;posts_page_2&lt;/code&gt; (which contains posts 11-20, plus a new lazy frame for &lt;code&gt;posts_page_3&lt;/code&gt;) and swaps it seamlessly into the original page.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The user experiences a buttery smooth infinite scroll, and you didn't have to write a single line of JavaScript.&lt;/p&gt;

&lt;h2&gt;
  
  
  STEP 3: Optimization (The Pro Move)
&lt;/h2&gt;

&lt;p&gt;The code above works perfectly. However, there is one small performance optimization we should make.&lt;/p&gt;

&lt;p&gt;When Turbo requests Page 2, Rails is rendering the entire &lt;code&gt;index.html.erb&lt;/code&gt; layout (including your navbar, footer, and sidebars). Turbo is smart enough to throw away the navbar and only use the specific &lt;code&gt;&amp;lt;turbo-frame&amp;gt;&lt;/code&gt;, but rendering that extra HTML on the server wastes CPU cycles.&lt;/p&gt;

&lt;p&gt;We can tell our Rails controller to skip the application layout if the request is coming from a Turbo Frame.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# app/controllers/posts_controller.rb&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;PostsController&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;ApplicationController&lt;/span&gt;
  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;index&lt;/span&gt;
    &lt;span class="vi"&gt;@posts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;order&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;created_at: :desc&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;page&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:page&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="nf"&gt;per&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# If Turbo is asking for this page (e.g. infinite scroll), &lt;/span&gt;
    &lt;span class="c1"&gt;# don't render the heavy application layout.&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;turbo_frame_request?&lt;/span&gt;
      &lt;span class="n"&gt;render&lt;/span&gt; &lt;span class="ss"&gt;partial: &lt;/span&gt;&lt;span class="s2"&gt;"posts/page"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;locals: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="ss"&gt;posts: &lt;/span&gt;&lt;span class="vi"&gt;@posts&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;(You would then extract the turbo-frame logic from Step 2 into a &lt;code&gt;_page.html.erb&lt;/code&gt; partial).&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;This is the beauty of the modern Rails 8 stack. By understanding how HTML over the wire works, you can delete entire categories of frontend complexity. &lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  You don't need a Javascript Intersection Observer.&lt;/li&gt;
&lt;li&gt;  You don't need to parse JSON.&lt;/li&gt;
&lt;li&gt;  You just use a &lt;code&gt;&amp;lt;turbo-frame&amp;gt;&lt;/code&gt; with &lt;code&gt;loading="lazy"&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;As a solo developer, finding these "low-code" solutions is the only way to ship features fast enough to compete. Keep it simple, and let the browser do the heavy lifting.&lt;/p&gt;

</description>
      <category>rails</category>
      <category>hotwire</category>
      <category>tutorial</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Why I Stopped Using Stripe: The Case for Merchant of Records (MoR)</title>
      <dc:creator>Zil Norvilis</dc:creator>
      <pubDate>Wed, 13 May 2026 23:23:37 +0000</pubDate>
      <link>https://forem.com/zilton7/why-i-stopped-using-stripe-the-case-for-merchant-of-records-mor-3o2g</link>
      <guid>https://forem.com/zilton7/why-i-stopped-using-stripe-the-case-for-merchant-of-records-mor-3o2g</guid>
      <description>&lt;p&gt;If you are building a SaaS in 2026, the default advice is always the same: &lt;em&gt;"Just plug in Stripe and launch."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Stripe is an incredible piece of engineering. Their APIs are flawless, their documentation is the gold standard of the internet, and integrating Stripe Checkout into a Rails app takes about ten minutes. &lt;/p&gt;

&lt;p&gt;For a long time, I used Stripe for everything. But as my side projects started actually making money, a dark, terrifying reality crept in: &lt;strong&gt;Global Tax Compliance.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;As a solo developer based in Europe, I realized that taking payments globally isn't just about moving money from Point A to Point B. It’s a legal minefield. This is why I entirely stopped using Stripe for my B2C (Business to Consumer) SaaS projects and switched to using a &lt;strong&gt;Merchant of Record (MoR)&lt;/strong&gt; like Paddle or Lemon Squeezy.&lt;/p&gt;

&lt;p&gt;Here is the exact difference, and why it matters to your sanity.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem: The "Tax Nexus" Nightmare
&lt;/h2&gt;

&lt;p&gt;When you use Stripe, Stripe is simply a &lt;strong&gt;Payment Processor&lt;/strong&gt;. &lt;/p&gt;

&lt;p&gt;They take the credit card, they verify the funds, and they put the money in your bank account. That’s it. &lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Legally, YOU are the one selling the software to the customer.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Why is this a problem? Let's say you sell a $10/month subscription.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  A user in Germany buys it. You owe 19% VAT to Germany.&lt;/li&gt;
&lt;li&gt;  A user in the UK buys it. You owe 20% VAT to the UK.&lt;/li&gt;
&lt;li&gt;  A user in Texas buys it. You owe Texas state sales tax.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Suddenly, because you sold software globally, you are legally required to register for taxes in multiple different countries, calculate the exact rates based on the buyer's IP address, collect the tax, and remit it to foreign governments quarterly. &lt;/p&gt;

&lt;p&gt;If you are a One-Person Team, trying to figure out the tax laws of 50 different countries will consume your entire life. You will spend more time doing accounting than writing Ruby code. (Yes, Stripe Tax exists, but it only &lt;em&gt;calculates&lt;/em&gt; the tax; you still have to file the paperwork yourself).&lt;/p&gt;

&lt;h2&gt;
  
  
  The Solution: The Merchant of Record (MoR)
&lt;/h2&gt;

&lt;p&gt;A Merchant of Record (like &lt;strong&gt;Paddle&lt;/strong&gt; or &lt;strong&gt;Lemon Squeezy&lt;/strong&gt;) works entirely differently.&lt;/p&gt;

&lt;p&gt;When you use an MoR, the legal flow changes. &lt;br&gt;
&lt;strong&gt;You sell your software to the MoR. The MoR sells the software to the customer.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Because Paddle is the one officially selling the product to the user in Germany, Paddle is legally responsible for calculating, collecting, and remitting the 19% VAT to the German government. &lt;/p&gt;

&lt;p&gt;For you, the developer, the nightmare is over. At the end of the month, Paddle sends you one single payout. As far as your local tax authority is concerned, you only made one B2B sale that month: you sold your services to Paddle (a UK company). &lt;/p&gt;
&lt;h2&gt;
  
  
  The Trade-Offs
&lt;/h2&gt;

&lt;p&gt;Switching to an MoR feels like a magic bullet, but it does come with trade-offs you need to understand.&lt;/p&gt;
&lt;h3&gt;
  
  
  1. The Fees are Higher
&lt;/h3&gt;

&lt;p&gt;Because an MoR handles international taxes, handles chargeback disputes, and assumes legal liability, they charge more.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;  &lt;strong&gt;Stripe:&lt;/strong&gt; ~2.9% + 30¢ per transaction.&lt;/li&gt;
&lt;li&gt;  &lt;strong&gt;Paddle / Lemon Squeezy:&lt;/strong&gt; ~5% + 50¢ per transaction.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When you are starting out, giving up 5% feels painful. But ask yourself: how much is an international tax accountant going to cost you? Usually, the 2% difference is much cheaper than hiring a professional to file VAT returns in the EU.&lt;/p&gt;
&lt;h3&gt;
  
  
  2. The Integration is Slightly Different
&lt;/h3&gt;

&lt;p&gt;Integrating an MoR into Rails is conceptually similar to Stripe, but slightly less "native". You don't use a massive Ruby gem. &lt;/p&gt;

&lt;p&gt;Usually, you drop their Javascript snippet onto your pricing page to trigger a checkout overlay. Then, just like Stripe, you set up a webhook endpoint in your Rails app.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# app/controllers/webhooks/paddle_controller.rb&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Webhooks::PaddleController&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;ApplicationController&lt;/span&gt;
  &lt;span class="n"&gt;skip_before_action&lt;/span&gt; &lt;span class="ss"&gt;:verify_authenticity_token&lt;/span&gt;

  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;create&lt;/span&gt;
    &lt;span class="c1"&gt;# Paddle sends a signature we must verify&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;valid_paddle_signature?&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="n"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:alert_name&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

      &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;
      &lt;span class="k"&gt;when&lt;/span&gt; &lt;span class="s1"&gt;'subscription_payment_succeeded'&lt;/span&gt;
        &lt;span class="c1"&gt;# Upgrade the user!&lt;/span&gt;
        &lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;User&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find_by&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;email: &lt;/span&gt;&lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:email&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
        &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;pro: &lt;/span&gt;&lt;span class="kp"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="k"&gt;end&lt;/span&gt;

      &lt;span class="n"&gt;head&lt;/span&gt; &lt;span class="ss"&gt;:ok&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt;
      &lt;span class="n"&gt;head&lt;/span&gt; &lt;span class="ss"&gt;:unauthorized&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It is very straightforward, but you will be relying more on raw webhook handling rather than a polished library.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Less Control Over the Checkout
&lt;/h3&gt;

&lt;p&gt;With Stripe Elements, you can design a checkout flow that perfectly matches your app's brand. With an MoR, you are generally forced to use their hosted checkout pages or standard popups. &lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;If you are a US-based developer selling exclusively to other US businesses (B2B), Stripe is still the undisputed king.&lt;/p&gt;

&lt;p&gt;But if you are a solo developer (especially in Europe) selling B2C products globally, the math changes. Your goal is to write code and build a great product, not to become an expert in the European Union's digital VAT laws.&lt;/p&gt;

&lt;p&gt;Giving up an extra 2% of your revenue to a Merchant of Record like Paddle or Lemon Squeezy is the best "DevOps" investment you can make. It buys you total peace of mind.&lt;/p&gt;

</description>
      <category>saas</category>
      <category>startup</category>
      <category>payments</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Stop Paying for Vector Databases: How to Build AI Search in Postgres</title>
      <dc:creator>Zil Norvilis</dc:creator>
      <pubDate>Wed, 13 May 2026 23:07:00 +0000</pubDate>
      <link>https://forem.com/zilton7/stop-paying-for-vector-databases-how-to-build-ai-search-in-postgres-51pl</link>
      <guid>https://forem.com/zilton7/stop-paying-for-vector-databases-how-to-build-ai-search-in-postgres-51pl</guid>
      <description>&lt;p&gt;I see developers trying to build "AI Chatbots" that know about their specific company data. They want the AI to read their PDFs, their internal wikis, or their past customer support tickets, and answer questions based on that data. &lt;/p&gt;

&lt;p&gt;This technique is called &lt;strong&gt;RAG&lt;/strong&gt; (Retrieval-Augmented Generation).&lt;/p&gt;

&lt;p&gt;When the AI hype first started, developers thought they had to pay for expensive, dedicated "Vector Databases" like Pinecone or Milvus to do this. They added a massive layer of complexity to their stack just to store some AI data.&lt;/p&gt;

&lt;p&gt;In 2026, the Rails way to do this is much simpler. &lt;strong&gt;You just use PostgreSQL.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;By using the &lt;code&gt;pgvector&lt;/code&gt; extension and a brilliant Ruby gem called &lt;code&gt;neighbor&lt;/code&gt;, you can keep all your AI data perfectly synced inside your standard Rails database. You get the power of RAG without leaving the comfort of ActiveRecord.&lt;/p&gt;

&lt;p&gt;Here is exactly how to build "Chat with your Database" in 4 steps.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Mental Model: What are Embeddings?
&lt;/h2&gt;

&lt;p&gt;Before we code, you need to understand how AI "searches" text. &lt;/p&gt;

&lt;p&gt;AI does not read words; it reads math. When you send a paragraph of text to an AI (like OpenAI's embedding model), it returns an &lt;strong&gt;Embedding&lt;/strong&gt; - a massive array of 1,536 numbers. &lt;/p&gt;

&lt;p&gt;Think of this array as a set of coordinates on a map. Paragraphs that talk about similar things are placed closer together on this map. To search for an answer, we turn the user's question into coordinates, and ask the database: &lt;em&gt;"Which paragraphs are physically closest to this question on the map?"&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  STEP 1: The Database Setup
&lt;/h2&gt;

&lt;p&gt;First, we need to tell PostgreSQL that it is allowed to store these massive arrays of numbers. We do this by enabling the &lt;code&gt;vector&lt;/code&gt; extension.&lt;/p&gt;

&lt;p&gt;Add the gems to your &lt;code&gt;Gemfile&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="n"&gt;gem&lt;/span&gt; &lt;span class="s1"&gt;'ruby-openai'&lt;/span&gt; &lt;span class="c1"&gt;# To talk to ChatGPT&lt;/span&gt;
&lt;span class="n"&gt;gem&lt;/span&gt; &lt;span class="s1"&gt;'neighbor'&lt;/span&gt;    &lt;span class="c1"&gt;# To add vector search to ActiveRecord&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run &lt;code&gt;bundle install&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Next, generate a migration to enable the extension and add a vector column to the table we want to search (let's use a &lt;code&gt;Document&lt;/code&gt; model).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;rails g migration AddEmbeddingsToDocuments
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# db/migrate/20260506120000_add_embeddings_to_documents.rb&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AddEmbeddingsToDocuments&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;ActiveRecord&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Migration&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mf"&gt;8.0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;change&lt;/span&gt;
    &lt;span class="c1"&gt;# 1. Enable the Postgres extension&lt;/span&gt;
    &lt;span class="n"&gt;enable_extension&lt;/span&gt; &lt;span class="s2"&gt;"vector"&lt;/span&gt;

    &lt;span class="c1"&gt;# 2. Add the column. OpenAI's standard models output 1536 dimensions.&lt;/span&gt;
    &lt;span class="n"&gt;add_column&lt;/span&gt; &lt;span class="ss"&gt;:documents&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:embedding&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:vector&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;limit: &lt;/span&gt;&lt;span class="mi"&gt;1536&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run &lt;code&gt;rails db:migrate&lt;/code&gt;. &lt;/p&gt;

&lt;p&gt;Now, open your model and tell the &lt;code&gt;neighbor&lt;/code&gt; gem to track that column:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# app/models/document.rb&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Document&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;ApplicationRecord&lt;/span&gt;
  &lt;span class="n"&gt;has_neighbors&lt;/span&gt; &lt;span class="ss"&gt;:embedding&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  STEP 2: Generating the Embeddings
&lt;/h2&gt;

&lt;p&gt;When a user creates a new Document in your app, we need to turn its text into an embedding and save it to the database. &lt;em&gt;(Note: Because API calls are slow, you should do this in a Solid Queue background job!)&lt;/em&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# app/services/embedding_service.rb&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;EmbeddingService&lt;/span&gt;
  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nc"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;generate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;document&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;OpenAI&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;access_token: &lt;/span&gt;&lt;span class="no"&gt;ENV&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'OPENAI_API_KEY'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

    &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;embeddings&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="ss"&gt;parameters: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="ss"&gt;model: &lt;/span&gt;&lt;span class="s2"&gt;"text-embedding-3-small"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="ss"&gt;input: &lt;/span&gt;&lt;span class="n"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;content&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# Extract the array of 1536 floats&lt;/span&gt;
    &lt;span class="n"&gt;vector&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"data"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"embedding"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# Save it directly to our Postgres column&lt;/span&gt;
    &lt;span class="n"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;embedding: &lt;/span&gt;&lt;span class="n"&gt;vector&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  STEP 3: The Vector Search (Finding the Context)
&lt;/h2&gt;

&lt;p&gt;Now for the magic. A user asks a question: &lt;em&gt;"What is our company's refund policy?"&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;First, we must turn their question into a vector using the exact same OpenAI model. Then, we use the &lt;code&gt;neighbor&lt;/code&gt; gem's &lt;code&gt;.nearest_neighbors&lt;/code&gt; method to search Postgres.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# app/services/rag_search_service.rb&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;RagSearchService&lt;/span&gt;
  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nc"&gt;self&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;question&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;OpenAI&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;access_token: &lt;/span&gt;&lt;span class="no"&gt;ENV&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'OPENAI_API_KEY'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

    &lt;span class="c1"&gt;# 1. Turn the question into coordinates&lt;/span&gt;
    &lt;span class="n"&gt;question_vector&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;embeddings&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="ss"&gt;parameters: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="ss"&gt;model: &lt;/span&gt;&lt;span class="s2"&gt;"text-embedding-3-small"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;input: &lt;/span&gt;&lt;span class="n"&gt;question&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;dig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"data"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"embedding"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# 2. Ask Postgres to find the 3 closest documents&lt;/span&gt;
    &lt;span class="c1"&gt;# "inner_product" is the fastest distance metric for OpenAI embeddings&lt;/span&gt;
    &lt;span class="n"&gt;relevant_docs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;nearest_neighbors&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:embedding&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;question_vector&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;distance: &lt;/span&gt;&lt;span class="s2"&gt;"inner_product"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;relevant_docs&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because of the &lt;code&gt;neighbor&lt;/code&gt; gem, searching vectors feels exactly like a standard ActiveRecord query!&lt;/p&gt;

&lt;h2&gt;
  
  
  STEP 4: The RAG Prompt
&lt;/h2&gt;

&lt;p&gt;We have the user's question, and we have the 3 documents that contain the answer. Now, we just smash them together into one giant prompt and send it to ChatGPT to generate a human-sounding response.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# app/controllers/chats_controller.rb&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ChatsController&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;ApplicationController&lt;/span&gt;
  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;create&lt;/span&gt;
    &lt;span class="n"&gt;user_question&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:question&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

    &lt;span class="c1"&gt;# 1. Get the relevant data from Postgres&lt;/span&gt;
    &lt;span class="n"&gt;docs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;RagSearchService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_question&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# 2. Build the context string&lt;/span&gt;
    &lt;span class="n"&gt;context&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;docs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="ss"&gt;:content&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="s2"&gt;---&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# 3. Build the RAG Prompt&lt;/span&gt;
    &lt;span class="n"&gt;system_prompt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;~&lt;/span&gt;&lt;span class="no"&gt;PROMPT&lt;/span&gt;&lt;span class="sh"&gt;
      You are a helpful company assistant. Answer the user's question 
      using ONLY the context provided below. If the answer is not in 
      the context, say "I don't know."

      CONTEXT:
      &lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;
&lt;/span&gt;&lt;span class="no"&gt;    PROMPT&lt;/span&gt;

    &lt;span class="c1"&gt;# 4. Ask the AI&lt;/span&gt;
    &lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;OpenAI&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;access_token: &lt;/span&gt;&lt;span class="no"&gt;ENV&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'OPENAI_API_KEY'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="ss"&gt;parameters: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="ss"&gt;model: &lt;/span&gt;&lt;span class="s2"&gt;"gpt-4o"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;:[&lt;/span&gt;
          &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="ss"&gt;role: &lt;/span&gt;&lt;span class="s2"&gt;"system"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;content: &lt;/span&gt;&lt;span class="n"&gt;system_prompt&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
          &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="ss"&gt;role: &lt;/span&gt;&lt;span class="s2"&gt;"user"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;content: &lt;/span&gt;&lt;span class="n"&gt;user_question&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;]&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="vi"&gt;@answer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"choices"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"content"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# Render your Hotwire view here...&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;The entire multi-billion dollar "RAG" industry boils down to this incredibly simple pipeline:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Text -&amp;gt; OpenAI -&amp;gt; Numbers (Saved in Postgres).&lt;/li&gt;
&lt;li&gt;Question -&amp;gt; OpenAI -&amp;gt; Numbers.&lt;/li&gt;
&lt;li&gt;Find closest Numbers in Postgres using &lt;code&gt;neighbor&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Send Question + Found Text -&amp;gt; OpenAI -&amp;gt; Final Answer.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;By leveraging &lt;code&gt;pgvector&lt;/code&gt; and ActiveRecord, we avoid adding a completely new piece of infrastructure to our stack. Your AI data lives right next to your user data, it is backed up together, and it is queried using the same Ruby syntax you already know and love.&lt;/p&gt;

&lt;p&gt;The "One Person Framework" strikes again.&lt;/p&gt;

</description>
      <category>rails</category>
      <category>ai</category>
      <category>database</category>
      <category>postgres</category>
    </item>
    <item>
      <title>Background AI: Using Solid Queue for Slow OpenAI API Calls</title>
      <dc:creator>Zil Norvilis</dc:creator>
      <pubDate>Tue, 12 May 2026 23:07:01 +0000</pubDate>
      <link>https://forem.com/zilton7/background-ai-using-solid-queue-for-slow-openai-api-calls-31de</link>
      <guid>https://forem.com/zilton7/background-ai-using-solid-queue-for-slow-openai-api-calls-31de</guid>
      <description>&lt;p&gt;Very often I see developers integrating AI into their Rails apps for the first time, and they make a critical mistake that completely destroys their server performance.&lt;/p&gt;

&lt;p&gt;They treat the OpenAI (or Anthropic) API like a regular database query. They put the API call directly inside their controller.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# The "Server Killer" Approach&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;create&lt;/span&gt;
  &lt;span class="vi"&gt;@document&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:id&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

  &lt;span class="c1"&gt;# This might take 15 seconds!&lt;/span&gt;
  &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;OpenAiClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;generate_summary&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="vi"&gt;@document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="vi"&gt;@document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;summary: &lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;redirect_to&lt;/span&gt; &lt;span class="vi"&gt;@document&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When you do this, the Puma web thread handling that user's request &lt;strong&gt;freezes&lt;/strong&gt;. It sits there doing absolutely nothing for 15 seconds while it waits for the AI to respond. If you have 5 users asking for summaries at the same time, your entire server will lock up. No one else will be able to load your website. The browser might even time out and show an error page.&lt;/p&gt;

&lt;p&gt;AI calls are slow. You &lt;strong&gt;must&lt;/strong&gt; put them in the background.&lt;/p&gt;

&lt;p&gt;In 2026, Rails 8 makes this ridiculously easy because we have &lt;strong&gt;Solid Queue&lt;/strong&gt; built-in. We don't need to install Redis. We just use our existing PostgreSQL database. Here is how to move your AI calls to the background and use Hotwire to update the user's screen in real-time.&lt;/p&gt;

&lt;h2&gt;
  
  
  STEP 1: The Empty State View
&lt;/h2&gt;

&lt;p&gt;When a user clicks "Generate Summary", we want the page to load instantly. We will show them a loading spinner while the AI thinks in the background.&lt;/p&gt;

&lt;p&gt;To do this, we need to set up a Hotwire listener (&lt;code&gt;turbo_stream_from&lt;/code&gt;) on our document page.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight erb"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- app/views/documents/show.html.erb --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;h1&amp;gt;&lt;/span&gt;&lt;span class="cp"&gt;&amp;lt;%=&lt;/span&gt; &lt;span class="vi"&gt;@document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;title&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;/h1&amp;gt;&lt;/span&gt;

&lt;span class="c"&gt;&amp;lt;!-- 1. Listen for WebSocket broadcasts attached to this specific document --&amp;gt;&lt;/span&gt;
&lt;span class="cp"&gt;&amp;lt;%=&lt;/span&gt; &lt;span class="n"&gt;turbo_stream_from&lt;/span&gt; &lt;span class="vi"&gt;@document&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;

&lt;span class="c"&gt;&amp;lt;!-- 2. The target div that we will update later --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="cp"&gt;&amp;lt;%=&lt;/span&gt; &lt;span class="n"&gt;dom_id&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="vi"&gt;@document&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:summary&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;

  &lt;span class="cp"&gt;&amp;lt;%&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="vi"&gt;@document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;present?&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;p&amp;gt;&lt;/span&gt;&lt;span class="cp"&gt;&amp;lt;%=&lt;/span&gt; &lt;span class="vi"&gt;@document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;summary&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
  &lt;span class="cp"&gt;&amp;lt;%&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;p&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"text-gray-500 animate-pulse"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;🤖 AI is generating your summary...&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
  &lt;span class="cp"&gt;&amp;lt;%&lt;/span&gt; &lt;span class="k"&gt;end&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  STEP 2: The Fast Controller
&lt;/h2&gt;

&lt;p&gt;Now, we update our controller. Instead of calling the AI, we just tell our background queue to handle it, and we immediately render the page.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# app/controllers/summaries_controller.rb&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;SummariesController&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;ApplicationController&lt;/span&gt;
  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;create&lt;/span&gt;
    &lt;span class="vi"&gt;@document&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:document_id&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

    &lt;span class="c1"&gt;# Send the heavy lifting to Solid Queue!&lt;/span&gt;
    &lt;span class="no"&gt;GenerateSummaryJob&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;perform_later&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="vi"&gt;@document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# Instantly redirect back to the show page&lt;/span&gt;
    &lt;span class="n"&gt;redirect_to&lt;/span&gt; &lt;span class="vi"&gt;@document&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;notice: &lt;/span&gt;&lt;span class="s2"&gt;"Summary is generating..."&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your controller now executes in &lt;code&gt;0.02&lt;/code&gt; seconds instead of 15 seconds. Your server is happy.&lt;/p&gt;

&lt;h2&gt;
  
  
  STEP 3: The Solid Queue Job
&lt;/h2&gt;

&lt;p&gt;Now we create the actual job that will run in the background.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;rails generate job generate_summary
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Inside this job, we make the slow API call, save the result to the database, and then broadcast the new HTML over WebSockets so the user's screen updates without them refreshing the page.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# app/jobs/generate_summary_job.rb&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;GenerateSummaryJob&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;ApplicationJob&lt;/span&gt;
  &lt;span class="n"&gt;queue_as&lt;/span&gt; &lt;span class="ss"&gt;:default&lt;/span&gt;

  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;perform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;document_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;document&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;document_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# 1. The Slow API Call (Takes 10-15 seconds)&lt;/span&gt;
    &lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;OpenAI&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;access_token: &lt;/span&gt;&lt;span class="no"&gt;ENV&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'OPENAI_ACCESS_TOKEN'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="ss"&gt;parameters: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="ss"&gt;model: &lt;/span&gt;&lt;span class="s2"&gt;"gpt-4o"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;:[{&lt;/span&gt; &lt;span class="ss"&gt;role: &lt;/span&gt;&lt;span class="s2"&gt;"user"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;content: &lt;/span&gt;&lt;span class="s2"&gt;"Summarize this: &lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}]&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;summary_text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"choices"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"content"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# 2. Save to database&lt;/span&gt;
    &lt;span class="n"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;summary: &lt;/span&gt;&lt;span class="n"&gt;summary_text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# 3. The Hotwire Magic: Broadcast the new HTML to the user!&lt;/span&gt;
    &lt;span class="no"&gt;Turbo&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;StreamsChannel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;broadcast_replace_to&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="n"&gt;document&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;# Matches the turbo_stream_from in our view&lt;/span&gt;
      &lt;span class="ss"&gt;target: &lt;/span&gt;&lt;span class="s2"&gt;"document_&lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;_summary"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;# The ID of the div to replace&lt;/span&gt;
      &lt;span class="ss"&gt;partial: &lt;/span&gt;&lt;span class="s2"&gt;"documents/summary"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;# A partial containing the final text&lt;/span&gt;
      &lt;span class="ss"&gt;locals: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="ss"&gt;document: &lt;/span&gt;&lt;span class="n"&gt;document&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  STEP 4: The Broadcast Partial
&lt;/h2&gt;

&lt;p&gt;In the job above, we told Hotwire to render a partial called &lt;code&gt;documents/summary&lt;/code&gt;. Let's create that tiny file so Hotwire knows what HTML to send over the WebSocket.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight erb"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- app/views/documents/_summary.html.erb --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="cp"&gt;&amp;lt;%=&lt;/span&gt; &lt;span class="n"&gt;dom_id&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;document&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:summary&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"p-4 bg-green-50 border border-green-200 rounded-lg"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;h3&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"font-bold text-green-800"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;AI Summary Complete:&lt;span class="nt"&gt;&amp;lt;/h3&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;p&amp;gt;&lt;/span&gt;&lt;span class="cp"&gt;&amp;lt;%=&lt;/span&gt; &lt;span class="n"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;summary&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;This is the ultimate workflow for the modern AI application. Look at what we achieved without writing a single line of custom JavaScript:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;User Experience:&lt;/strong&gt; The user clicks a button and gets instant feedback (the loading state). They don't stare at a frozen browser.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Server Health:&lt;/strong&gt; The Puma web server is free to handle hundreds of other users because the 15-second AI wait time is offloaded to a background worker.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Simplicity:&lt;/strong&gt; Because of Rails 8 and Solid Queue, we don't have to manage Redis servers or complex infrastructure. The jobs live right in our standard database.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Real-Time UI:&lt;/strong&gt; Hotwire securely pushes the finished HTML directly into the user's browser the exact millisecond the AI finishes thinking.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you are building AI wrappers, this exact pattern is your blueprint for success.&lt;/p&gt;

</description>
      <category>rails</category>
      <category>ai</category>
      <category>backgroundjobs</category>
      <category>hotwire</category>
    </item>
  </channel>
</rss>
