Rails Encrypted Credentials: The Git Diff Feature You May Have Been Using Without Knowing

If you have been developing Rails applications for years, there’s a good chance you’ve used:

bin/rails credentials:edit

hundreds of times.

You probably know that Rails stores encrypted credentials in:

config/credentials.yml.enc

and keeps the encryption key separately in:

config/master.key

But did you know that Rails can make:

git diff

show the decrypted, human-readable changes to credentials.yml.enc?

I recently discovered this while working on a Rails 8.1.3.1 application and it was one of those:

“I’ve been using Rails every day for years, and I didn’t know Rails could do this!”

moments.

Let’s see how it works.


First: What is credentials.yml.enc?

Rails encrypted credentials allow us to keep secrets such as:

openai:
  api_key: ...

or:

aws:
  access_key_id: ...
  secret_access_key: ...

inside:

config/credentials.yml.enc

The file is encrypted.

The encryption key is stored separately in:

config/master.key

Rails documentation explicitly states that the encrypted credentials file can be stored in version control as long as the master key remains secure. (Ruby on Rails Guides)

So our repository can contain:

config/
โ”œโ”€โ”€ credentials.yml.enc โ† encrypted, safe to commit
โ””โ”€โ”€ master.key โ† secret, NEVER commit

Editing Rails Credentials

Normally we edit credentials with:

bin/rails credentials:edit

Rails decrypts the credentials, opens them in your configured editor, and encrypts them again when you save.

Conceptually:

credentials.yml.enc
        โ”‚
        โ”‚ decrypt
        โ–ผ
   Plain YAML
        โ”‚
        โ”‚ edit
        โ–ผ
   Plain YAML
        โ”‚
        โ”‚ encrypt
        โ–ผ
credentials.yml.enc

The plaintext credentials aren’t saved as a normal file.

But What Happens With git diff?

Here’s the interesting part.

If Git simply compared the encrypted files, we’d get something useless:

- 3d9Jx8...random-encrypted-data...
+ 7kP2mL...different-encrypted-data...

We wouldn’t know:

  • Which credential changed?
  • Was a key added?
  • Was a key removed?
  • Did the API key change?
  • Did somebody accidentally modify something?

This is where Rails’ credentials diff integration becomes useful.

Rails + Git textconv

Rails can configure Git to use a special diff driver:

[diff "rails_credentials"]
textconv = bin/rails credentials:diff

In my Rails 8.1 application, I found exactly this in:

.git/config

Git therefore doesn’t simply compare the encrypted contents.

Instead:

git diff
    โ”‚
    โ–ผ
Git sees credentials.yml.enc
    โ”‚
    โ–ผ
rails_credentials diff driver
    โ”‚
    โ–ผ
bin/rails credentials:diff
    โ”‚
    โ–ผ
Rails decrypts the credentials
    โ”‚
    โ–ผ
Git displays a readable diff

Git itself doesn’t understand Rails encryption.

Rails is providing the text conversion command. Git simply knows how to invoke it.

See It Yourself

Suppose our credentials originally contain:

openai:
api_key: OLD_KEY

We change it to:

openai:
api_key: NEW_KEY

Now:

git diff

can show a useful diff such as:

+
+openai:
+ api_key: 'sdsdssdsdsdwewewddvcfgfgth'

That’s much more useful than comparing encrypted bytes.

The Experiment That Makes This Obvious

This is what made the behavior click for me.

Run:

git diff -- config/credentials.yml.enc

You get the human-readable credentials diff.

Now bypass Git’s text conversion:

git diff --no-textconv -- config/credentials.yml.enc

Now you see the encrypted content.

Something like:

3d9Jx8...encrypted-data...

That’s the proof.

The file itself is still encrypted.

It’s only the diff representation that’s being transformed.

So What Exactly Does Git Know?

Git doesn’t know anything about:

Rails
credentials
master.key
AES
encryption
decryption

Git knows:

diff driver
textconv

Rails configures:

rails_credentials

and tells Git:

When displaying a diff for this file,
run:
bin/rails credentials:diff

That’s a very nice example of two independent tools cooperating:

             Rails
               โ”‚
               โ”‚ provides
               โ–ผ
       credentials:diff
               โ”‚
               โ–ผ
             Git
               โ”‚
               โ”‚ uses
               โ–ผ
           textconv

How Does Rails Configure It?

Rails provides:

bin/rails credentials:diff --enroll

This enrolls the project in credentials diffing.

The Git attributes include:

config/credentials/*.yml.enc diff=rails_credentials
config/credentials.yml.enc diff=rails_credentials

Rails then ensures the Git diff driver is configured to use:

bin/rails credentials:diff

Rails’ application generator includes this credentials diff enrollment as part of application setup and Rails 7.0 already contained the credentials diffing implementation. (Gem)

So this isn’t actually an 8.1-only feature.

That’s an important distinction.

Is This New in Rails 8.1?

No – and this is an important correction.

The encrypted credentials diff functionality existed before Rails 8.1.

For example, Rails 7.0 already had the credentials:diff implementation, and Rails 7.2’s application generator also enrolled projects in credentials diffing. (Gem)

Rails has supported decrypted Git diffs for encrypted credentials for several versions and Rails 8.x continues to build on the credentials tooling.

Rails 8.1 does introduce other useful credentials functionality. For example, Rails 8.1 added command-line credential fetching, which can be useful for deployment tooling such as Kamal. (Ruby on Rails Guides)

Does This Make My Secrets Unsafe?

No – provided you protect the master key.

The important distinction is:

Git repository
โ”‚
โ”œโ”€โ”€ credentials.yml.enc
โ”‚       โ†“
โ”‚   encrypted
โ”‚
โ””โ”€โ”€ master.key
        โ†“
     SECRET

The encrypted file can be committed.

The master key should not be committed. Rails’ security guide explicitly recommends keeping the master key safe and out of version control. (Ruby on Rails Guides)

One Thing to Remember

The decrypted content can appear in your local terminal output.

For example:

git diff

could display:

+
+openai:
+  api_key: 'sdsdssdsdsdwewewddvcfgfgth'

So don’t casually share terminal screenshots containing credential diffs.

Also be careful when copying terminal output into:

  • Slack
  • GitHub issues
  • Pull requests
  • screenshots
  • blog posts
  • AI assistants

NOTE: The encryption protects the file stored in Git, but a decrypted diff is plaintext.

Rails Developer Takeaway

There are three different things here:

1. Encrypted file

config/credentials.yml.enc

This is what is actually stored in Git.

2. Encryption key

config/master.key

This decrypts the credentials and must remain secret.

3. Git diff representation

bin/rails credentials:diff

This is what allows us to see meaningful changes locally.

So:

                 GitHub
                   โ”‚
                   โ”‚ encrypted
                   โ–ผ
       credentials.yml.enc
                   โ–ฒ
                   โ”‚
             master.key
             stays secret


Local git diff:

credentials.yml.enc
        โ”‚
        โ–ผ
credentials:diff
        โ”‚
        โ–ผ
decrypted representation
        โ”‚
        โ–ผ
human-readable diff

Try This Yourself

If you’re working on a Rails application, check:

git config --show-origin --get-regexp 'diff|textconv|filter'

You may find:

file:.git/config diff.rails_credentials.textconv bin/rails credentials:diff

Then:

git diff --no-textconv -- config/credentials.yml.enc

Compare that with:

git diff -- config/credentials.yml.enc

The difference is a great way to understand what’s really happening.

Quick Reference

# Edit credentials
bin/rails credentials:edit

# Enroll project in credential diffing
bin/rails credentials:diff --enroll

# Normal readable diff
git diff

# Show the actual encrypted file diff
git diff --no-textconv -- config/credentials.yml.enc

# Inspect Git's configuration
git config --show-origin --get-regexp 'diff|textconv|filter'

# Check Git attributes
git check-attr diff -- config/credentials.yml.enc

Security rule:

 Y config/credentials.yml.enc โ†’ commit it
 X config/master.key          โ†’ NEVER commit it


Rails’ official security guide confirms that encrypted credentials can be stored in version control while the master key must remain protected. (Ruby on Rails Guides)

๐Ÿ“š References

Happy Coding!

Integrate AI with Rails: AI bootcamp for Developers โ€“ Day 7 – AI Response Streaming

Now let’s implement streaming. OpenRouter supports Server-Sent Events (SSE) when stream: true, and the current Ruby SDK exposes Chat Completions streaming through stream_raw; its higher-level stream helper is not available in every released SDK version. (OpenRouter)

We’ll keep the implementation practical and compatible with the SDK behavior you’re using.

Step 9 – Stream the AI response

What changes?

Currently:

Browser
  โ†“
POST
  โ†“
Rails waits for entire LLM response
  โ†“
redirect

We want:

Browser
  โ†“
POST
  โ†“
Rails
  โ†“
OpenRouter SSE stream
  โ†“
token
token
token
token
  โ†“
Browser

SSE is a long-lived HTTP response where the server sends incremental events. OpenRouter explicitly supports this with stream: true.

9.1 First, prove streaming works from Ruby

Before involving Rails, modify Ai::Client temporarily with a method:

def stream_chat(messages:, &on_delta)
  stream = @client.chat.completions.stream_raw(
    model: MODEL,
    messages: messages
  )

  stream.each do |chunk|
    delta = chunk.choices.first&.delta&.content
    on_delta.call(delta) if delta.present?
  end
end

The current SDK’s stream_raw returns an enumerable stream of chat completion chunks. (RubyDoc)

Now from Rails console:

conversation = Conversation.first

messages = Ai::PromptBuilder
  .new(conversation: conversation)
  .build

Then:

Ai::Client.new.stream_chat(messages: messages) do |delta|
  print delta
  $stdout.flush
end

You should see the answer appearing progressively:

Ruby is a programming language...

instead of getting the entire answer at once.

Why $stdout.flush?

Ruby can buffer stdout. Flushing makes each chunk visible immediately in the console.

9.2 Now expose streaming from Rails

Instead of making MessagesController#create wait for the completed response, we’ll create a streaming endpoint.

Open:

config/routes.rb

Add:

resources :conversations, only: [:create, :show] do
  resources :messages, only: [:create]
end

get "/conversations/:conversation_id/messages/stream",
    to: "messages#stream",
    as: :conversation_messages_stream

9.3 Add the streaming controller action

Open:

app/controllers/messages_controller.rb

Add:

include ActionController::Live

and:

def stream
  conversation = Conversation.find(params[:conversation_id])

  response.headers["Content-Type"] = "text/event-stream"
  response.headers["Cache-Control"] = "no-cache"
  response.headers["X-Accel-Buffering"] = "no"

  sse = SSE.new(response.stream)

  messages = Ai::PromptBuilder
    .new(conversation: conversation)
    .build

  content = +""

  begin
    Ai::Client.new.stream_chat(messages: messages) do |delta|
      next if delta.blank?

      content << delta

      sse.write(
        { content: delta },
        event: "message"
      )
    end

    sse.write(
      { done: true },
      event: "done"
    )
  ensure
    sse.close
    response.stream.close
  end
end

But Rails doesn’t provide SSE automatically.

Add:

include ActionController::Live

and use Rails’ ActionController::Live::SSE if available in our Rails 8.1 setup, or otherwise we can use the standard SSE format directly. Rails 8.1’s Live controller infrastructure is the relevant mechanism here.

To avoid another dependency, let’s actually use the raw SSE format ourselves.

Replace the sse.write(...) parts with:

response.stream.write(
  "event: message\n" \
  "data: #{JSON.generate(content: delta)}\n\n"
)

and completion:

response.stream.write(
  "event: done\n" \
  "data: #{JSON.generate(done: true)}\n\n"
)

So the complete action becomes:

include ActionController::Live

def stream
  conversation = Conversation.find(params[:conversation_id])

  response.headers["Content-Type"] = "text/event-stream"
  response.headers["Cache-Control"] = "no-cache"
  response.headers["X-Accel-Buffering"] = "no"

  messages = Ai::PromptBuilder
    .new(conversation: conversation)
    .build

  begin
    Ai::Client.new.stream_chat(messages: messages) do |delta|
      next if delta.blank?

      response.stream.write(
        "event: message\n" \
        "data: #{JSON.generate(content: delta)}\n\n"
      )
    end

    response.stream.write(
      "event: done\n" \
      "data: #{JSON.generate(done: true)}\n\n"
    )
  rescue IOError
    # Browser disconnected.
  ensure
    response.stream.close
  end
end

9.4 What’s happening?

The server sends chunks like:

event: message
data: {"content":"Ruby"}

event: message
data: {"content":" is"}

event: message
data: {"content":" a"}

event: message
data: {"content":" programming"}

That’s SSE.

The browser doesn’t need to wait for the entire LLM response.

9.5 Important limitation

Our current stream action is only streaming the display.

We are not yet persisting the final assistant message.

That’s deliberate.

The next iteration will accumulate:

content << delta

and after the stream finishes:

conversation.messages.create!(
role: :assistant,
content: content,
model: ...,
input_tokens: ...,
output_tokens: ...
)

So we ultimately want:

LLM
โ†“
stream chunks
โ†“
Browser
โ†“
accumulate full response
โ†“
PostgreSQL

9.6 Browser side

We can consume SSE with JavaScript:

const source = new EventSource(
  `/conversations/${conversationId}/messages/stream`
);

let content = "";

source.addEventListener("message", (event) => {
  const data = JSON.parse(event.data);

  content += data.content;

  document.querySelector("#assistant-response").innerHTML =
    content;
});

source.addEventListener("done", () => {
  source.close();
});

For our application, we’ll eventually use a Stimulus controller rather than inline JavaScript.

9.7 Don’t spend time styling this

Our immediate objective is proving:

LLM โ†’ SSE โ†’ Browser

Once you can see the response arriving incrementally, we’ve achieved the important part.

9.8 Commit

Once Ruby streaming works:

git add app/services/ai/client.rb
git commit -m "feat: stream LLM responses"
git push

Then we’ll wire the browser properly.


Int. knowledge from this step

You should now be able to explain:

What is SSE?

A persistent HTTP connection where the server pushes events to the client.

Why use it for AI?

Because LLM output naturally arrives incrementally, and streaming improves perceived latency.

Why not Action Cable?

WebSockets are bidirectional; SSE is simpler when the server primarily needs to push generated output to the browser.

Where does the LLM stream end?

At the Rails server, which consumes the provider’s SSE stream and forwards its own stream to the browser.

OpenRouter documents its AI streaming as SSE, while the Ruby SDK provides streaming chat-completion chunks through stream_raw.

Next step

Since ActionController::Live::SSE exists in Rails 8.1, let’s test the controller before committing.

One important point first: don’t test this through bin/rails server with WEBrick. Rails documents that WEBrick buffers responses, so Live streaming won’t behave correctly. Use our normal Puma server instead. (Ruby on Rails Guides)

1. First verify the route

Run:

bin/rails routes | grep stream

You should see our route, something like:

conversation_messages_stream
GET /conversations/:conversation_id/messages/stream

Then get a conversation ID:

bin/rails c
Conversation.last.id

For example:

1

Exit:

exit

2. Test with curl first

This is the easiest way to prove that the Rails endpoint is actually streaming.

Start Rails with Puma:

bin/rails server

Then in another terminal:

curl -N \
  -H "Accept: text/event-stream" \
  http://localhost:3000/conversations/1/messages/stream

Replace 1 with your real conversation ID.

Why -N?

curl -N means:

Don’t buffer the response.

Without it, you may receive everything at once and incorrectly conclude that streaming isn’t working.

3. What you should see

Because we’re using ActionController::Live::SSE, our response should look roughly like:

event: message
data: {"content":"Ruby"}
event: message
data: {"content":" is"}
event: message
data: {"content":" a"}
event: message
data: {"content":" programming"}
event: done
data: {"done":true}

The exact chunks will vary.

The important thing is that the output arrives progressively, not as one giant response at the end.

Rails’ SSE helper formats events and data for the text/event-stream response. (Ruby on Rails API)

4. Very important: our current stream action has a logical problem

Our current endpoint is probably something like:

def stream
  conversation = Conversation.find(params[:conversation_id])

  response.headers["Content-Type"] = "text/event-stream"

  sse = ActionController::Live::SSE.new(response.stream)

  messages = Ai::PromptBuilder
    .new(conversation: conversation)
    .build

  Ai::Client.new.stream_chat(messages: messages) do |delta|
    sse.write({ content: delta }, event: "message")
  end

  sse.write({ done: true }, event: "done")
ensure
  sse.close
end

This can stream the existing conversation, but it doesn’t receive a new user question.

Eventually our endpoint needs something like:

POST /conversations/:id/messages/stream

with:

content=Explain Ruby blocks

Otherwise we’re streaming whatever messages already exist in the conversation.

So for this first test, we’re only proving:

Rails
โ†“
ActionController::Live
โ†“
SSE
โ†“
Browser/curl

We’ll fix the request lifecycle immediately afterward.

5. Test directly from the browser

You can also open the endpoint in Chrome:

http://localhost:3000/conversations/1/messages/stream

But I prefer curl -N for the first test because the browser doesn’t give you a very useful raw view of SSE events.

Rails’ documentation uses essentially this same pattern – writing to response.stream periodically and closing the stream in ensure. (Ruby on Rails Guides)


One architectural correction before we commit

Don’t commit our current streaming implementation yet.

we’ll change the flow to the proper one:

Browser
   โ”‚
   โ”‚ POST message
   โ–ผ
MessagesController
   โ”‚
   โ”œโ”€โ”€ save user message
   โ”‚
   โ–ผ
LLM streaming
   โ”‚
   โ”œโ”€โ”€ SSE chunk โ†’ Browser
   โ”œโ”€โ”€ SSE chunk โ†’ Browser
   โ”œโ”€โ”€ SSE chunk โ†’ Browser
   โ”‚
   โ–ผ
Complete response
   โ”‚
   โ–ผ
Save assistant message

That is the version worth keeping in our portfolio and discussing in an int. scenario. Rails requires the response headers to be set before the first stream write and requires the stream to be closed when finished. (Ruby on Rails API)

the next step will be to connect the actual user message โ†’ streaming endpoint โ†’ browser UI rather than having a standalone stream endpoint.


Debug:ActionController::Live::ClientDisconnected – 500 Internal Server Error

Yes – very likely from our rescue behavior, but the deeper issue is that ActionController::Live::ClientDisconnected is not the same exception as IOError in Rails 8.1.

Rails 8.1 explicitly has:

ActionController::Live::ClientDisconnected

as its own exception class. (Ruby on Rails API)

So this:

rescue IOError
# Browser disconnected

does not necessarily catch the exception you’re seeing.

Why the 500 appears

Our stream is working, then eventually the client closes the connection – or example:

  • browser finishes and closes the SSE connection
  • EventSource.close() is called
  • browser navigates/reloads
  • user closes the tab
  • network connection disappears

Rails detects that the client is gone while processing the Live response and raises:

ActionController::Live::ClientDisconnected

Rails’ Live processing happens in a separate thread, and once the response has been committed Rails handles exceptions differently from a normal controller request. (Ruby on Rails API)

Fix our rescue

add:

rescue ActionController::Live::ClientDisconnected
Rails.logger.info("SSE client disconnected")

You can optionally also handle IOError:

rescue ActionController::Live::ClientDisconnected, IOError
Rails.logger.info("SSE client disconnected")

And keep:

ensure
sse.close
end

So our action should have roughly:

begin
# streaming logic
rescue IOError
Rails.logger.debug(">>>>>>>>>>>>>> Error Occured: IOError")
rescue ActionController::Live::ClientDisconnected
Rails.logger.debug(">>>>>>>>>>>>>> SSE client disconnected")
ensure
sse.close
end

But there is an important point

Don’t interpret ClientDisconnected as an application failure.

It’s closer to:

Rails: "I'm streaming."
Browser: "I'm no longer listening."
Rails: "Okay."

For SSE, that’s a normal lifecycle event.

Why you’re seeing Completed 500

This is the part that initially looks strange.

With ActionController::Live, Rails starts processing the action in a separate thread. When an exception occurs after the response has already been committed/started streaming, Rails can’t behave like a normal controller request with a fresh 500 response. Rails’ Live implementation has special handling for committed responses and closes the stream after logging the exception.

So our log can be misleading:

Completed 500 Internal Server Error
ActionController::Live::ClientDisconnected

even though:

  • the user received the streamed answer successfully,
  • the connection closed normally afterward.

That’s exactly why I would treat ClientDisconnected separately from genuine application errors.


Fix Streaming issue: After entering the question the page get stuck

Our server logs are as follows:

Started POST "/conversations"
Processing by ConversationsController#create as TURBO_STREAM
Parameters: {"...., "conversation" => {"title" => "Tell me about Node js"}, "commit" => "Start conversation"}

Then goes to
Started GET "/conversations/13"
Processing by ConversationsController#show as TURBO_STREAM
Parameters: {"id" => "13"}
Rendering conversations/show.html.erb
200 OK

Then the page stuck on the Form: form_with in the conversations/show.html.erb.
the div streaming-response is blank and it is not going to messages controller#stream or I think it is not executing the chat_controller.js also.

Yes – the server logs strongly suggest the problem is before MessagesController#stream. The conversation page loads correctly, then the form submission isn’t triggering our Stimulus chat#submit.

The likely culprit is Turbo + Stimulus registration, not SSE.

Let’s debug it quickly in the right order.

1. First confirm Stimulus is actually loading

Open your browser DevTools โ†’ Console.

Put this temporarily at the top of:

app/javascript/controllers/chat_controller.js
import { Controller } from "@hotwired/stimulus"

console.log("chat_controller.js loaded")

export default class extends Controller {
  connect() {
    console.log("Chat controller connected")
  }

  submit(event) {
    console.log("Chat submit triggered")
    // existing code...
  }
}

Reload:

http://localhost:3000/conversations/13

You should see:

chat_controller.js loaded
Chat controller connected

If you don’t see these

Then the problem is Stimulus registration/import, not the form.

2. Check your Stimulus setup

Because you’re using Rails 8.1, check:

app/javascript/controllers/index.js

It should contain something similar to:

import { application } from "controllers/application"
import ChatController from "controllers/chat_controller"
application.register("chat", ChatController)

Depending on the Rails 8 application template/setup, your index.js may use automatic controller loading instead. The important thing is that chat_controller.js is being registered under:

chat

3. Check application.js

Open:

app/javascript/application.js

You should have the normal Rails setup, typically something along the lines of:

import "@hotwired/turbo-rails"
import "controllers"

The important line is:

import "controllers"

Without it, your Stimulus controllers won’t be registered.

4. Check the actual HTML generated by Rails

This is very important.

Inspect the form in Chrome DevTools.

You should see:

<form
  data-controller="chat"
  data-action="submit->chat#submit"
  ...
>

If you don’t see:

data-controller="chat"

then our view isn’t producing the attributes we expect.

Our form should look approximately like:

<%= form_with(
  url: conversation_messages_path(@conversation),
  method: :post,
  data: {
    controller: "chat",
    action: "submit->chat#submit"
  }
) do |form| %>

5. There is another issue in our previous implementation

This is important.

We currently have:

<%= form_with(... method: :post) %>

but the Stimulus controller is trying to create:

GET /conversations/:id/messages/stream

That means the normal Turbo form submission and our SSE request are two different mechanisms.

We don’t actually want Turbo submitting the form at all.

Let Stimulus own the submission.

Change the form to:

<%= form_with(
  url: conversation_messages_path(@conversation),
  method: :post,
  data: {
    controller: "chat",
    action: "submit->chat#submit",
    turbo: false
  }
) do |form| %>

  <%= form.text_area :content,
      rows: 4,
      placeholder: "Ask something..." %>

  <%= form.submit "Send" %>
<% end %>

<div id="streaming-response"></div>

The important addition is:

turbo: false

This prevents Turbo from hijacking the form submission.

6. But there’s a second problem: EventSource

Our previous controller used:

const source = new EventSource(streamUrl)

That means the browser makes:

GET /conversations/13/messages/stream

and that endpoint expects:

params[:content]

So the URL must contain:

?content=Tell+me+about+Node+js

Let’s make the controller simpler and more reliable.

Use:

app/javascripts/controllers/chat_controller.js

import { Controller } from "@hotwired/stimulus"

console.log("chat_controller.js loaded")

export default class extends Controller {
  connect() {
    console.log("Chat controller connected")
  }

  submit(event) {
    console.log("Chat submit triggered")

    event.preventDefault()

    const form = event.currentTarget
    const url = form.action
    const formData = new FormData(form)

    const responseElement = document.querySelector("#streaming-response")
    responseElement.textContent = ""

    const conversationId = url.match(/conversations\/(\d+)\/messages/)[1]
    const streamUrl = 
      `/conversations/${conversationId}/messages/stream?${new URLSearchParams(
          formData
        )}`

    const source = new EventSource(streamUrl)
    source.addEventListener("message", (event) => {
      const data = JSON.parse(event.data)

      responseElement.textContent += data.content
    })

    source.addEventListener("done", () => {
      source.close()

      // Reload for now so the persisted assistant message appears.
      window.location.reload()
    })

    source.onerror = () => {
      source.close()
      responseElement.textContent += "\n\n[AI stream disconnected]"
    }
  }
}

7. Verify the route

Run:

bin/rails routes | grep messages

You should have something like:

conversation_messages
conversation_messages_stream

Specifically:

GET /conversations/:conversation_id/messages/stream

If you don’t have that route, the browser will never reach:

MessagesController#stream

8. Test the route independently

Before testing Stimulus, prove the endpoint itself still works:

curl -N \
-H "Accept: text/event-stream" \
"http://localhost:3000/conversations/13/messages/stream?content=Explain%20Node.js"

Since you already confirmed SSE works, this should still stream.

9. Then test the browser

Reload the conversation page and open DevTools โ†’ Console.

When the page loads:

Chat controller connected

When you submit:

Chat submit triggered

Then open DevTools โ†’ Network.

You should see:

GET /conversations/13/messages/stream?content=...

and its response should remain open while the AI streams.

If you see that request, the pipeline is working:

Form
โ†“
Stimulus
โ†“
EventSource
โ†“
GET /messages/stream
โ†“
MessagesController#stream
โ†“
LLM
โ†“
SSE
โ†“
Browser

10. Why our current log looks the way it does

You currently see:

POST /conversations
โ†“
GET /conversations/13
โ†“
Rendering conversations/show
โ†“
200 OK

and nothing afterward.

That means:

Rails page rendering โœ…
Stimulus submit โŒ

The fact that we don’t see:

Started GET "/conversations/13/messages/stream"

is the strongest clue.

So don’t debug ActionController::Live or OpenRouter yet.

The problem is almost certainly:

Stimulus controller registration
OR
Turbo intercepting the form
OR
data-controller/data-action not present

Cheers! We solved it.


Recommended architectural change (Optional)

One option is we can stop using EventSource with a GET query parameter for the actual chat request.

It’s fine for learning SSE, but for a real AI application, sending the user prompt as:

GET /messages/stream?content=...

isn’t ideal.

We’ll eventually use:

POST /conversations/:id/messages

with fetch() and consume the streaming response body:

POST
โ†“
Rails
โ†“
LLM stream
โ†“
ReadableStream
โ†“
Browser

We have to keep the user’s message in the POST body and have a single request lifecycle.

But this make our current app/javascript/controllers/chat_controller.js to re-write completely. So let’s move on to the next step ASAP.

to be continued…

Integrate AI with Rails: AI bootcamp for Developers โ€“ Day 4 -Practical Course โ€“ Part 3

Great. Now we can make the first real LLM request.

We’ll keep this step deliberately small. Our goal is not to build the complete AI assistant yet.

The goal is simply:

Rails
โ†“
Ai::Client
โ†“
OpenAI API
โ†“
LLM
โ†“
Response

Once this works, we’ll build the Rails service layer around it.

Step 5.11 – Add the OpenAI Ruby SDK

Rather than manually constructing HTTP requests, we’ll start with the official Ruby SDK.

1. Add the gem

Open our Gemfile and add:

gem "openai"

Then run:

bundle install

Verify:

bundle info ruby-openai

You should see where Bundler installed the gem.

Why use an SDK?

We could use Ruby’s Net::HTTP ourselves:

Ruby
โ†“
Net::HTTP
โ†“
HTTP request
โ†“
OpenAI

But then we’d have to manually handle:

  • authentication headers
  • JSON encoding
  • HTTP errors
  • response parsing
  • request formatting

The SDK gives us:

Ruby
โ†“
OpenAI Ruby SDK
โ†“
HTTP
โ†“
OpenAI

Important point: An SDK doesn’t eliminate the HTTP API. It is an abstraction over it.

Step 5.12 – Verify the gem

Run:

bin/rails console

Then:

require "openai"

It should return:

=> true

or possibly:

=> "openai"

depending on the gem’s load behavior.

Then:

OpenAI

should resolve without a NameError.

Exit:

exit

Step 5.13 – Let’s inspect the SDK before using it

This is something I want you to develop as a senior Ruby developer habit.

Instead of blindly copying code from a blog, let’s see what API the installed gem exposes.

Run:

bundle info ruby-openai

Then:

bin/rails console

Inside console:

require "openai"

Then:

OpenAI::Client.instance_method(:initialize).parameters

This tells us what the client’s constructor expects.

Also try:

OpenAI::Client.instance_methods(false)

We’re learning to inspect a Ruby library rather than treating it as magic.

Step 5.14 – Create the OpenAI client

Now let’s modify:

app/services/ai/client.rb

We’ll start with:

class Ai::Client
  def initialize
    @api_key = Rails.application.credentials.dig(:openai, :api_key)

    raise "OpenAI API key is missing" if @api_key.blank?

    @client = OpenAI::Client.new(api_key: @api_key)
  end
end

Now we have:

Ai::Client
   โ”‚
   โ”œโ”€โ”€ reads Rails credentials
   โ”‚
   โ””โ”€โ”€ creates OpenAI SDK client

Step 5.15 – Test initialization

Run:

bin/rails console

Then:

client = Ai::Client.new

It should return something similar to:

#<Ai::Client:0x...>

No request has happened yet.

That’s important.

We’ve only done:

Rails credentials
โ†“
API key
โ†“
OpenAI::Client

Stop here

Don’t call the LLM yet.

I want you to complete these steps first:

1. Gemfile

gem "openai"

2. Install

bundle install

3. Verify

bundle info ruby-openai

4. Update

app/services/ai/client.rb

with the code above.

5. Test

bin/rails c
client = Ai::Client.new

One note

The Ruby OpenAI SDK’s API can change between versions, so don’t blindly copy the exact request syntax from older tutorials. That’s why we’re checking the version we’ve actually installed before writing the API call.

Now we’ve:

“OpenAI client initialized.”

We’ll make our first actual LLM request and inspect the complete response, including:

response
model
output
usage
input tokens
output tokens

That will lead directly into why we added those fields to our Message model.


@client = OpenAI::Client.new(api_key: @api_key)

We’ll use our installed SDK’s API, not older ruby-openai examples. The current official openai Ruby SDK documents OpenAI::Client.new(api_key: ...) and the Responses API as the current interface. (GitHub)

Step 5.16 – Make the First Real LLM Request

For this step, we’ll do one simple request and inspect the response.

We are not integrating it with Conversation or Message yet.

Our goal is:

Rails console
โ†“
Ai::Client
โ†“
OpenAI Responses API
โ†“
LLM
โ†“
Response

1. Add a chat method

Open:

app/services/ai/client.rb

Change it to:

class Ai::Client
  def initialize
    @api_key = Rails.application.credentials.dig(:openai, :api_key)

    raise "OpenAI API key is missing" if @api_key.blank?

    @client = OpenAI::Client.new(api_key: @api_key)
  end

  def chat(message)
    @client.responses.create(
      model: "gpt-5.2",
      input: message
    )
  end
end

The SDK’s current Responses API accepts model and input for creating a response. (GitHub)

Why input: message?

We’re deliberately starting with the simplest possible request:

input: "Explain Ruby blocks in simple terms"

Later we’ll send structured conversation history:

input: [
{ role: :system, content: "..." },
{ role: :user, content: "..." }
]

The Responses API supports both simple input and structured message input. (GitHub)

2. Start Rails console

bin/rails c

Create the client:

client = Ai::Client.new

Now make the request:

response = client.chat(
message: "Explain Ruby blocks in simple terms."
)

This is the moment our application makes an actual network request.

3. Inspect the response

First:

response.class

Then:

response

Don’t worry if the output is large.

The current official Ruby SDK returns typed response objects and the response contains the generated output plus metadata such as usage. (GitHub)

But if you get the following output, we can change the model which has free API calls:

ai-assistant(dev):013> client = Ai::Client.new
ai-assistant(dev):003> res = ai.chat('I want to be a expert in Ruby language')
app/services/ai/client.rb:11:in 'Ai::Client#chat': {url: "https://api.openai.com/v1/responses", status: 429, body: {error: {message: "You have no credits remaining. Add credits to continue using the API at https://platform.openai.com/settings/organization/billing/.", type: "insufficient_quota", param: nil, code: "credit_balance_exhausted"}}} (OpenAI::Errors::RateLimitError)
        from (ai-assistant):3:in '<compiled>'

Yes – the error makes sense and there is an important distinction here:

Our ChatGPT subscription and OpenAI API billing are separate.

So even if you can use ChatGPT normally, that does not give your Ruby application free API calls. OpenAI explicitly says ChatGPT and API billing are managed separately. (OpenAI Help Center)

Why you’re seeing You have no credits remaining

Your Rails code is calling the OpenAI API, not ChatGPT:

Rails app
โ†“
OpenAI API
โ†“
API billing / credits

The API account associated with your key currently has no usable credits. OpenAI’s current prepaid-billing documentation says API requests stop once the available credit balance is exhausted. (OpenAI Help Center)

“But aren’t basic models free?”

Not generally for the API.

There may be specific free/trial allocations or products with included usage, but you should not assume that a model being available in ChatGPT means the API is free.

For our Rails application, we’re using:

OpenAI::Client

which consumes API usage and is metered separately.


What I recommend for our course

I don’t think we should spend money just to continue learning unless you’re comfortable doing so.

We have three practical paths:

Option 1 – Add a small API balance

Open your OpenAI API billing overview and check your balance. New API users currently use prepaid billing and the documented minimum purchase is $5, with $10 as the default purchase amount. (OpenAI Help Center)

For this course a small balance should be plenty for experimentation because our prompts will be tiny.

Option 2 – Use another provider with a free tier

We could temporarily use a provider that offers some free API usage, while keeping the same architecture:

Ai::Client
โ†“
Provider
โ†“
LLM

This is actually useful because later we’ll make our architecture provider-agnostic.

Option 3 – Run a local model

We can install something like Ollama and run an LLM locally:

Rails
โ†“
Ai::Client
โ†“
localhost
โ†“
Local LLM

Advantages:

  • no API credits
  • no network dependency
  • no per-token cost
  • great for development

The downside is that the model quality may differ from hosted models, and local inference requires reasonable hardware.


One important thing for our architecture

Don’t change this:

Ai::Client

The fact that OpenAI isn’t currently usable doesn’t mean we should redesign the application.

We specifically created:

Rails
โ†“
Ai::Client
โ†“
Provider

so that later we can switch:

Ai::Client
โ†“
OpenAI

to:

Ai::Client
โ†“
Anthropic

or:

Ai::Client
โ†“
Ollama

without rewriting our Rails application.

That’s actually an important senior-level design lesson.


What we can do now?

Since our objective is learning AI engineering, not spending money on API calls, first check your API billing page.

If it shows:

Free trial credit remaining: $0.00

then the error is fully explained. OpenAI’s billing documentation uses exactly this sort of balance indicator. (OpenAI Help Center)

We can then decide between a small API credit or a local/free-tier provider.

For this course, I slightly prefer keeping OpenAI as the first provider so you learn the real production API flow, then later we’ll add a second provider/local model to demonstrate the abstraction properly.

4. Get the generated text

Try:

response.output_text

You should get a normal answer such as:

A Ruby block is a chunk of code that can be passed to a method...

This is the first important distinction:

response
โ†“
entire API response
response.output_text
โ†“
just the model's text

Don’t immediately throw away the full response. We need the metadata later.

5. Inspect the model

Try:

response.model

This tells you which model actually generated the response.

That’s relevant to our messages.model column.

6. Inspect usage

Now:

response.usage

You should see token-related information.

Inspect it:

response.usage.input_tokens

and:

response.usage.output_tokens

These are directly related to the fields we added earlier:

messages
-------------------
input_tokens
output_tokens

So our database design is now connected to a real API response.

LLM response
โ”‚
โ”œโ”€โ”€ model
โ”œโ”€โ”€ output text
โ””โ”€โ”€ usage
โ”œโ”€โ”€ input_tokens
โ””โ”€โ”€ output_tokens

The SDK’s response models expose usage information as part of the response. (GitHub)

7. One very important experiment

Ask a second question:

response2 = client.chat(
message: "What is my name?"
)

You’ll probably notice the model doesn’t know your name from the previous request.

That’s intentional.

We made two independent requests:

Request 1
"Explain Ruby blocks"
Request 2
"What is my name?"

The LLM does not automatically receive our previous request.

This is going to become extremely important when we implement:

Conversation
โ†“
Messages
โ†“
Prompt Builder
โ†“
LLM

Our Rails application will be responsible for providing the appropriate conversation context.

8. One thing to notice

We’ve built:

app/services/ai/client.rb

and now:

Ai::Client.new.chat(...)

works.

That’s already a valuable architectural boundary:

Rails application
โ”‚
โ–ผ
Ai::Client
โ”‚
โ–ผ
OpenAI SDK
โ”‚
โ–ผ
OpenAI API

Our controllers won’t need to know:

  • how authentication works,
  • how the SDK works,
  • which API endpoint is used,
  • how OpenAI responses are represented.

That’s why we created the abstraction.

Check our code in this repo:ย https://github.com/abhilashak/ai_assistant


Stop Here

Run these commands one by one:

bin/rails c
client = Ai::Client.new
response = client.chat(
message: "Explain Ruby blocks in simple terms."
)

Then inspect:

response.output_text
response.model
response.usage
response.usage.input_tokens
response.usage.output_tokens

Don’t paste our API key or any sensitive output anywhere.

Now: “Our First LLM request works.”

Then we’ll do the next important step: inspect the raw response structure and improve Ai::Client so it returns a clean Ruby object to the rest of our Rails application.


Understading Rails 8.1 Action Controller Live SSE

Modern applications often need to deliver information to the browser as it becomes available, rather than waiting until the entire controller action finishes.

Examples include:

  • Live progress updates
  • Long-running exports
  • Real-time dashboards
  • AI-generated responses
  • Build/deployment logs
  • Notifications
  • Server-side status updates
  • Streaming large files
  • Server-Sent Events (SSE)

Rails provides this capability through ActionController::Live.

Rails 8.1 also exposes a particularly useful companion class:

ActionController::Live::SSE

Together, they provide a relatively simple way to implement HTTP streaming and Server-Sent Events directly from a Rails controller.

One important clarification: ActionController::Live itself is not new in Rails 8.1. It has existed for several Rails versions. However, Rails 8.1 continues to provide and refine the streaming infrastructure, including configuration around execution-state sharing. The examples below are based on the Rails 8.1 API.


What is ActionController::Live?

Normally, a Rails controller behaves conceptually like this:

Browser
|
| HTTP request
v
Rails Controller
|
| execute entire action
|
| generate complete response
v
Browser receives response

For example:

def report
result = generate_report
render json: result
end

The browser generally waits until the action has generated its response.

With ActionController::Live, Rails can instead stream pieces of the response while the action is still executing:

Browser
|
| HTTP request
v
Rails Controller
|
| write chunk #1
|--------------------> Browser
|
| write chunk #2
|--------------------> Browser
|
| write chunk #3
|--------------------> Browser
|
| finish

Rails documents ActionController::Live as a module that allows controller actions to stream data to the client as it is written.


Basic ActionController::Live Example

A minimal controller looks like this:

class StreamsController < ApplicationController
  include ActionController::Live

  def show
    response.headers["Content-Type"] = "text/plain"

    5.times do |i|
      response.stream.write "Chunk #{i + 1}\n"
      sleep 1
    end
  ensure
    response.stream.close
  end
end

The important part is:

include ActionController::Live

and then:

response.stream.write(...)

Instead of constructing one large response, the controller writes directly to the response stream.

What happens internally?

Rails executes the streaming action in a separate thread so that the response can begin flowing to the client while the controller continues producing data. Rails 8.1 uses a dedicated cached thread-pool executor for live controller processing.

That distinction is extremely important for production applications.


What is Server-Sent Events?

ActionController::Live is the general streaming mechanism.

SSE is a specific protocol built on top of HTTP streaming.

Server-Sent Events allow the server to continuously send events to a browser over a long-lived HTTP connection.

The browser uses the standard JavaScript API:

const source = new EventSource("/events");
source.onmessage = event => {
console.log(event.data);
};

The communication is one-way:

Server --------------------> Browser

Unlike WebSockets:

Server <-------------------> Browser

SSE is therefore a good choice when the browser mainly needs to listen for server-side updates rather than continuously send messages back to the server. The browser’s EventSource API maintains the persistent connection and automatically handles reconnection.


ActionController::Live::SSE

Rails provides:

ActionController::Live::SSE

to make SSE formatting easier.

Instead of manually writing:

event: update
data: {"status":"processing"}

Rails can generate the SSE format for you.

The class accepts a stream:

sse = ActionController::Live::SSE.new(response.stream)

and then:

sse.write({ status: "processing" })

Rails converts non-string objects to JSON and writes them using SSE formatting.


Building a Rails SSE Endpoint

Let’s build a realistic example.

Controller

class NotificationsController < ApplicationController
  include ActionController::Live

  def index
    response.headers["Content-Type"] = "text/event-stream"
    response.headers["Cache-Control"] = "no-cache"

    sse = ActionController::Live::SSE.new(
      response.stream,
      retry: 3000
    )

    10.times do |i|
      sse.write(
        {
          message: "Notification #{i + 1}",
          timestamp: Time.current.iso8601
        },
        event: "notification",
        id: i + 1
      )

      sleep 2
    end
  ensure
    sse&.close
  end
end

Rails’ SSE implementation supports three primary options:

:event
:retry
:id

event identifies the event type, retry tells the browser how long to wait before reconnecting, and id becomes the event identifier used for Last-Event-ID on reconnect.


JavaScript Client

The browser can consume the endpoint using EventSource.

const source = new EventSource("/notifications");
source.addEventListener("notification", event => {
const data = JSON.parse(event.data);
console.log(data.message);
console.log(data.timestamp);
});
source.onerror = error => {
console.error("SSE connection error", error);
};

The browser automatically opens a persistent HTTP connection.

When Rails sends:

event: notification
id: 1
data: {"message":"Notification 1","timestamp":"..."}

the browser invokes:

source.addEventListener("notification", ...)

The SSE wire format is based on text fields such as event, data, id, and retry, with an empty line terminating each event.


ActionController::Live vs ActionController::Live::SSE

This distinction is worth remembering.

FeatureActionController::LiveActionController::Live::SSE
PurposeGeneric HTTP streamingSSE formatting
OutputArbitrary stream dataSSE events
Browser APIDepends on your protocolEventSource
JSON handlingYou handle itRails can serialize objects
Event namesManualBuilt in
Event IDsManualBuilt in
Reconnection supportManualSSE protocol support
Typical useCSV/file/log streamingNotifications/live updates

Think of it like this:

ActionController::Live
        |
        +---- response.stream.write
        |
        +---- send_stream
        |
        +---- SSE
                 |
                 +---- event
                 +---- data
                 +---- id
                 +---- retry


Streaming a Large CSV

ActionController::Live is not limited to SSE.

A very practical use case is exporting a large dataset.

Rails 8.1 exposes send_stream, specifically for generating data progressively rather than buffering the entire file in memory.

For example:

class ReportsController < ApplicationController
  include ActionController::Live

  def export
    send_stream(
      filename: "users.csv",
      type: "text/csv"
    ) do |stream|

      stream.write "id,email,created_at\n"

      User.find_each do |user|
        stream.write(
          "#{user.id},#{user.email},#{user.created_at.iso8601}\n"
        )
      end
    end
  end
end

This is much better than:

csv = User.find_each.map do |user|
  ...
end

send_data csv

for a very large export.

The second approach potentially builds a large amount of data in memory.

The streaming approach allows Rails to send the output progressively.


A Very Interesting Use Case: AI Streaming

Another practical use case is streaming generated text.

Imagine an AI API returns tokens incrementally:

Hello
Hello, I
Hello, I can
Hello, I can help
Hello, I can help you
...

Instead of waiting for the complete response:

response = ai_client.generate(...)
render json: response

you could expose a streaming endpoint:

class AiController < ApplicationController
  include ActionController::Live

  def stream
    response.headers["Content-Type"] = "text/event-stream"

    sse = ActionController::Live::SSE.new(
      response.stream,
      event: "token"
    )

    ai_client.stream(prompt) do |token|
      sse.write(
        {
          content: token
        }
      )
    end
  ensure
    sse&.close
  end
end

The browser can then update the UI immediately as chunks arrive.

This is one of the reasons HTTP streaming has become particularly relevant for modern applications.


Real-Time Notifications

A very common architecture is:

                    +----------------+
                    | Rails Server   |
                    +--------+-------+
                             |
                             | SSE
                             |
                    +--------v-------+
                    | Browser       |
                    +----------------+

For example:

class NotificationsController < ApplicationController
  include ActionController::Live

  def stream
    response.headers["Content-Type"] = "text/event-stream"

    sse = ActionController::Live::SSE.new(
      response.stream,
      event: "notification"
    )

    loop do
      notification = Notification.pending.first

      if notification
        sse.write(
          {
            id: notification.id,
            message: notification.message
          },
          id: notification.id
        )
      else
        # Heartbeat
        sse.write(": keep-alive")
      end

      sleep 2
    end
  ensure
    sse&.close
  end
end

However, this example introduces an important architectural question.

Where does the event come from?

Polling the database inside every open SSE request is usually not a scalable architecture.

For production systems, you will typically want an event source such as:

Database
|
v
Redis / PubSub / Message Broker
|
v
Rails SSE endpoint
|
v
Browser

That is a much better design than repeatedly querying the database from every connected client.


Heartbeats Matter

Long-lived HTTP connections can be terminated by proxies, load balancers, or infrastructure when no data is transferred for a while.

SSE supports comment messages such as:

: heartbeat

which browsers ignore as application events but still receive as stream traffic.

The SSE format explicitly allows comment lines, and they can be used to keep connections alive.

In Rails:

sse.write(": heartbeat")

or, depending on how you implement the stream, write an SSE comment directly to response.stream.

For an application with long periods of inactivity, heartbeat strategy should be considered part of your production design.


Reconnection and Last-Event-ID

One of the most useful SSE features is event IDs.

Suppose Rails sends:

id: 101
event: order_update
data: {"status":"paid"}

The browser remembers the last event ID.

If the connection is interrupted, the browser may reconnect and send:

Last-Event-ID: 101

Rails’ SSE class supports the id field specifically for this scenario.

Your controller can inspect it:

last_id = request.headers["Last-Event-ID"]

and resume appropriately:

updates = OrderUpdate.where("id > ?", last_id.to_i)
updates.find_each do |update|
sse.write(
update.attributes,
id: update.id,
event: "order_update"
)
end

This is significantly more robust than treating every reconnect as a completely new stream.


The Most Important ActionController::Live Caveat: Threads

This is probably the most important thing to understand before introducing ActionController::Live.

Rails executes the streaming action in a separate thread.

Therefore:

class MyController < ApplicationController
include ActionController::Live
def stream
# Runs in streaming execution context
end
end

should not be treated exactly like a normal synchronous controller action.

Rails explicitly warns that streaming actions need to be thread-safe and should not share unsafe mutable state between threads.

Avoid patterns such as:

@@shared_state = {}
@@shared_state[user_id] = ...

or other mutable global/class-level state unless it is deliberately designed for concurrent access.

Prefer:

Redis
Database
Message broker
Thread-safe abstractions

for shared state.


Rails 8.1: Execution State Sharing

Rails 8.1 exposes:

config.action_controller.live_streaming_excluded_keys

which controls which execution-state keys should not be copied into the streaming thread.

By default, Rails shares execution state from the parent thread.

One important example involves Active Record connection routing.

Rails documents this configuration for cases such as:

ActiveRecord::Base.connected_to(role: :reading) do
...
end

where the streaming thread might otherwise inherit the parent’s database connection context.

For example:

config.action_controller.live_streaming_excluded_keys =
[:active_record_connected_to_stack]

This is a more advanced Rails 8.1 consideration, but it demonstrates an important point:

Streaming is not just “normal controller code with response.stream.write.”

Execution context matters.


Headers Must Be Set Before Streaming

Once you start writing to the stream:

response.stream.write(...)

the response can be committed.

After the response is committed, you cannot safely modify headers.

Rails specifically documents that calling write or close commits the response.

Therefore do this:

response.headers["Content-Type"] = "text/event-stream"
response.headers["Cache-Control"] = "no-cache"
sse = ActionController::Live::SSE.new(response.stream)
sse.write(...)

Not:

sse.write(...)
response.headers["Cache-Control"] = "no-cache"

The second version is too late.


Always Close the Stream

This is another critical rule.

Always ensure the stream closes:

ensure
sse&.close
end

or:

ensure
response.stream.close
end

Rails explicitly warns that failing to close the stream can leave the socket open indefinitely.

A production implementation should therefore almost always look like:

begin
# streaming work
ensure
# close stream
end

Handling Client Disconnects

A browser can disappear at any time.

For example:

User closes tab
|
v
SSE connection disappears
|
v
Rails stream encounters disconnect

Rails exposes:

ActionController::Live::ClientDisconnected

for client disconnect situations.

You can handle it explicitly when appropriate:

rescue ActionController::Live::ClientDisconnected
Rails.logger.info("SSE client disconnected")
ensure
sse&.close
end

For long-running streams, disconnect handling is especially important because you don’t want server-side work continuing unnecessarily after the browser is gone.


Proxy and Middleware Buffering

A common mistake is to test streaming locally and assume production will behave identically.

You might write:

response.stream.write "hello"
sleep 5
response.stream.write "world"

and expect:

hello

to appear immediately.

But an intermediary could buffer the response.

Possible intermediaries include:

Browser
|
Load Balancer
|
Reverse Proxy
|
Nginx
|
Rails

Rails itself documents that response buffering can interfere with streaming, including interaction with Rack::ETag in relevant Rack versions.

Therefore streaming should always be tested through the same infrastructure path used in production.


SSE vs WebSockets vs Polling

This is one of the most important architectural decisions.

ApproachDirectionConnectionGood For
PollingClient โ†’ Server repeatedlyShortSimple updates
Long PollingMostly server โ†’ clientRepeated HTTPOlder architectures
SSEServer โ†’ ClientLong-lived HTTPNotifications/live feeds
WebSocketBidirectionalPersistent socketChat/games/collaboration
ActionController::LiveDepends on implementationStreaming HTTPGeneric streaming

Use SSE when:

Server -> Browser

is the dominant requirement.

Examples:

Order status
Build progress
Notifications
Stock updates
Live dashboard
AI text streaming
Import progress

Use WebSockets when:

Server <-> Browser

needs continuous two-way communication.

Examples:

Chat
Multiplayer applications
Collaborative editing
Interactive sessions

Use normal HTTP when:

You simply need:

request -> response

There is no reason to introduce streaming complexity for an ordinary CRUD endpoint.


Connection Scalability Is Different

A normal HTTP request may live for:

100 ms
500 ms
2 seconds

An SSE connection may remain open for:

5 minutes
30 minutes
several hours

That changes your capacity model.

Suppose:

10,000 users

each maintain an SSE connection.

That means your infrastructure potentially needs to support:

10,000 long-lived connections

You therefore need to think about:

Web server capacity
Worker/thread usage
File descriptors
Load balancers
Reverse proxies
Timeout configuration
Connection limits
Memory
Monitoring

There is also a browser-level consideration: SSE uses persistent HTTP connections, and connection limits can matter especially under HTTP/1.1; HTTP/2 changes the connection model by multiplexing streams.


Be Careful with Active Record Connections

A particularly important Rails concern is database connection usage.

Consider:

loop do
users = User.where(active: true)
...
sleep 1
end

inside every SSE request.

If you have hundreds or thousands of clients, you can easily end up with poor database behavior.

A better architecture is usually:

            Event Producer
                 |
       +---------+---------+
       |                   |
     Redis              Broker
       |                   |
       +---------+---------+
                 |
            Rails SSE
                 |
              Browser

The SSE request should ideally wait for events, rather than continuously hammer the database.


A Better Production Architecture

For example, imagine an order-management application.

When an order changes:

Order updated
|
v
Publish "order.updated"
|
v
Redis / PubSub
|
v
SSE connection
|
v
Browser updates UI

The Rails controller becomes primarily responsible for:

Connection
โ†“
Subscribe
โ†“
Receive event
โ†“
Serialize event
โ†“
Write SSE
โ†“
Repeat

rather than:

Connection
โ†“
Query database
โ†“
Sleep
โ†“
Query database
โ†“
Sleep
โ†“
Query database

That distinction becomes very important at scale.


Testing an SSE Endpoint

A browser test is useful, but curl is often even more convenient during development.

For example:

curl -N http://localhost:3000/notifications

The -N option prevents curl from buffering output, making the stream easier to observe.

You should see events arrive progressively:

event: notification
id: 1
data: {"message":"Notification 1"}
event: notification
id: 2
data: {"message":"Notification 2"}

This is a very useful debugging technique.


Testing ActionController::Live

For controller tests, streaming requires more consideration than a typical controller action because the response is not necessarily generated as one complete body.

The key things to test are:

Content-Type
Event names
Event IDs
Payload format
Connection termination
Client disconnect handling
Error handling

For example, conceptually:

assert_equal "text/event-stream", response.media_type

and verify that the generated body contains expected SSE fields.

For more complex streaming behavior, integration/system-level testing is generally more valuable than testing only internal controller implementation details.


A Clean SSE Controller Pattern

For a production-style controller, I prefer keeping the controller small:

class EventsController < ApplicationController
  include ActionController::Live

  def index
    prepare_stream_headers

    sse = ActionController::Live::SSE.new(
      response.stream,
      retry: 3_000
    )

    event_stream.each do |event|
      sse.write(
        event.payload,
        event: event.type,
        id: event.id
      )
    end
  rescue ActionController::Live::ClientDisconnected
    Rails.logger.info("SSE client disconnected")
  ensure
    sse&.close
  end

  private

  def prepare_stream_headers
    response.headers["Content-Type"] = "text/event-stream"
    response.headers["Cache-Control"] = "no-cache"
  end

  def event_stream
    # Redis / PubSub / broker subscription
  end
end

The controller handles HTTP concerns, while the event source is delegated elsewhere.

That separation becomes especially valuable when the event system grows.


Advantages of ActionController::Live

Lower time-to-first-byte

The server can start sending data before the complete operation has finished.

Lower memory usage for large streams

You don’t necessarily need to construct the entire response in memory first.

Native HTTP

There is no requirement for a completely different networking protocol.

SSE is simple for browser clients

The browser already provides:

EventSource

Automatic SSE reconnect behavior

SSE includes protocol support for reconnecting and event IDs.

Fits naturally into Rails controllers

You can continue using Rails authentication, routing, controllers, and application services while introducing streaming only where needed.


Disadvantages

Streaming is not free.

Threading complexity

ActionController::Live executes the action in a separate thread.

Long-lived connections

Unlike conventional requests, connections may remain open for long periods.

Capacity planning becomes important

Thousands of connected browsers can have a very different infrastructure impact than thousands of short requests.

Reverse-proxy configuration matters

Buffering and timeout behavior can break an otherwise-correct implementation.

Database usage can become dangerous

Naive polling inside every streaming connection can put significant pressure on PostgreSQL.

Operational complexity

Logging, monitoring, disconnects, reconnects, retries, and infrastructure timeouts all become part of the design.


When Should a Rails Developer Use It?

A good decision rule is:

Do I need data before the complete response is available?
            |
           Yes
            |
            v
Does the client only need server -> browser updates?
            |
         +--+--+
         |     |
        Yes    No
         |      |
         v      v
       SSE    WebSocket

For generic data/file streaming:

ActionController::Live

For browser-facing event streams:

ActionController::Live::SSE

For ordinary request/response APIs:

render json:

is usually the better choice.


What a Senior Rails Developer Should Know Before Using It

Before introducing ActionController::Live, I would explicitly answer these questions:

1. How long will the connection remain open?

Seconds?

Minutes?

Hours?

2. How many simultaneous clients could exist?

100?

1,000?

100,000?

3. What is the event source?

Database?

Redis?

Kafka?

Another service?

4. What happens when the client disconnects?

Can the server stop work immediately?

5. How will reconnects work?

Will events be lost?

Do you need id and Last-Event-ID?

6. What happens behind the load balancer?

Does it buffer?

Does it timeout idle connections?

7. Is your code thread-safe?

Remember that Rails executes Live actions in a separate thread.

8. How will you monitor connections?

You should be able to answer:

How many active SSE connections exist?
How long have they been open?
How many disconnected unexpectedly?
How many events are being delivered?
What is the event delivery latency?

Final Example

A compact Rails 8.1 SSE implementation can look like this:

class EventsController < ApplicationController
  include ActionController::Live

  def stream
    response.headers["Content-Type"] = "text/event-stream"
    response.headers["Cache-Control"] = "no-cache"

    sse = ActionController::Live::SSE.new(
      response.stream,
      retry: 3_000
    )

    10.times do |i|
      sse.write(
        {
          message: "Event #{i + 1}",
          timestamp: Time.current.iso8601
        },
        event: "update",
        id: i + 1
      )

      sleep 1
    end
  rescue ActionController::Live::ClientDisconnected
    Rails.logger.info("Client disconnected")
  ensure
    sse&.close
  end
end

And the client:

const events = new EventSource("/events/stream");
events.addEventListener("update", event => {
const data = JSON.parse(event.data);
console.log(data.message);
});
events.onerror = error => {
console.error("Connection error", error);
};

This small example demonstrates the complete concept:

ActionController::Live
โ†“
HTTP streaming
โ†“
ActionController::Live::SSE
โ†“
text/event-stream
โ†“
Browser EventSource
โ†“
Real-time UI updates

Conclusion

ActionController::Live is Rails’ low-level mechanism for streaming HTTP responses while the controller is still producing them.

ActionController::Live::SSE builds on that mechanism to provide a convenient implementation of Server-Sent Events.

The most important distinction is:

Live = streaming mechanism
SSE = event-stream protocol

For modern Rails applications, this makes ActionController::Live particularly useful for large exports, progressive responses, logs, long-running operations, and AI output, while SSE is a strong fit for server-to-browser real-time updates.

But the real engineering challenge is usually not writing:

sse.write(...)

The difficult part is designing the surrounding system correctly:

Event source
โ†“
Concurrency
โ†“
Connection lifecycle
โ†“
Reconnect strategy
โ†“
Proxy/load-balancer behavior
โ†“
Database/resource usage
โ†“
Observability

That is where ActionController::Live moves from being a simple Rails API feature to a genuine production architecture decision.

References

Rails 8.1 ActionController::Live API: https://edgeapi.rubyonrails.org/classes/ActionController/Live.html

Rails 8.1 ActionController::Live::SSE API: https://api.rubyonrails.org/classes/ActionController/Live/SSE.html

Rails 8.1 release information:https://guides.rubyonrails.org/8_1_release_notes.html

MDN – Server-Sent Events and EventSource:

https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events

https://developer.mozilla.org/en-US/docs/Web/API/EventSource

Happy Implementing!

Rails 8 + ActiveAdmin: The Propshaft vs Sprockets Dilemma ๐Ÿ˜ตโ€๐Ÿ’ซ

ActiveAdmin traditionally requires Sprockets, but there are actually several alternatives and modern approaches. Let me break down your options:

ActiveAdmin Asset Pipeline Options:

Current Status (2025):

  • ActiveAdmin 3.x still primarily designed for Sprockets
  • It has SCSS files with imports, mixins, and variables that need preprocessing
  • The gem’s CSS compilation expects Sprockets’ asset compilation pipeline

Alternative Approaches:

1. Keep Sprockets (Current Choice)

Pros:

  • โœ… Fully supported, zero configuration issues
  • โœ… All ActiveAdmin features work out-of-the-box
  • โœ… Stable and well-tested

Cons:

  • โŒ Older, slower asset pipeline
  • โŒ More complex than Propshaft
  • โŒ Goes against Rails 8 defaults

2. Hybrid: Propshaft + Manual CSS for ActiveAdmin

We could try keeping Propshaft for our main app and handling ActiveAdmin CSS manually:

ActiveAdmin traditionally requires Sprockets, but the landscape is evolving. Here are your realistic options in 2025:

Current Situation:

ActiveAdmin 3.x: Requires Sprockets (stable)
ActiveAdmin 4.0 beta: Propshaft support is experimental/problematic
Rails 8 default: ๐Ÿ†• Propshaft (faster, simpler)

https://github.com/activeadmin/activeadmin/releases

Our Options:

Option 1: Stay with Sprockets โญ (Recommended for now)

Pros:

  • โœ… Zero configuration issues
  • โœ… All ActiveAdmin features work perfectly
  • โœ… Battle-tested and stable

Cons:

  • โŒ Slower than Propshaft
  • โŒ More complex asset pipeline
  • โŒ Goes against Rails 8 defaults

Option 2: ActiveAdmin Assets Gem (Simplest alternative)

The activeadmin_assets gem was mentioned as a simple solution! Let me add this option:

# Add to Gemfile
gem 'activeadmin_assets'

Pros:

  • โœ… Zero Node.js dependency
  • โœ… Works with Propshaft
  • โœ… Pre-compiled ActiveAdmin assets
  • โœ… Simple one-line installation

Cons:

  • โŒ Can’t customize ActiveAdmin styles
  • โŒ Community-maintained (not official)

Option 3: ActiveAdmin 4.0 Beta + Propshaft โš ๏ธ (Experimental)

Based on the GitHub discussions, this is possible but complex:

# Rails 8 setup with Propshaft + ActiveAdmin v4 beta
rails new myapp -a propshaft
bundle add activeadmin -v=4.0.0beta15
bundle add cssbundling-rails -v "1.4.1"
bundle add tailwindcss-ruby -v "~> 3.4.17"
yarn add tailwindcss@^3.4.17  # ActiveAdmin uses TailwindCSS v3
rails generate active_admin:install
rails css:install:tailwind
yarn add @activeadmin/activeadmin@4.0.0-beta15
npm pkg set scripts.build:css="tailwindcss -i ./app/assets/stylesheets/active_admin.css -o ./app/assets/builds/active_admin.css --minify -c tailwind-active_admin.config.js"
./bin/dev  # Instead of rails server

Pros:

  • โœ… Modern asset pipeline (Propshaft)
  • โœ… ActiveAdmin v4 features

Cons:

  • โŒ Complex setup with many steps
  • โŒ Requires Node.js/Yarn (defeats the purpose)
  • โŒ Beta software (unstable)
  • โŒ TailwindCSS v3/v4 compatibility issues

Option 4: ๐Ÿ”„ Alternative Admin Gems

If you want to avoid the ActiveAdmin complexity entirely:

RailsAdmin:

gem 'rails_admin'
  • โœ… No asset pipeline issues
  • โœ… Works with Propshaft out of the box
  • โŒ Less customizable than ActiveAdmin

Trestle:

gem 'trestle'
  • โœ… Modern Bootstrap-based admin
  • โœ… Better Rails 8 compatibility
  • โŒ Smaller community

Adminterface (Deprecated):

  • ๐Ÿšซ No longer maintained (as shown in search results)

๐Ÿ† My Recommendation:

For our production app: Stick with Sprockets + ActiveAdmin 3.x

Why?

  1. Stability – Battle-tested combination
  2. Zero configuration issues
  3. Full feature set available
  4. Easy maintenance

The performance difference between Propshaft and Sprockets won’t significantly impact your admin interface, which is typically used by a small number of internal users.

๐Ÿ”ฎ Future Outlook:

  • ActiveAdmin v4 will eventually have better Propshaft support
  • Rails community is working on solutions
  • Node.js dependency trend for complex CSS is becoming standard anyway

Bottom Line: Don’t let the asset pipeline take over the application preferences. Our current Sprockets setup is perfectly fine for an admin interface! ๐ŸŽฏ

The Reality Check:

We completely replaced Propshaft with Sprockets for the entire application.

Current State:

  • โŒ Propshaft: Completely removed
  • โœ… Sprockets: Handles ALL assets (main app + admin)
  • โœ… Importmaps: Still working (unaffected)
  • โœ… Hotwire/Turbo: Still working (unaffected)

๐Ÿ—๏ธ How ActiveAdmin Works:

ActiveAdmin is NOT a Rails Engine. It integrates directly into your app:

  1. Routes: ActiveAdmin.routes(self) adds /admin routes to your app
  2. Assets: ActiveAdmin’s CSS/JS becomes part of your asset pipeline
  3. Controllers: ActiveAdmin generates controllers in your app’s namespace

It’s tightly coupled to your application, not isolated.

๐Ÿคทโ€โ™‚๏ธ So What Are Our Real Options?

Option 1: Accept Current Setup

What we have:

  • Modern Rails 8 with Importmaps + Hotwire โœ…
  • Sprockets instead of Propshaft โŒ (but works fine)
  • Full-featured admin interface โœ…

Option 2: Use activeadmin_assets Gem

# Replace current ActiveAdmin setup
gem 'activeadmin_assets'  # Pre-compiled assets
gem 'propshaft'           # Back to Rails 8 default
  • โœ… Get Propshaft back
  • โœ… Keep Rails 8 defaults
  • โŒ Can’t customize admin styles

Option 3: Build Custom Admin

Keep your Rails 8 purity but build admin from scratch:

  • โœ… Pure Rails 8
  • โŒ Weeks of development time

๐Ÿ” What activeadmin_assets Actually Does:

The activeadmin_assets gem doesn’t replace ActiveAdmin – it provides pre-compiled static assets for ActiveAdmin to work with Propshaft.

But here’s the critical issue for you:

๐Ÿšจ activeadmin_assets = NO Customization

If you use activeadmin_assets, you get:

  • โœ… ActiveAdmin functionality
  • โœ… Propshaft compatibility
  • โŒ ZERO ability to customize styles/interface

The assets are pre-compiled and frozen – you can’t modify colors, layouts, or styling.

Since We Want Customization:

We’re actually in the BEST setup possible right now!

Our Current Setup Gives You:

  • โœ… Full ActiveAdmin customization (styles, layouts, themes)
  • โœ… Modern Rails 8 (Importmaps, Hotwire, Turbo)
  • โœ… All ActiveAdmin features (filters, actions, forms)
  • โŒ Sprockets instead of Propshaft (minor trade-off)

Customization Examples We Can Do:

  • Custom CSS themes and colors
  • Layout modifications
  • Custom JavaScript behaviors
  • Brand styling and logos
  • Advanced form customizations

๐ŸŽฏ Reality Check:

There’s no solution that gives us:

  1. Full ActiveAdmin customization AND
  2. Propshaft AND
  3. Zero complexity

We have to pick 2 out of 3.

๐Ÿ’ก My Honest Last Decision:

Keep our current setup! We’re already using:

  • Modern Rails 8 patterns (95% of your codebase)
  • Importmaps and Hotwire (exactly as intended)
  • Sprockets only affects asset serving (invisible to users)

The Propshaft vs Sprockets difference won’t affect your product’s success, but losing admin customization will limit our UX.


Guide: Integrating React โš›๏ธ into a Railsย 8 Application โ€“ Partย 3 | Start developing react

Let’s move on to quick development of more react components now. Before that let’s check what we have now and understand it very clear.

๐Ÿ“„ File 1:

Our app/javascript/components/App.jsx file:

import React from 'react';

function App() {
  return (
    <div>
      <h1>React is working fine!</h1>
      <p>Welcome to Rails + React App</p>
    </div>
  );
}

export default App;

Let’s examine this React component step by step:

Line 1: Import React

import React from 'react';
  • import – ES6 module syntax to bring in external code
  • React – The main React library
  • from 'react' – Importing from the npm package named “react”
  • Why needed? Even though we use --jsx=automatic, we still import React for any hooks or React features we might use.

Function Component: Line 3-9

A React function component is a simple JavaScript function that serves as a building block for user interfaces in React applications. These components are designed to be reusable and self-contained, encapsulating a specific part of the UI and its associated logic.

function App() {
  return (
    <div>
      <h1>React is working fine!</h1>
      <p>Welcome to Rails + React App</p>
    </div>
  );
}

๐Ÿ” Breaking this down:

Line 3: Component Declaration

function App() {
  • function App() – This is a React Function Component
  • Component naming – Must start with capital letter (App, not app)
  • What it is – A JavaScript function that returns JSX (user interface)

Line 4-8: JSX Return

return (
  <div>
    <h1>React is working fine!</h1>
    <p>Welcome to Rails + React App</p>
  </div>
);
  • return – Every React component must return something
  • JSX – Looks like HTML, but it’s actually JavaScript
  • <div> – Must have one parent element (React Fragment rule)
  • <h1> & <p> – Regular HTML elements, but processed by React

Line 11: Export

export default App;
  • export default – ES6 syntax to make this component available to other files
  • App – The component name we’re exporting
  • Why needed? So application.js can import and use this component

๐Ÿ“„ File 2:

Our app/javascript/application.js file:

// Entry point for the build script in your package.json
import React from 'react';
import { createRoot } from 'react-dom/client';
import App from './components/App';

document.addEventListener('DOMContentLoaded', () => {
  const container = document.getElementById('react-root');

  if(container) {
    const root = createRoot(container);
    root.render(<App />);
  }
});

This is the entry point that connects React to your Rails app:

    Imports: Line 2-4

    import React from 'react';
    import { createRoot } from 'react-dom/client';
    import App from './components/App';
    

    ๐Ÿ” Breaking down each import:

    Line 2:

    import React from 'react';
    
    • Same as before – importing the React library

    Line 3:

    import { createRoot } from 'react-dom/client';
    
    • { createRoot }Named import (notice the curly braces)
    • react-dom/client – ReactDOM library for browser/DOM manipulation
    • createRoot – New React 18+ API for rendering components to DOM

    Line 4:

    import App from './components/App';
    
    • AppDefault import (no curly braces)
    • ./components/App – Relative path to our App component
    • Note: We don’t need .jsx extension, esbuild figures it out

    DOM Integration: Line 6-12

    document.addEventListener('DOMContentLoaded', () => {
      const container = document.getElementById('react-root');
    
      if(container) {
        const root = createRoot(container);
        root.render(<App />);
      }
    });
    

    ๐Ÿ” Step by step breakdown:

    Line 6:

    document.addEventListener('DOMContentLoaded', () => {
    
    • document.addEventListener – Standard browser API
    • 'DOMContentLoaded' – Wait until HTML is fully loaded
    • () => { – Arrow function (ES6 syntax)
    • Why needed? Ensures the HTML exists before React tries to find elements

    Line 7:

    const container = document.getElementById('react-root');
    
    • const container – Create a variable to hold the DOM element
    • document.getElementById('react-root') – Find HTML element with id="react-root"
    • Where is it? In your Rails view file: app/views/home/index.html.erb

    Line 9:

    if(container) {
    
    • Safety check – Only proceed if the element exists
    • Prevents errors – If someone visits a page without react-root element

    Line 10-11:

    const root = createRoot(container);
    root.render(<App />);
    
    • createRoot(container) – Create a React “root” at the DOM element
    • root.render(<App />) – Render our App component inside the container
    • <App /> – JSX syntax for using our component (self-closing tag)

    ๐ŸŽฏ Key React Concepts You Just Learned:

    1. Components

    • Functions that return JSX
    • Must start with capital letter
    • Reusable pieces of UI

    2. JSX

    • Looks like HTML, actually JavaScript
    • Must return single parent element
    • Processed by esbuild into regular JavaScript

    3. Import/Export

    • Default exports: export default App โ†’ import App from './App'
    • Named exports: export { createRoot } โ†’ import { createRoot } from 'package'

    4. React DOM

    • createRoot() – Modern way to mount React apps (React 18+)
    • render() – Display components in the browser

    5. Rails Integration

    • Rails serves the HTML page
    • React takes over the #react-root element
    • esbuild bundles everything together

    ๐Ÿš€ This pattern is the foundation of every React app! We create components, import them, and render them to the DOM.


    ๐Ÿ“š Step-by-Step React Learning with Todo List

    Now let’s build a Todo List app step by step. I’ll explain each React concept thoroughly as we go. Here’s our learning roadmap:

    Step 1: Understanding JSX and Basic Component Structure

    First, let’s update our App.jsx to create the basic structure of our Todo app:

    import React from 'react';
    
    function App() {
      return (
        <div className="todo-app">
          <h1>My Todo List</h1>
          <p>Let's learn React by building a todo app!</p>
    
          {/* This is a JSX comment */}
          <div className="todo-container">
            <h2>Add a new todo</h2>
            <input type="text" placeholder="Enter a todo..." />
            <button>Add Todo</button>
    
            <h2>My Todos</h2>
            <ul>
              <li>Learn React basics</li>
              <li>Build a todo app</li>
              <li>Master React hooks</li>
            </ul>
          </div>
        </div>
      );
    }
    
    export default App;
    

    ๐ŸŽฏ Key Concepts Explained:

    JSX (JavaScript XML):

    • JSX lets you write HTML-like syntax directly in JavaScript
    • It’s a syntax extension for JavaScript, not actual HTML
    • JSX gets compiled to JavaScript function calls
    • You can use {} to embed JavaScript expressions inside JSX

    Important JSX Rules:

    • Use className instead of class (because class is a reserved word in JavaScript)
    • You can use single quotes for className values in JSX. Both work perfectly fine:
    // Both of these are valid:
    <div className='todo-app'>    // Single quotes โœ…
    <div className="todo-app">    // Double quotes โœ…
    

    Quote Usage in JSX/JavaScript:

    Single quotes vs Double quotes:

    • JavaScript treats them identically
    • It’s mostly a matter of personal/team preference
    • The key is to be consistent throughout your project

    Common conventions:

    // Option 1: Single quotes for JSX attributes
    <div className='todo-app'>
      <input type='text' placeholder='Enter todo...' />
    </div>
    
    // Option 2: Double quotes for JSX attributes  
    <div className="todo-app">
      <input type="text" placeholder="Enter todo..." />
    </div>
    
    // Option 3: Mixed (but stay consistent within each context)
    const message = 'Hello World';  // Single for JS strings
    <div className="todo-app">      // Double for JSX attributes
    

    When you MUST use specific quotes:

    // When the string contains the same quote type
    <div className="It's a great day">        // Double quotes needed
    <div className='He said "Hello"'>        // Single quotes needed
    
    // Or use escape characters
    <div className='It\'s a great day'>       // Escaping single quote
    <div className="He said \"Hello\"">      // Escaping double quote
    

    ๐Ÿ’ก Tip: Many teams use tools like Prettier or ESLint to automatically format and enforce consistent quote usage across the entire project.

    • All tags must be closed (self-closing tags need / at the end)
    • JSX comments use {/* */} syntax
    • Return a single parent element (or use React Fragment <>...</>)

    Try updating our App.jsx with this code and see it in your browser!


    Step 2: Introduction to State with useState

    Now let’s add state to make our app interactive. State is data that can change over time.

    import React, { useState } from 'react';
    
    function App() {
      // useState Hook - creates state variable and setter function
      const [todos, setTodos] = useState([
        { id: 1, text: 'Learn React basics', completed: false },
        { id: 2, text: 'Build a todo app', completed: false },
        { id: 3, text: 'Master React hooks', completed: true }
      ]);
    
      const [inputValue, setInputValue] = useState('');
    
      return (
        <div className="todo-app">
          <h1>My Todo List</h1>
    
          <div className="todo-container">
            <h2>Add a new todo</h2>
            <input 
              type="text" 
              placeholder="Enter a todo..." 
              value={inputValue}
              onChange={(e) => setInputValue(e.target.value)}
            />
            <button>Add Todo</button>
    
            <h2>My Todos ({todos.length})</h2>
            <ul>
              {todos.map(todo => (
                <li key={todo.id}>
                  {todo.text} {todo.completed ? 'โœ…' : 'โณ'}
                </li>
              ))}
            </ul>
          </div>
        </div>
      );
    }
    
    export default App;
    

    ๐ŸŽฏ Key Concepts Explained:

    useState Hook:

    • useState is a React Hook that lets you add state to functional components
    • It returns an array with two elements: [currentValue, setterFunction]
    • const [todos, setTodos] = useState([]) creates a state variable todos and a function setTodos to update it
    • The initial value is passed as an argument to useState

    Controlled Components:

    • The input field is now “controlled” by React state
    • value={inputValue} makes the input show what’s in state
    • onChange={(e) => setInputValue(e.target.value)} updates state when user types

    Array.map() for Rendering Lists:

    • todos.map() transforms each todo into a JSX element
    • Each list item needs a unique key prop for React’s optimization
    • {todo.text} embeds the todo text using JSX expressions

    Try this code and notice how the input field now responds to typing!


    Step 3: Event Handling and Adding Todos

    Let’s make the “Add Todo” button work:

    import React, { useState } from 'react';
    
    function App() {
      const [todos, setTodos] = useState([
        { id: 1, text: 'Learn React basics', completed: false },
        { id: 2, text: 'Build a todo app', completed: false },
        { id: 3, text: 'Master React hooks', completed: true }
      ]);
    
      const [inputValue, setInputValue] = useState('');
    
      // Function to add a new todo
      const addTodo = () => {
        if (inputValue.trim() !== '') {
          const newTodo = {
            id: Date.now(), // Simple ID generation
            text: inputValue,
            completed: false
          };
    
          setTodos([...todos, newTodo]); // Spread operator to add new todo
          setInputValue(''); // Clear the input field
        }
      };
    
      // Function to handle Enter key press
      const handleKeyPress = (e) => {
        if (e.key === 'Enter') {
          addTodo();
        }
      };
    
      return (
        <div className="todo-app">
          <h1>My Todo List</h1>
    
          <div className="todo-container">
            <h2>Add a new todo</h2>
            <input 
              type="text" 
              placeholder="Enter a todo..." 
              value={inputValue}
              onChange={(e) => setInputValue(e.target.value)}
              onKeyPress={handleKeyPress}
            />
            <button onClick={addTodo}>Add Todo</button>
    
            <h2>My Todos ({todos.length})</h2>
            <ul>
              {todos.map(todo => (
                <li key={todo.id}>
                  {todo.text} {todo.completed ? 'โœ…' : 'โณ'}
                </li>
              ))}
            </ul>
          </div>
        </div>
      );
    }
    
    export default App;
    

    ๐ŸŽฏ Key Concepts Explained:

    Event Handlers:

    • onClick={addTodo} – function runs when button is clicked
    • onKeyPress={handleKeyPress} – function runs when key is pressed
    • Event handlers receive an event object (e) with information about the event

    State Updates:

    • setTodos([...todos, newTodo]) – creates a new array with all existing todos plus the new one
    • Important: Always create new arrays/objects instead of mutating existing ones
    • React compares old and new state to determine if re-render is needed

    Spread Operator (...):

    • ...todos spreads out all elements of the todos array
    • This is the React way to add items to an array in state

    Try adding new todos now!

    ๐ŸŽฏ Function Syntax Options in JavaScript:

    const addTodo = () => {} is a function syntax – specifically an arrow function. Let me explain why it’s declared as const and the different ways to write functions in JavaScript.

    1. Function Declaration (Traditional)
    function addTodo() {
      // function body
    }
    
    2. Function Expression with Arrow Function
    const addTodo = () => {
      // function body
    };
    
    3. Function Expression (Traditional)
    const addTodo = function() {
      // function body
    };
    

    ๐Ÿค” Why use const for functions?

    Arrow functions are expressions, not declarations:

    // This is a DECLARATION - creates a function named addTodo
    function addTodo() { }
    
    // This is an EXPRESSION - creates a function and assigns it to a variable
    const addTodo = () => { };
    

    Why const specifically?

    // โŒ Could be reassigned accidentally
    let addTodo = () => { };
    addTodo = "oops"; // Function is now gone!
    
    // โŒ Could be reassigned accidentally  
    var addTodo = () => { };
    addTodo = null; // Function is now gone!
    
    // โœ… Cannot be reassigned - prevents bugs
    const addTodo = () => { };
    addTodo = "something"; // ERROR: Assignment to constant variable
    

    ๐Ÿ“š Key Differences:

    Function Declaration vs Arrow Function:

    // Function Declaration
    function addTodo() {
      console.log("Adding todo");
    }
    
    // Arrow Function (assigned to const)
    const addTodo = () => {
      console.log("Adding todo");
    };
    

    Hoisting Behavior:

    // โœ… This works - function declarations are "hoisted"
    sayHello(); // "Hello!"
    
    function sayHello() {
      console.log("Hello!");
    }
    
    // โŒ This doesn't work - arrow functions are not hoisted
    sayGoodbye(); // Error: Cannot access 'sayGoodbye' before initialization
    
    const sayGoodbye = () => {
      console.log("Goodbye!");
    };
    

    this Binding:

    // Function declaration has its own 'this'
    function regularFunction() {
      console.log(this); // 'this' can change based on how it's called
    }
    
    // Arrow function inherits 'this' from surrounding scope
    const arrowFunction = () => {
      console.log(this); // 'this' is inherited from parent scope
    };
    

    ๐Ÿš€ In React Context:

    In React functional components, we typically use arrow functions with const because:

    1. Prevents accidental reassignment – our function won’t get overwritten
    2. Consistent with modern JavaScript – ES6+ standard
    3. Cleaner syntax – less verbose than traditional function expressions
    4. Better for event handlersthis behavior is more predictable

    All these are equivalent in React:

    // Option 1: Arrow function with const (most common)
    const addTodo = () => {
      if (inputValue.trim() !== '') {
        // ... logic
      }
    };
    
    // Option 2: Traditional function declaration
    function addTodo() {
      if (inputValue.trim() !== '') {
        // ... logic  
      }
    }
    
    // Option 3: Function expression with const
    const addTodo = function() {
      if (inputValue.trim() !== '') {
        // ... logic
      }
    };
    

    ๐Ÿ’ก Why React developers prefer arrow functions:

    1. Shorter syntax for simple functions
    2. Consistent variable declaration (everything uses const)
    3. No hoisting confusion – functions are defined before they’re used
    4. Better with modern tooling – ESLint, Prettier handle them well

    So yes, const addTodo = () => {} is definitely a function! It’s just a modern way to write functions that prevents accidental reassignment and has cleaner syntax.

    ๐ŸŽฏ What is Hoisting?

    Hoisting is a fundamental JavaScript concept that can be confusing at first. Let me explain it clearly with examples.

    Hoisting is JavaScript’s behavior of moving declarations to the top of their scope during the compilation phase, before the code is executed.

    Think of it like JavaScript “hoists” (lifts up) your variable and function declarations to the top of their scope.

    ๐Ÿ“š How Hoisting Works:

    Function Declarations are Hoisted:

    // This works even though we call the function before declaring it!
    sayHello(); // Outputs: "Hello!"
    
    function sayHello() {
      console.log("Hello!");
    }
    

    Behind the scenes, JavaScript treats it like this:

    // JavaScript internally reorganizes it like this:
    function sayHello() {
      console.log("Hello!");
    }
    
    sayHello(); // Now it makes sense!
    

    Variable Declarations (with var) are Hoisted:

    console.log(myVar); // Outputs: undefined (not an error!)
    var myVar = "Hello";
    console.log(myVar); // Outputs: "Hello"
    

    Behind the scenes:

    // JavaScript internally treats it like this:
    var myVar; // Declaration is hoisted to the top
    console.log(myVar); // undefined (declared but not assigned)
    myVar = "Hello"; // Assignment stays in place
    console.log(myVar); // "Hello"
    

    ๐Ÿšซ What is NOT Hoisted:

    let and const variables:

    // โŒ This throws an error!
    console.log(myLet); // ReferenceError: Cannot access 'myLet' before initialization
    let myLet = "Hello";
    
    // โŒ This also throws an error!
    console.log(myConst); // ReferenceError: Cannot access 'myConst' before initialization
    const myConst = "Hello";
    

    Arrow Functions (Function Expressions):

    // โŒ This throws an error!
    sayGoodbye(); // ReferenceError: Cannot access 'sayGoodbye' before initialization
    
    const sayGoodbye = () => {
      console.log("Goodbye!");
    };
    

    Function Expressions:

    // โŒ This throws an error!
    myFunc(); // TypeError: myFunc is not a function
    
    var myFunc = function() {
      console.log("Hello!");
    };
    

    ๐ŸŽญ Visual Examples:

    Example 1: Function Declaration Hoisting

    // What you write:
    console.log("1. Start");
    myFunction(); // This works!
    console.log("3. End");
    
    function myFunction() {
      console.log("2. Inside function");
    }
    
    // Output:
    // 1. Start
    // 2. Inside function  
    // 3. End
    

    Example 2: Variable Hoisting with var

    // What you write:
    console.log("Value is:", myVar); // undefined
    var myVar = 42;
    console.log("Value is:", myVar); // 42
    
    // What JavaScript internally does:
    var myVar; // Hoisted to top
    console.log("Value is:", myVar); // undefined
    myVar = 42; // Assignment stays here
    console.log("Value is:", myVar); // 42
    

    Example 3: Why let and const are different

    // Temporal Dead Zone
    console.log("Before declaration");
    
    // โŒ This area is the "Temporal Dead Zone" for myLet
    console.log(myLet); // ReferenceError!
    
    let myLet = "Hello"; // Declaration and initialization
    console.log(myLet); // "Hello"
    

    ๐ŸŽฏ Practical Implications:

    Why Function Declarations work everywhere:

    // This is valid JavaScript
    function main() {
      helper(); // Can call helper before it's defined
    
      function helper() {
        console.log("Helper function");
      }
    }
    

    Why Arrow Functions must be defined first:

    // โŒ This doesn't work
    const main = () => {
      helper(); // Error! helper is not defined yet
    
      const helper = () => {
        console.log("Helper function");
      };
    };
    
    // โœ… This works
    const main = () => {
      const helper = () => {
        console.log("Helper function");
      };
    
      helper(); // Now it works!
    };
    

    ๐Ÿ’ก Best Practices:

    1. Use const and let instead of var to avoid hoisting confusion
    2. Define functions before using them (even though function declarations are hoisted)
    3. Use arrow functions for consistency and to avoid hoisting surprises
    4. Initialize variables when you declare them

    ๐Ÿ”— Back to Our React Example:

    // This is why we use const for arrow functions
    const App = () => {
      // โœ… Good: Function defined before use
      const addTodo = () => {
        // function logic
      };
    
      // โŒ Bad: Would cause error if we tried to call it here
      // deleteTodo(); // Error!
    
      const deleteTodo = () => {
        // function logic
      };
    
      return (
        <div>
          <button onClick={addTodo}>Add</button>
          <button onClick={deleteTodo}>Delete</button>
        </div>
      );
    };
    

    Hoisting is JavaScript’s way of making function declarations available throughout their scope, but it doesn’t apply to modern variable declarations (let, const) or arrow functions. That’s why we define our functions with const and make sure to declare them before we use them!


    Step 4: Toggling Todo Completion

    Let’s add the ability to mark todos as complete/incomplete:

    import React, { useState } from 'react';
    
    function App() {
      const [todos, setTodos] = useState([
        { id: 1, text: 'Learn React basics', completed: false },
        { id: 2, text: 'Build a todo app', completed: false },
        { id: 3, text: 'Master React hooks', completed: true }
      ]);
    
      const [inputValue, setInputValue] = useState('');
    
      const addTodo = () => {
        if (inputValue.trim() !== '') {
          const newTodo = {
            id: Date.now(),
            text: inputValue,
            completed: false
          };
    
          setTodos([...todos, newTodo]);
          setInputValue('');
        }
      };
    
      // Function to toggle todo completion
      const toggleTodo = (id) => {
        setTodos(todos.map(todo => 
          todo.id === id 
            ? { ...todo, completed: !todo.completed } // Toggle completed status
            : todo // Keep todo unchanged
        ));
      };
    
      const handleKeyPress = (e) => {
        if (e.key === 'Enter') {
          addTodo();
        }
      };
    
      return (
        <div className="todo-app">
          <h1>My Todo List</h1>
    
          <div className="todo-container">
            <h2>Add a new todo</h2>
            <input 
              type="text" 
              placeholder="Enter a todo..." 
              value={inputValue}
              onChange={(e) => setInputValue(e.target.value)}
              onKeyPress={handleKeyPress}
            />
            <button onClick={addTodo}>Add Todo</button>
    
            <h2>My Todos ({todos.length})</h2>
            <ul>
              {todos.map(todo => (
                <li key={todo.id}>
                  <span 
                    onClick={() => toggleTodo(todo.id)}
                    style={{ 
                      textDecoration: todo.completed ? 'line-through' : 'none',
                      cursor: 'pointer',
                      color: todo.completed ? '#888' : '#000'
                    }}
                  >
                    {todo.text}
                  </span>
                  {todo.completed ? ' โœ…' : ' โณ'}
                </li>
              ))}
            </ul>
          </div>
        </div>
      );
    }
    
    export default App;
    

    ๐ŸŽฏ Key Concepts Explained:

    Array.map() for Updates:

    • todos.map() creates a new array where each todo is either updated or kept the same
    • todo.id === id ? { ...todo, completed: !todo.completed } : todo
    • This pattern is very common in React for updating items in arrays

    Conditional Rendering:

    • todo.completed ? 'โœ…' : 'โณ' – ternary operator for conditional display
    • textDecoration: todo.completed ? 'line-through' : 'none' – conditional styling

    Arrow Functions in JSX:

    • onClick={() => toggleTodo(todo.id)} – arrow function to pass arguments to event handlers
    • Without the arrow function, toggleTodo(todo.id) would execute immediately

    Now you can click on todos to toggle their completion status!


    ๐Ÿค” What we’ve learned so far:

    1. โœ… JSX syntax and rules
    2. โœ… useState hook for state management
    3. โœ… Event handling (onClick, onChange, onKeyPress)
    4. โœ… Controlled components
    5. โœ… Array mapping for rendering lists
    6. โœ… Conditional rendering
    7. โœ… State updates with spread operator

    Next Steps: In the following steps, we’ll cover:

    • Deleting todos
    • Component composition (breaking into smaller components)
    • Props passing
    • Filtering todos
    • More advanced state management

    Let’s see in Part 4. Happy React Development! ๐Ÿš€

    ๐Ÿ“ฆ Sprockets vs ๐Ÿงต Propshaft in Ruby on Rails 7/8 โ€“ What’s the Difference?

    When working with asset pipelines in Ruby on Rails 7 and 8, you might encounter Sprockets and Propshaftโ€”two asset handling libraries. While both aim to serve static assets like JavaScript, CSS, images, and fonts, they do so in different ways.

    This post will walk you through what each does, how they differ, and when you might want to use one over the other.


    ๐Ÿ“ฆ What is Sprockets?

    Sprockets is the original Rails asset pipeline system, introduced way back in Rails 3.1. It allows developers to:

    • Concatenate and minify JavaScript and CSS
    • Preprocess assets using things like SCSS, CoffeeScript, ERB, etc.
    • Fingerprint assets for cache busting
    • Compile assets at deploy time

    It works well for traditional Rails applications where the frontend and backend are tightly coupled.

    Pros:

    • Mature and stable
    • Rich preprocessing pipeline (SCSS, CoffeeScript, ERB, etc.)
    • Supports advanced directives like //= require_tree .

    Cons:

    • Complex internal logic
    • Slower compilation times
    • Relies on a manifest file that can get messy
    • Tightly coupled with older Rails asset practices

    ๐Ÿงต What is Propshaft?

    Propshaft is the newer asset pipeline introduced by the Rails team as an alternative to Sprockets. It focuses on simplicity and modern best practices. Propshaft was added as an optional asset pipeline starting in Rails 7 and is included by default in some new apps.

    Design Philosophy:
    Propshaft aims to work like a static file server with fingerprinting and logical path mapping, rather than a full asset compiler.

    Key Features:

    • Uses logical paths (e.g., /assets/application.css)
    • No preprocessing pipeline by default (but supports it via extensions like Tailwind or Sass)
    • Supports digesting (fingerprinting) of assets
    • Leaner and faster than Sprockets
    • Easier to integrate with modern JavaScript bundlers (like importmaps, esbuild, or webpack)

    Pros:

    • Lightweight and fast
    • Easier to debug
    • Works great with importmaps and Hotwire
    • Modern, forward-looking approach

    Cons:

    • No advanced preprocessing by default
    • Limited plugin ecosystem (still maturing)
    • Doesn’t support old Sprockets directives

    ๐Ÿ” Key Differences at a Glance

    FeatureSprocketsPropshaft
    Introduced InRails 3.1Rails 7
    Default in RailsRails 6 and earlierOptional from Rails 7+
    Preprocessing SupportYes (SCSS, ERB, CoffeeScript, etc.)No (only raw assets by default)
    SpeedSlowerFaster
    Configuration ComplexityHigherMinimal
    Plugin EcosystemLarge and matureNew and growing
    Use With Importmaps/HotwireCan work, but heavierIdeal
    DebuggingHarder due to complexityEasier

    ๐Ÿงฐ When Should You Use Sprockets?

    Choose Sprockets if:

    • You are upgrading a legacy Rails app
    • Your project already relies on Sprockets
    • You use heavy asset preprocessing
    • You need compatibility with gems that depend on Sprockets

    โšก When Should You Use Propshaft?

    Choose Propshaft if:

    • You are starting a new Rails 7/8 project
    • You use Importmaps or Hotwire/Turbo
    • You prefer faster and simpler asset handling
    • You don’t need complex preprocessing

    Propshaft pairs particularly well with modern frontend workflows like Tailwind CSS (via build tools) or StimulusJS (with importmaps).

    ๐Ÿ› ๏ธ Switching from Sprockets to Propshaft

    If you’re migrating, here are basic steps:

    1. Remove sprockets-rails gem from your Gemfile: # Gemfile # gem "sprockets-rails"
    2. Add propshaft: gem "propshaft"
    3. Update config/application.rb: config.assets.resolver = Propshaft::Resolver.new( paths: [Rails.root.join("app/assets")] )
    4. Remove app/assets/config/manifest.js (used by Sprockets)
    5. Move all assets to the correct logical paths under app/assets
    6. Use digested URLs as needed (asset_path("application.css") etc.)

    ๐Ÿงช Real Example in Rails 8

    Hereโ€™s how your application.html.erb might look using Propshaft:

    <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
    <%= javascript_include_tag "application", "data-turbo-track": "reload", defer: true %>
    
    

    And your app/assets/builds/application.css could be compiled via Tailwind or SCSS using a toolchain.


    ๐Ÿง  Final Thoughts

    Sprockets has served Rails well for over a decade, but Propshaft is the new lightweight future. If you’re starting fresh, Propshaft is a strong choice, especially when used alongside Hotwire, Importmaps, or modern JS bundlers.

    However, don’t feel pressured to switch if your current Sprockets setup works fineโ€”Rails continues to support both.


    โœจ TL;DR

    • Sprockets = older, feature-rich, best for legacy apps
    • Propshaft = newer, minimal, better for modern workflows

    Choose based on your app’s needs and complexity. Cheers! ๐Ÿš€

    Design Studio – Rails 8 e-commerce application: Integrate active admin

    Now let’s create an admin interface for our e-commerce Application.

    We have a well-structured e-commerce Rails application with:

    Models: User, Product, ProductVariant, Order, OrderItem
    Authentication: Custom session-based auth with user roles (customer/admin)
    Authorization: Already has admin role checking

    Admin Interface Recommendations

    Here are the best options for Rails admin interfaces, ranked by suitability for our project:

    1. ActiveAdmin (Recommended โญ)
      Best fit for e-commerce with complex associations
      Excellent filtering, search, and batch operations
      Great customization options and ecosystem
      Handles your Product โ†’ ProductVariant โ†’ OrderItem relationships well
    2. Administrate (Modern Alternative)
      Clean, Rails-way approach by Thoughtbot
      Good for custom UIs, less configuration
      More work to set up initially
    3. Rails Admin (What you asked about)
      Quick setup but limited customization
      Less actively maintained
      Good for simple admin needs
    4. Avo (Modern Premium)
      Beautiful modern UI
      Some features require paid version

      https://avohq.io/rails-admin
      https://docs.avohq.io/3.0/

    Choose ActiveAdmin for our e-commerce application. Let’s integrate it with our existing authentication system

    Add in Gemfile:

    gem "activeadmin"
    gem "sassc-rails" # Required for ActiveAdmin
    gem "image_processing", "~> 1.2" # For variant processing if not already present
    

    Bundle Install and run the Active Admin Generator:

    $ bundle install
    $ rails generate active_admin:install --skip-users
    definition of Rules was here
    create app/assets/javascripts/active_admin.js
    create app/assets/stylesheets/active_admin.scss
    create db/migrate/20250710083516_create_active_admin_comments.rb
    

    Migration File created by Active Admin:

    class CreateActiveAdminComments < ActiveRecord::Migration[8.0]
      def self.up
        create_table :active_admin_comments do |t|
          t.string :namespace
          t.text   :body
          t.references :resource, polymorphic: true
          t.references :author, polymorphic: true
          t.timestamps
        end
        add_index :active_admin_comments, [ :namespace ]
      end
    
      def self.down
        drop_table :active_admin_comments
      end
    end
    

    Run database migration:

    $ rails db:migrate
    

    in app/initializers/active_admin.rb

    # This setting changes the method which Active Admin calls
      # within the application controller.
      config.authentication_method = :authenticate_admin_user!
    ....
    # This setting changes the method which Active Admin calls
      # (within the application controller) to return the currently logged in user.
      config.current_user_method = :current_admin_user
    ....
     # Default:
      config.logout_link_path = :destroy_session_path
    

    in app/controllers/application_controller.rb

    private
    
      def authenticate_admin_user!
        require_authentication
        ensure_admin
      end
    
      def current_admin_user
        Current.user if Current.user&.admin?
      end
    

    Run the active admin user, product generator:

    rails generate active_admin:resource User
    rails generate active_admin:resource Product
    rails generate active_admin:resource ProductVariant
    rails generate active_admin:resource Order
    rails generate active_admin:resource OrderItem
    

    Let’s update all the active admin resources with fields, filters, attributes, panels etc.

    Let’s add accepts_nested_attributes_for :variants, allow_destroy: true in Product Model.

    accepts_nested_attributes_for is a Rails feature that allows a parent model to accept and process attributes for its associated child models through nested parameters. Here’s what it does:

    What it enables:

    1. Nested Forms: You can create/update a Product and its ProductVariants in a single form submission
    2. Mass Assignment: Allows passing nested attributes through strong parameters
    3. CRUD Operations: Create, update, and delete associated records through the parent

    In our Product model

    class Product < ApplicationRecord
      has_many :variants, dependent: :destroy, class_name: "ProductVariant"
      accepts_nested_attributes_for :variants, allow_destroy: true
    end
    

    What this allows:

    Before: You’d need separate forms/requests for Product and ProductVariant

    # Create product first
    product = Product.create(name: "T-Shirt", brand: "Nike")
    
    # Then create variants separately
    product.variants.create(size: "M", color: "Red", sku: "NIKE-001-M-RED")
    product.variants.create(size: "L", color: "Blue", sku: "NIKE-001-L-BLUE")
    

    After: You can do it all in one go:

    Product.create(
      name: "T-Shirt",
      brand: "Nike",
      variants_attributes: [
        { size: "M", color: "Red", sku: "NIKE-001-M-RED" },
        { size: "L", color: "Blue", sku: "NIKE-001-L-BLUE" }
      ]
    )
    

    In ActiveAdmin context:

    This enables the nested form I created in app/admin/products.rb:

    f.inputs "Product Variants" do
      f.has_many :variants, allow_destroy: true, new_record: true do |v|
        v.input :size
        v.input :color
        v.input :sku
        # ... other variant fields
      end
    end
    
    The allow_destroy: true option:
    • Allows deleting associated records by passing _destroy: ‘1’
    • Essential for updating existing products with variants
    Strong Parameters:

    In the controller, you need to permit the nested attributes:

    permit_params :name, :brand, :category, 
                  variants_attributes: [:id, :size, :color, :sku, :mrp, :discount_percent, :_destroy]
    

    Propshaft vs Sprockets

    We have an error after integrating ActiveAdmin:

     Rendered layout layouts/application.html.erb (Duration: 13.9ms | GC: 0.5ms)
    Completed 500 Internal Server Error in 118ms (ActiveRecord: 28.7ms (1 query, 0 cached) | GC: 27.0ms)
    
    ActionView::Template::Error (undefined method 'load_path' for an instance of Sprockets::Environment)
    Caused by: NoMethodError (undefined method 'load_path' for an instance of Sprockets::Environment)
    
    Information for: ActionView::Template::Error (undefined method 'load_path' for an instance of Sprockets::Environment):
        14:     <link rel="icon" href="/icon.svg" type="image/svg+xml">
        15:     <link rel="apple-touch-icon" href="/icon.png">
    

    This error occurs because Rails 8 uses Propshaft by default, but ActiveAdmin requires Sprockets. There’s a conflict between the two asset pipelines. Let’s fix this by switching to Sprockets completely:

    Add in Gemfile:

    # Use Sprockets for asset pipeline (required for ActiveAdmin)
    gem "sprockets-rails"
    
    $ bundle install
    
    # application.rb
    
    # Use Sprockets for asset pipeline
    config.assets.enabled = true
    
    

    Let’s create an admin user for testing our ActiveAdmin:

    # seed.rb
    # Create admin user
    admin_user = User.find_or_create_by(email: "admin@designstudio.com") do |user|
      user.password = "password123"
      user.role = "admin"
    end
    
    puts "Admin user created: #{admin_user.email}" if admin_user.persisted?
    
    # run seed
    โœ— rails db:seed
    

    โœ… We have Successfully Integrated ActiveAdmin with Nested Attributes!

    I’ve successfully converted your Rails application to use nested attributes (variants_attributes) across the entire stack and integrated ActiveAdmin as your admin interface. Here’s what I accomplished:

    ๐Ÿ”ง What I Fixed & Updated:

    1. Asset Pipeline Issue:

    • Switched from Propshaft to Sprockets (required for ActiveAdmin)
    • Updated Gemfile and application configuration
    • Fixed the load_path error you encountered

    2. Model Changes:

    • Added accepts_nested_attributes_for :variants, allow_destroy: true to Product model

    3. Controller Updates:

    • Changed from singular product_variant to plural variants_attributes
    • Simplified create/update actions to use Rails nested attributes handling
    • Updated strong parameters to permit nested variant attributes

    4. View Improvements:

    • Updated product form to handle multiple variants with JavaScript
    • Added “Add Another Variant” functionality
    • Implemented proper nested form fields for all variant attributes

    5. ActiveAdmin Configuration:

    • Set up authentication using your existing user system
    • Created comprehensive admin resources for all models
    • Added nested variant management in product forms
    • Built an informative dashboard with key metrics

    ๐ŸŽฏ Admin Features You Now Have:

    1. Dashboard – Key metrics, recent activity, revenue overview
    2. User Management – Role management, order history
    3. Product Management – Full CRUD with image uploads and nested variants
    4. Order Management – Status updates, batch actions
    5. Inventory Management – ProductVariant and OrderItem tracking

    ๐Ÿ”‘ Admin Access:

    • URL: http://localhost:3000/admin
    • Email: admin@designstudio.com
    • Password: password123

    ๐Ÿ“Š Comparison vs Rails Admin:

    ActiveAdmin Advantages:

    • โœ… Better for complex e-commerce relationships
    • โœ… Excellent filtering and search capabilities
    • โœ… Powerful batch operations
    • โœ… Highly customizable forms and views
    • โœ… Active development and community

    Rails Admin:

    • โŒ Less actively maintained
    • โŒ Limited customization options
    • โŒ Not as suitable for complex nested relationships

    ๐Ÿš€ What’s Working Now:

    1. Nested Variants: Create/edit products with multiple variants in one form
    2. Image Management: Upload multiple product images
    3. Order Processing: Admin can update order statuses with batch actions
    4. User Management: Promote users to admin, view order history
    5. Dashboard Analytics: Revenue tracking, popular products, recent activity

    Visit /admin to explore your new admin interface, Cheers!

    Check docs: https://activeadmin.info/0-installation.html

    โš ๏ธ Tailwind UI Issue after moving to Sprockets

    The issue is with <%= stylesheet_link_tag "/tailwind/application.css", "data-turbo-track": "reload" %> where we are trying to load Tailwind CSS from an incorrect path. The tailwindcss-rails gem serves Tailwind CSS differently. Let me fix this:

    ls -la app/assets/tailwind/
    
    curl -I http://localhost:3000/tailwind/application.css
    
    rails routes | grep -i tailwind
    

    The issue is that the /tailwind/application.css path is returning 404. The tailwindcss-rails gem works differently. Let’s check how it’s configured and fix this:

    find . -name "*.css" -path "*/public/*" | head -10
    
    rails assets:precompile
    

    Now let’s test if the Tailwind CSS is properly accessible:

    curl -I http://localhost:3000/assets/tailwind/application.css
    

    The issue is that with the tailwindcss-rails gem, Tailwind CSS gets compiled into the main application.css file rather than a separate file. Let’s check the current asset manifest and fix the layout:

    rails assets:precompile
    
    bundle exec tailwindcss build
    

    The Tailwind CSS is already built and located in app/assets/builds/tailwind.css. Let’s fix the configuration:

    rails assets:precompile
    
    curl -I http://localhost:3000/assets/tailwind.css
    

    The issue has been fixed. The Tailwind CSS is now properly accessible. Here’s what I did to resolve the problem:

    1. Identified the issue: The Tailwind CSS was being incorrectly referenced as /tailwind/application.css in the layout file, but it wasn’t being served properly through the asset pipeline.
    2. Found the correct location: Discovered that the tailwindcss-rails gem builds the CSS file to app/assets/builds/tailwind.css.
    3. Fixed the asset pipeline configuration:
    • Updated app/assets/config/manifest.js to include //= link_tree ../builds which includes the built Tailwind CSS
    • Removed the incorrect tailwind/application.css reference
    1. Updated the layout file:
    • Changed from <%= stylesheet_link_tag "/tailwind/application.css" %>
    • To the correct <%= stylesheet_link_tag "tailwind", "data-turbo-track": "reload" %>
    1. Verified the fix:
    • Successfully precompiled assets
    • Confirmed that http://localhost:3000/assets/tailwind.css returns a 200 OK status

    Install Rails Icons ๐Ÿ

    For better font-awesome assets, lets add rails-icons gem:

    # Rails Icons - Modern icon library support
    gem "rails_icons"
    
    $ rails generate rails_icons:install --help
    $ rails generate rails_icons:install --libraries=heroicons
    
    $ npm install @fortawesome/fontawesome-free
    

    How to migrate from the CDN to Rails Icons

    For a production Rails application, it’s generally better to use a gem rather than a CDN for Font Awesome. Here’s why:

    CDN Issues:

    • External dependency (can fail if CDN is down)
    • Privacy concerns (external requests)
    • No version control
    • Requires internet connection
    • Not cacheable with your assets

    Gem Benefits:

    • Self-hosted (no external dependencies)
    • Better performance (served with your assets)
    • Version control
    • Works offline
    • Follows Rails conventions
    • Better for production

    Based on my research, adding a gem is definitely the better approach for a production Rails application. Here are the best options:

    Option 1: Modern approach with npm package (Recommended)

    Since we’re using Rails 8 with modern tooling, I’d recommend using the npm package:

    yarn add @fortawesome/fontawesome-free
    
    npm --version
    
    which node
    

    However, given your current setup, I’d recommend Option 2 which is more Rails-native:

    Option 2: Rails Icons gem (Recommended for our setup)

    This is a modern gem that supports Font Awesome and other icon libraries with a clean Rails API:

    bundle install
    
    rails generate rails_icons:install --help
    
    rails generate rails_icons:install --libraries=heroicons
    

    Let’s remove the CDN and set up Rails Icons properly. Since we’re already using Font Awesome extensively, let’s add Font Awesome as a custom library:

    rails generate rails_icons:initializer --custom=fontawesome
    
    npm install @fortawesome/fontawesome-free
    
    find node_modules/@fortawesome/fontawesome-free -name "*.svg" -type f | head -5
    
    cp -r node_modules/@fortawesome/fontawesome-free/svgs/* app/assets/svg/icons/fontawesome/
    

    โœ… Rails Icons is now properly set up instead of the CDN. Here’s what we’ve accomplished:

    What’s Changed:

    1. โœ… Removed the Font Awesome CDN link (no more external dependency)
    2. โœ… Added rails_icons gem with Heroicons as the default library
    3. โœ… Added Font Awesome as a custom library with all variants (solid, regular, brands)
    4. โœ… Self-hosted all icons through Rails asset pipeline

    What is Ransack?

    Ransack is a popular Ruby gem that provides powerful search and filtering capabilities for Rails applications. It’s commonly used with ActiveAdmin (which your app uses) to create searchable, filterable, and sortable data tables.

    Ransackable Attributes and Associations

    ransackable_attributes

    This method defines which model attributes can be searched, filtered, or sorted through Ransack. It’s a security feature that prevents unauthorized access to sensitive data.

    def self.ransackable_attributes(auth_object = nil)
      %w[id email role created_at updated_at password_reset_token password_reset_sent_at]
    end
    

    What it does:

    • Allows searching/filtering by id, email, role, created_at, updated_at, etc.
    • Prevents searching by sensitive fields like password_digest
    • The auth_object parameter can be used for role-based access control

    ransackable_associations

    This method defines which model associations can be used in Ransack queries.

    def self.ransackable_associations(auth_object = nil)
      %w[orders sessions]
    end
    

    What it does:

    • Allows searching/filtering by related models (e.g., “users who have orders”)
    • Enables joins and complex queries across associations

    Why This Matters for Security

    Without these methods, Rails 7+ will raise warnings or errors because Ransack needs explicit permission to search certain fields. This prevents:

    1. Information disclosure – Searching sensitive fields like passwords
    2. Performance issues – Searching on unindexed or inappropriate fields
    3. Unauthorized access – Accessing data through unexpected query parameters

    Example Usage in ActiveAdmin

    In your ActiveAdmin dashboard, this enables features like:

    # In app/admin/users.rb
    ActiveAdmin.register User do
      # Users can now search by email, role, etc.
      # Users can filter by orders, sessions
      # Users can sort by created_at, updated_at
    end
    

    In Our User Model

    Looking at your User model:

    def self.ransackable_attributes(auth_object = nil)
      %w[id email role created_at updated_at password_reset_token password_reset_sent_at]
    end
    
    def self.ransackable_associations(auth_object = nil)
      %w[orders sessions]
    end
    

    This means:

    • โœ… Admins can search users by email, role, creation date
    • โœ… Admins can filter users by their orders or sessions
    • โœ… Admins can sort users by any of these attributes
    • โŒ Admins cannot search by password_digest (secure)
    • โŒ Admins cannot search by other sensitive fields

    Benefits

    1. Security: Explicit control over what can be searched
    2. Performance: Only searchable fields need indexes
    3. Usability: Provides powerful admin interface features
    4. Compliance: Helps meet data protection requirements

    Migration Guide:

    Old CDN way:

    <i class="fas fa-user"></i>          <!-- solid -->
    <i class="far fa-star"></i>          <!-- regular -->
    <i class="fab fa-facebook-f"></i>    <!-- brands -->
    

    New Rails Icons way:

    <%= icon "user", library: "fontawesome", variant: "solid", class: "size-6" %>
    <%= icon "star", library: "fontawesome", variant: "regular", class: "size-6" %>
    <%= icon "facebook-f", library: "fontawesome", variant: "brands", class: "size-6" %>
    

    Or use modern Heroicons (recommended for new icons):

    <%= icon "user", class: "size-6" %>
    <%= icon "star", class: "size-6" %>
    <%= icon "heart", class: "size-6" %>
    

    Benefits:

    • โœ… No external dependencies (works offline)
    • โœ… Better performance (served with your assets)
    • โœ… Version control over icons
    • โœ… Multiple libraries (Font Awesome + Heroicons + more)
    • โœ… Consistent API across all icon libraries
    • โœ… Better for production and privacy

    Our application should work exactly the same now, but with better performance and no external dependencies! You can gradually migrate icons from <i class="fas fa-x"> to <%= icon "x", library: "fontawesome", variant: "solid" %> as needed.

    Asset Issue Again

    โœ… Final Fix Applied:

    Added //= link_tree ../../javascript .js to the manifest, which tells Sprockets to include all JavaScript files from the app/javascript directory tree.

    โœ… What’s Working Now:

    1. All CSS files properly compiled:
    • tailwind.css โœ…
    • application.css โœ…
    • active_admin.css โœ…
    1. All JavaScript files properly compiled:
    • application.js โœ…
    • controllers/application.js โœ…
    • controllers/decorative_background_controller.js โœ…
    • controllers/hello_controller.js โœ…
    • controllers/index.js โœ…
    1. Asset Pipeline fully functional:
    • Sprockets serving all assets correctly
    • Proper cache headers and ETags
    • All asset links declared in manifest

    ๐ŸŽฏ Current Status:

    Our Rails application now has:

    • โœ… Modern asset pipeline with Sprockets + Importmap
    • โœ… Tailwind CSS for styling (self-hosted)
    • โœ… ActiveAdmin for administration
    • โœ… Rails Icons instead of Font Awesome CDN
    • โœ… Stimulus controllers for JavaScript functionality
    • โœ… All assets properly precompiled and served

    Check Github: Active admin changes

    ๐Ÿ“‹ Clean Commit History Summary:

    1. โœ… Phase 1: feat: Add Rails Icons gem with Font Awesome and Heroicons support #94
    • Replaced Font Awesome CDN with self-hosted icons
    • Added Rails Icons with unified API for multiple icon libraries
    1. โœ… Phase 2: task: Migrate from Propshaft to Sprockets asset pipeline #96
    • Switched from Propshaft to Sprockets for ActiveAdmin compatibility
    • Fixed asset compilation and linking issues
    1. โœ… Phase 3: feat: Integrate ActiveAdmin for comprehensive admin interface #94
    • Complete ActiveAdmin setup with authentication
    • Full admin resources for all e-commerce models
    1. โœ… Phase 4: fix: Resolve ActiveAdmin PostgreSQL and Ransack security issues #94
    • Fixed PostgreSQL GROUP BY errors in dashboard
    • Added Ransack security configuration for all models

    ๐Ÿš€ Our ActiveAdmin is now fully functional!

    You should now be able to:

    • โœ… Access the admin dashboard at localhost:3000/admin
    • โœ… View analytics and statistics without GROUP BY errors
    • โœ… Search and filter all resources safely with Ransack
    • โœ… Manage Users, Products, Variants, Orders, and Order Items
    • โœ… Use nested attributes for product variants
    • โœ… Perform batch operations and advanced filtering

    Test it out: Visit localhost:3000/admin and log in with your admin credentials to see the beautiful, fully-functional admin interface! ๐ŸŽฏ

    to be continued ๐Ÿš€…

    Guide: Integrating React.js โš›๏ธ into a Railsย 8 Application โ€“ Partย 2: Install React | Add esbuild, Jsx | Integrate React View

    Throw back:

    rails new design_studio_react --database=postgresql -j esbuild --skip-hotwire
    

    Here’s what our Rails app looks like after skipping Hotwire with the --skip-hotwire flag:

    โœ… Current JavaScript/Node.js Setup (Clean & Minimal)

    ๐Ÿ“ฆ Package Management:

    • package.json – Clean setup with only esbuild script
    • .node-version – Node.js version 24.1.0
    • No dependencies – Ready for React installation

    ๐Ÿ“ JavaScript File Structure (Ultra-Clean):

    app/javascript/
    โ””โ”€โ”€ application.js          # Empty entry point (2 lines total!)
    

    app/javascript/application.js content:

    // Entry point for the build script in your package.json
    

    ๐Ÿšซ What Got Successfully Removed:

    • โŒ No Turbo/Stimulus imports in application.js
    • โŒ No controllers/ directory at all
    • โŒ No Hotwire gems in Gemfile (only jsbundling-rails remains)
    • โŒ No @hotwired/turbo-rails or @hotwired/stimulus dependencies

    โš™๏ธ Configuration Files (Minimal – Only 4):

    1. package.json – esbuild build script only
    2. .node-version – Node.js version pinning
    3. Procfile.dev – Development processes (js: yarn build --watch)
    4. app/javascript/application.js – Empty entry point

    ๐Ÿ”ง esbuild Configuration:

    {
      "scripts": {
        "build": "esbuild app/javascript/*.* --bundle --sourcemap --format=esm --outdir=app/assets/builds --public-path=/assets"
      }
    }
    

    ๐Ÿ“‚ Build Output:

    • app/assets/builds/ – Contains only .keep file (empty, ready for bundles)

    ๐ŸŽฏ HTML Integration:

    <!-- Still includes the JavaScript module correctly -->
    <%= javascript_include_tag "application", "data-turbo-track": "reload", type: "module" %>
    

    (Note: data-turbo-track is just an HTML attribute for cache busting, not the Turbo library)

    # create db
    โœ— rails db:migrate
    
    # run react-rails-app in port 3001
    โœ— rails s -p 3001
    

    ๐Ÿš€ Next Steps: Install & Setup React

    Step 1: Install react, react-dom

    Your app is now perfectly clean for React! Just run:

    brew install yarn
    yarn add react react-dom # check node_modules/ folder for what is installed
    yarn add --dev @types/react @types/react-dom  # Optional: for TypeScript support, check node_modules/@types folder
    

    Status: โœ… Minimal JavaScript foundation – No Hotwire bloat, perfect React starting point!

    Now that we have a clean Rails app with esbuild setup, here’s our step-by-step roadmap to get React working:

    Step 2: Create Your First React Component

    Create a simple React component to test the setup:

    mkdir app/javascript/components
    

    Then create app/javascript/components/App.jsx:

    import React from 'react';
    
    function App() {
      return (
        <div>
          <h1>React is Working!</h1>
          <p>Welcome to your Rails + React app</p>
        </div>
      );
    }
    
    export default App;
    

    Step 3: Update JavaScript Entry Point

    Modify app/javascript/application.js to render React:

    // Entry point for the build script in your package.json
    import React from 'react';
    import { createRoot } from 'react-dom/client';
    import App from './components/App';
    
    document.addEventListener('DOMContentLoaded', () => {
      const container = document.getElementById('react-root');
      if (container) {
        const root = createRoot(container);
        root.render(<App />);
      }
    });
    

    Step 4: Create a Controller & Route

    Generate a home controller:

    rails generate controller Home index
    

    Step 5: Add React Root to View

    Update app/views/home/index.html.erb:

    <div id="react-root"></div>
    

    Step 6: Set Root Route

    Update config/routes.rb:

    Rails.application.routes.draw do
      root "home#index"
      # ... other routes
    end
    

    Step 7: Start Development

    # update Procfile.dev assign port 3001
    web: env RUBY_DEBUG_OPEN=true bin/rails server -p 3001
    
    # run our rails-react app by
    
    โœ— bin/dev
    21:15:27 web.1  | started with pid 12619
    21:15:27 js.1   | started with pid 12620
    21:15:27 js.1   | yarn run v1.22.22
    21:15:27 js.1   | $ esbuild app/javascript/*.* --bundle --sourcemap --format=esm --outdir=app/assets/builds --public-path=/assets --watch
    21:15:27 js.1   | /bin/sh: esbuild: command not found
    21:15:27 js.1   | error Command failed with exit code 127.
    21:15:27 js.1   | info Visit https://yarnpkg.com/en/docs/cli/run for documentation about this command.
    21:15:27 web.1  | => Booting Puma
    ..........
    

    ๐ŸŽฏ What This Gives Us:

    • โœ… React components in app/javascript/components/
    • โœ… esbuild automatically bundles JSX
    • โœ… Hot reloading with yarn build --watch
    • โœ… Rails serves your React app

    ๐Ÿšจ Error Analysis:

    1. Rails serverย started fineย โœ… (port 3001)

    ๐Ÿ”ง Solution: Install JavaScript Dependencies

    You need to install esbuild and other JavaScript dependencies first:

    yarn install
    

    ๐Ÿ“‹ Files yarn install Checks:

    1. Primary: package.json

    {
      "name": "app",
      "private": true,
      "scripts": {
        "build": "esbuild app/javascript/*.* --bundle --sourcemap --format=esm --outdir=app/assets/builds --public-path=/assets"
      },
      "dependencies": {
        "react": "^19.1.0",           # โœ… Already added
        "react-dom": "^19.1.0"       # โœ… Already added  
      },
      "devDependencies": {
        "@types/react": "^19.1.8",      # โœ… Already added
        "@types/react-dom": "^19.1.6"   # โœ… Already added
      }
    }
    

    2. Lockfile: yarn.lock

    • Ensures exact versions for reproducible installs
    • Contains resolved URLs and integrity hashes

    ๐Ÿšจ The Problem: Missing esbuild!

    Our package.json calls esbuild in the build script, but esbuild is not listed as a dependency anywhere!

    Rails should have added it, but didn’t. We need to add esbuild:

    yarn add --dev esbuild
    

    ๐Ÿ”ง What yarn install does:

    1. Reads package.json for dependency list
    2. Reads yarn.lock for exact versions (if exists)
    3. Downloads packages to node_modules/
    4. Updates yarn.lock with new resolutions

    Current issue: Our build script expects esbuild command, but it’s not installed in node_modules/.bin/esbuild

    Solution: Add esbuild as a dev dependency!

    Solved~ and start servers: Error Again!

    x bin/dev
    
    * Listening on http://[::1]:3001
    22:54:43 web.1  | Use Ctrl-C to stop
    22:54:44 js.1   | โœ˜ [ERROR] The JSX syntax extension is not currently enabled
    22:54:44 js.1   |
    22:54:44 js.1   |     app/javascript/application.js:11:16:
    22:54:44 js.1   |       11 โ”‚     root.render(<App />);
    22:54:44 js.1   |          โ•ต                 ^
    22:54:44 js.1   |
    22:54:44 js.1   |   The esbuild loader for this file is currently set to "js" but it must be set to "jsx" to be able to parse JSX syntax. You can use "--loader:.js=jsx" to do that.
    22:54:44 js.1   |
    22:54:44 js.1   | 1 error
    22:54:44 js.1   | [watch] build finished, watching for changes..
    

    This error occurs because esbuild doesn’t know how to handle JSX syntax! The <App /> is JSX, but esbuild needs to be configured to transform it.

    ๐Ÿšจ Problem: esbuild can’t process JSX syntax

    Your application.js contains JSX (<App />), but esbuild isn’t configured to transform JSX!

    JSX (JavaScript XML) is a syntax extension for JavaScript, commonly used with React, that allows you to write HTML-like code within JavaScript files.

    ๐Ÿ”ง Solution: Configure esbuild for JSX

    Update your package.json build script to handle JSX:

    # add this to build
    --jsx=automatic --loader:.js=jsx 
    

    โœ… Fixed! Added JSX support:

    What I added:

    • --jsx=automatic – Enables React’s automatic JSX runtime
    • --loader:.js=jsx – Treats .js files as JSX files

    ๐Ÿ“ What this means:

    • โœ… esbuild can now process <App /> syntax
    • โœ… You don’t need to import React in every JSX file
    • โœ… Your .js files can contain JSX
    bin/dev
    

    Whola!!

    Let’s see in Part 3. Happy React configuration! ๐Ÿš€

    Rails 8 App: Create an Academic software app using SQL without using ActiveRecord – Part 1 | users | products | orders

    Let’s create a Rails 8 app which use SQL queries with raw SQL instead of ActiveRecord. Let’s use the full Rails environment with ActiveRecord for infrastructure, but bypass AR’s ORM features for pure SQL writing. Let me guide you through this step by step:

    Step 1: Create the Rails App with ActiveRecord and PostgreSQL (skipping unnecessary components)

    rails new academic-sql-software --database=postgresql --skip-action-cable --skip-jbuilder --skip-solid --skip-kamal
    

    What we’re skipping and why:

    • –skip-action-cable: No WebSocket functionality needed
    • –skip-jbuilder: No JSON API views needed for our SQL practice app
    • –skip-solid: Skips Solid Cache and Solid Queue (we don’t need caching or background jobs)
    • –skip-kamal: No deployment configuration needed

    What we’re keeping:

    • ActiveRecord: For database connection management and ActiveRecord::Base.connection.execute()
    • ActionController: For creating web interfaces to display our SQL query results
    • ActionView: For creating simple HTML pages to showcase our SQL learning exercises
    • PostgreSQL: Our database for practicing advanced SQL features

    Why this setup is perfect for App with raw SQL:

    • Minimal Rails app focused on database interactions
    • Full Rails environment for development conveniences
    • ActiveRecord infrastructure without ORM usage
    • Clean setup without unnecessary overhead

    => Open config/application.rb and comment the following for now:

    # require "active_job/railtie"
    ...
    # require "active_storage/engine"
    ...
    # require "action_mailer/railtie"
    # require "action_mailbox/engine"
    ...
    # require "action_cable/engine"
    

    => Open config/environments/development.rb config/environments/production.rb config/environments/test.rb comment action_mailer

    ๐Ÿค” Why I am using ActiveRecord (even though I don’t want the ORM):

    • Database Connection Management: ActiveRecord provides robust connection pooling, reconnection handling, and connection management
    • Rails Integration: Seamless integration with Rails console, database tasks (rails db:create, rails db:migrate), and development tools
    • Raw SQL Execution: We get ActiveRecord::Base.connection.execute() which is perfect for our raw SQL writing.
    • Migration System: Easy table creation and schema management with migrations (even though we’ll query with raw SQL)
    • Database Configuration: Rails handles database.yml configuration, environment switching, and connection setup
    • Development Tools: Access to Rails console for testing queries, database tasks, and debugging

    Our Learning Strategy: We’ll use ActiveRecord’s infrastructure but completely bypass its ORM methods. Instead of Student.where(), we’ll use ActiveRecord::Base.connection.execute("SELECT * FROM students WHERE...")

    Step 2: Navigate to the project directory

    cd academic-sql-software
    

    Step 3: Verify PostgreSQL setup

    # Check if PostgreSQL is running
    brew services list | grep postgresql
    # or
    pg_ctl status
    

    Database Foundation: PostgreSQL gives us advanced SQL features:

    • Complex JOINs (INNER, LEFT, RIGHT, FULL OUTER)
    • Window functions (ROW_NUMBER, RANK, LAG, LEAD)
    • Common Table Expressions (CTEs)
    • Advanced aggregations and subqueries

    Step 4: Install dependencies

    bundle install
    

    What this gives us:

    • pg gem: Pure PostgreSQL adapter (already included with --database=postgresql)
    • ActiveRecord: For connection management only
    • Rails infrastructure: Console, generators, rake tasks

    Step 5: Create the PostgreSQL databases

    โœ— rails db:create
    Created database 'academic_sql_software_development'
    Created database 'academic_sql_software_test
    

    Our Development Environment:

    • Creates academic_sql_software_development and academic_sql_software_test
    • Sets up connection pooling and management
    • Enables us to use Rails console for testing queries: rails console then ActiveRecord::Base.connection.execute("SELECT 1")

    Our Raw SQL Approach:

    # We'll use this pattern throughout our app:
    connection = ActiveRecord::Base.connection
    result = connection.execute("SELECT s.name, t.subject FROM students s INNER JOIN teachers t ON s.teacher_id = t.id")
    

    Why not pure pg gem:

    • Would require manual connection management
    • No Rails integration (no console, no rake tasks)
    • More boilerplate code for connection handling
    • Loss of Rails development conveniences

    Why not pure ActiveRecord ORM:

    • We want to do SQL query writing, not ActiveRecord methods.
    • Need to understand database performance implications.
    • Want to practice complex queries that might be harder to express in ActiveRecord.

    Step 6: Create Users table

    mkdir -p db/migrate
    
    class CreateUsers < ActiveRecord::Migration[8.0]
      def up
        # create users table
        execute <<~SQL
          CREATE TABLE users (
            id INT,
            username VARCHAR(200),
            email VARCHAR(150),
            phone_number VARCHAR(20)
          );
        SQL
      end
    
      def down
        execute <<~SQL
          DROP TABLE users;
        SQL
      end
    end
    
    
    class CreateOrders < ActiveRecord::Migration[8.0]
      def up
        # create table orders
        execute <<~SQL
        SQL
      end
    
      def down
        execute <<~SQL
        SQL
      end
    end
    
    

    execute <<~SQL is a Rails migration method that allows you to run raw SQL statements. Let me break it down:

    Components:

    1. execute – A Rails migration method that executes raw SQL directly against the database
    2. <<~SQL – Ruby’s “squiggly heredoc” syntax for multi-line strings that automatically strips leading whitespace (read: https://www.rubyguides.com/2018/11/ruby-heredoc/)

    Usage:

    class SomeMigration < ActiveRecord::Migration[8.0]
      def change
        execute <<~SQL
          CREATE INDEX CONCURRENTLY idx_users_email_lower 
          ON users (LOWER(email));
        SQL
      end
    end
    

    Why use it?

    • Database-specific features: When you need PostgreSQL-specific syntax, MySQL features, etc.
    • Complex SQL: For operations that Rails DSL doesn’t support easily
    • Performance: Direct SQL can sometimes be more efficient
    • Raw SQL requirements: When you specifically need to write SQL (as mentioned in our requirement)

    Example for creating a users table with raw SQL:

    class CreateUsers < ActiveRecord::Migration[8.0]
      def up
        execute <<~SQL
          CREATE TABLE users (
            id BIGSERIAL PRIMARY KEY,
            name VARCHAR(255) NOT NULL,
            email VARCHAR(255) NOT NULL,
            created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
            updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
          );
    
          CREATE UNIQUE INDEX idx_users_email ON users(email);
        SQL
      end
    
      def down
        execute <<~SQL
          DROP TABLE IF EXISTS users;
        SQL
      end
    end
    

    The <<~SQL heredoc ends when it encounters the closing SQL keyword, and the ~ strips indentation so your SQL can be properly formatted within the Ruby code.

    Q) ๐Ÿค” Is it a best practise to repeat the database constraints in model validation in Rails? DB level vs App level? or both?

    Best Practice: Use BOTH ๐ŸŽฏ

    Why both levels are recommended:

    Database Level (Last Line of Defense)

    -- Database constraints
    price DECIMAL(10,2) NOT NULL CHECK (price > 0),
    category product_category NOT NULL,
    title VARCHAR(250) NOT NULL
    

    Pros:

    • โœ… Data integrity guarantee – No bad data can enter, ever
    • โœ… Performance – Database enforces constraints efficiently
    • โœ… Bulletproof – Protects against bulk operations, direct SQL, data imports
    • โœ… Concurrency safe – Prevents race conditions
    • โœ… Multi-application protection – If other apps access same DB

    Application Level (User Experience)

    validates :price, presence: true, numericality: { greater_than: 0 }
    validates :category, inclusion: { in: %w[men women kids infants] }
    

    Pros:

    • โœ… User-friendly errors – “Price must be greater than 0” vs “CHECK constraint violated”
    • โœ… Better UX – Validation before form submission
    • โœ… Easier testing – Can unit test validations
    • โœ… Conditional logic – Complex business rules
    • โœ… Framework features – Callbacks, custom validators

    Real-world scenarios where each matters:

    Database saves you when:

    # Bulk operations bypass Rails validations
    Product.update_all(price: -10)  # DB constraint prevents this
    
    # Direct SQL injection attempts
    # DB constraints are your last line of defense
    

    App validations save you when:

    # User gets friendly error instead of:
    # PG::CheckViolation: ERROR: new row violates check constraint
    @product = Product.new(price: -5)
    @product.valid? # => false
    @product.errors.full_messages # => ["Price must be greater than 0"]
    

    Practical Implementation:

    class Product < ApplicationRecord
      # App-level validations for UX
      validates :title, presence: true, length: { maximum: 250 }
      validates :price, presence: true, numericality: { greater_than: 0 }
      validates :category, inclusion: { in: %w[men women kids infants] }
    
      # Don't duplicate precision validation if DB handles it
      # The DECIMAL(10,2) constraint is sufficient at DB level
    end
    
    -- DB-level constraints for data integrity
    CREATE TABLE products (
      id BIGSERIAL PRIMARY KEY,
      title VARCHAR(250) NOT NULL,
      price DECIMAL(10,2) NOT NULL CHECK (price > 0),
      category product_category NOT NULL,
      -- DB handles precision automatically with DECIMAL(10,2)
    );
    

    What NOT to duplicate:

    • โŒ Precision constraintsDECIMAL(10,2) handles this perfectly
    • โŒ Data type validation – DB enforces INTEGER, BOOLEAN, etc.
    • โŒ Complex regex patterns – Better handled in app layer

    Conclusion:

    Use both, but strategically:

    • Database: Core data integrity, type constraints, foreign keys
    • Application: User experience, business logic, conditional rules
    • Don’t over-duplicate simple type/precision constraints that DB handles well

    This approach gives you belt and suspenders protection with optimal user experience.

    to be continued … ๐Ÿš€