Rails Action Text Expert
Implement rich text editing in Rails applications using Action Text with the Lexxy editor (Rails 8.1+) or Trix (Rails 6-8.0).
Key Concepts
Action Text stores rich text in a separate action_text_rich_texts table via polymorphic associations — not in your model's table. It handles sanitization, rendering, attachments (via Active Storage), and embedded objects (via Signed GlobalIDs).
Editor History:
- Trix — Original Action Text editor (Rails 6+). Still works, still supported.
- Lexxy — Modern replacement from Basecamp (Rails 8.1+). Better dark mode, CSS custom properties, improved UX. Use Lexxy for new projects.
When To Use This Skill
- Adding rich text fields to a model
- Setting up Action Text in a new or existing Rails app
- Configuring Lexxy or Trix editor appearance
- Rendering rich text content safely
- Handling image/file attachments in rich text
- Creating custom attachable objects (embed users, products, etc.)
- Fixing N+1 queries with rich text
- Styling the editor and rendered content
- Testing rich text functionality
Instructions
Step 1: Check If Action Text Is Installed
# Check for Action Text tables
bin/rails runner "puts ActiveRecord::Base.connection.table_exists?('action_text_rich_texts')"
# Check for Action Text config files
ls app/views/layouts/action_text/contents/_content.html.erb 2>/dev/null
ls app/views/active_storage/blobs/_blob.html.erb 2>/dev/null
# Check Gemfile for editor
grep -E "trix|lexxy" Gemfile
If not installed, run installation first (Step 2). If already installed, skip to Step 3.
Step 2: Install Action Text
bin/rails action_text:install
bin/rails db:migrate
This creates:
- Migration for
action_text_rich_textstable (+ Active Storage tables if missing) - JavaScript imports for the editor
app/views/layouts/action_text/contents/_content.html.erb— content wrapper partialapp/views/active_storage/blobs/_blob.html.erb— attachment rendering partialapp/assets/stylesheets/actiontext.css— default styles
For Lexxy (Rails 8.1+), also add:
# Gemfile
gem "lexxy"
bundle install
Critical: Stylesheet load order for Lexxy:
<%# app/views/layouts/application.html.erb %>
<%# Lexxy FIRST, then app styles (so your overrides win) %>
<%= stylesheet_link_tag "lexxy", "data-turbo-track": "reload" %>
<%= stylesheet_link_tag :app, "data-turbo-track": "reload" %>
Common Agent Mistake: Forgetting bin/rails action_text:install. Without it, the migration, JS imports, and view partials are missing. The has_rich_text declaration alone is not enough.
Step 3: Add Rich Text to a Model
# app/models/article.rb
class Article < ApplicationRecord
has_rich_text :content
end
Key facts:
- No column needed on the
articlestable — content lives inaction_text_rich_texts - The attribute name is arbitrary (
:content,:body,:description, etc.) - A model can have multiple rich text attributes:
has_rich_text :bodyandhas_rich_text :summary - Each
has_rich_textcreates a separateActionText::RichTextrecord
Step 4: Add the Editor to Forms
<%# app/views/articles/_form.html.erb %>
<%= form_with model: @article do |form| %>
<div class="field">
<%= form.label :content %>
<%= form.rich_text_area :content %>
</div>
<% end %>
Permit the attribute in the controller:
class ArticlesController < ApplicationController
def create
@article = Article.create!(article_params)
redirect_to @article
end
private
def article_params
params.expect(article: [:title, :content])
end
end
Note: rich_text_area (or rich_textarea) — both work. The rich text content is a single string param; no special nesting required.
Step 5: Render Rich Text Content
<%# Safe — Action Text sanitizes content automatically %>
<%= @article.content %>
That's it. ActionText::RichText#to_s returns sanitized HTML safe for direct embedding.
For plain text (e.g., excerpts, meta descriptions):
@article.content.to_plain_text
# => "Hello world. This is bold text."
# Truncated excerpt
truncate(@article.content.to_plain_text, length: 150)
Check for content presence:
@article.content.blank? # true if no content
@article.content.present? # true if has content
Step 6: Style the Editor and Content
Lexxy (Rails 8.1+)
Editor sizing:
lexxy-editor,
.lexxy-editor {
min-height: 300px;
}
Rendered content styling (use .lexxy-content wrapper):
<%# app/views/layouts/action_text/contents/_content.html.erb %>
<div class="lexxy-content">
<%= yield %>
</div>
.lexxy-content {
line-height: 1.6;
overflow-wrap: break-word;
}
.lexxy-content p { margin: 0 0 1rem; }
.lexxy-content h1, .lexxy-content h2, .lexxy-content h3 {
font-weight: 600;
line-height: 1.3;
margin: 1.5rem 0 0.75rem;
}
.lexxy-content ul, .lexxy-content ol {
margin: 0 0 1rem;
padding-left: 1.5rem;
}
.lexxy-content blockquote {
border-left: 3px solid var(--color-border);
margin: 1rem 0;
padding: 0.5rem 0 0.5rem 1rem;
color: var(--color-ink-muted);
}
.lexxy-content code {
font-size: 0.875em;
background: var(--color-surface-muted);
padding: 0.125rem 0.375rem;
border-radius: 4px;
}
.lexxy-content pre {
background: var(--color-surface-muted);
border-radius: 8px;
padding: 1rem;
overflow-x: auto;
margin: 1rem 0;
}
Dark mode with CSS custom properties:
:root {
--lexxy-color-canvas: var(--color-surface);
--lexxy-color-text: var(--color-ink);
--lexxy-color-link: var(--color-link);
--lexxy-color-code-bg: var(--color-surface-muted);
--lexxy-focus-ring-color: var(--color-primary);
}
See references/editors.md for the full list of Lexxy CSS variables.
Trix (Rails 6–8.0)
Content wrapper uses .trix-content:
<%# app/views/layouts/action_text/contents/_content.html.erb %>
<div class="trix-content">
<%= yield %>
</div>
Override styles in app/assets/stylesheets/actiontext.css.
Common Agent Mistake: Not styling Action Text content at all. The raw output looks unstyled. Always provide CSS for .trix-content or .lexxy-content.
Step 7: Handle Attachments
Action Text uses Active Storage for file attachments (images, files dragged/dropped into the editor).
Prerequisites:
- Active Storage must be installed (
bin/rails active_storage:install) libvipsorImageMagickfor image processingimage_processinggem in Gemfile
Customize attachment rendering:
<%# app/views/active_storage/blobs/_blob.html.erb %>
<figure class="attachment attachment--<%= blob.representable? ? "preview" : "file" %> attachment--<%= blob.filename.extension %>">
<% if blob.representable? %>
<%= image_tag blob.representation(resize_to_limit: local_assigns[:in_gallery] ? [800, 600] : [1024, 768]) %>
<% end %>
<figcaption class="attachment__caption">
<% if caption = blob.try(:caption) %>
<%= caption %>
<% else %>
<span class="attachment__name"><%= blob.filename %></span>
<span class="attachment__size"><%= number_to_human_size blob.byte_size %></span>
<% end %>
</figcaption>
</figure>
Step 8: Avoid N+1 Queries
This is critical. Loading rich text triggers extra queries per record. Always preload.
# BAD — N+1 queries (one query per article for rich text)
Article.all.each { |a| a.content.to_s }
# GOOD — Preload rich text
Article.all.with_rich_text_content
# GOOD — Preload rich text AND its embedded attachments
Article.all.with_rich_text_content_and_embeds
The scope name is dynamic: with_rich_text_#{name} and with_rich_text_#{name}_and_embeds based on your has_rich_text :name declaration.
# If you have: has_rich_text :body
Article.with_rich_text_body
Article.with_rich_text_body_and_embeds
# If you have: has_rich_text :description
Product.with_rich_text_description_and_embeds
Common Agent Mistake: Forgetting _and_embeds. Without it, attachment images trigger additional queries when rendering.
Step 9: Custom Attachables (Embeds)
Embed any Active Record model inside rich text using Signed GlobalIDs.
# app/models/user.rb
class User < ApplicationRecord
include ActionText::Attachable
def to_attachable_partial_path
"users/mention"
end
end
<%# app/views/users/_mention.html.erb %>
<span class="user-mention">@<%= user.name %></span>
Inserting programmatically:
user = User.find(1)
html = %(<action-text-attachment sgid="#{user.attachable_sgid}"></action-text-attachment>)
article.update!(content: "Hello #{html}")
Handle deleted records gracefully:
class User < ApplicationRecord
include ActionText::Attachable
def self.to_missing_attachable_partial_path
"users/missing_mention"
end
end
<%# app/views/users/missing_mention.html.erb %>
<span class="user-mention user-mention--deleted">@deleted user</span>
Step 10: Testing Rich Text
Model tests:
require "test_helper"
class ArticleTest < ActiveSupport::TestCase
test "accepts rich text content" do
article = Article.new(title: "Test", content: "<h1>Hello</h1><p>World</p>")
assert article.content.present?
assert_includes article.content.to_plain_text, "Hello"
assert_includes article.content.to_plain_text, "World"
end
test "content is blank when not set" do
article = Article.new(title: "Test")
assert article.content.blank?
end
end
Request tests:
require "test_helper"
class ArticlesRequestTest < ActionDispatch::IntegrationTest
test "creates article with rich text" do
assert_difference "Article.count", 1 do
post articles_path, params: {
article: { title: "Test", content: "<p>Rich text body</p>" }
}
end
assert_equal "Rich text body", Article.last.content.to_plain_text
end
end
System tests (for editor interaction):
require "application_system_test_case"
class ArticlesSystemTest < ApplicationSystemTestCase
test "creates article with rich text editor" do
visit new_article_path
fill_in "Title", with: "My Article"
# Fill the rich text editor
find("trix-editor").click
find("trix-editor").set("Hello from the editor")
click_on "Create Article"
assert_text "Hello from the editor"
end
end
UUID Primary Keys
If your models use UUIDs, update the Action Text migration:
# In the generated migration
t.references :record, null: false, polymorphic: true, index: false, type: :uuid
Content Security
Action Text sanitizes HTML on render using a safe-list approach. Only allowed tags and attributes pass through. You do not need to call sanitize manually.
Custom sanitization (if needed):
# config/application.rb
config.action_text.sanitizer_allowed_tags = ActionText::ContentHelper::ALLOWED_TAGS + ["iframe"]
config.action_text.sanitizer_allowed_attributes = ActionText::ContentHelper::ALLOWED_ATTRIBUTES + ["src", "frameborder"]
⚠️ Be extremely careful expanding the allow-list. Adding <iframe> or <script> opens XSS vectors. Only do this if you trust all content authors.
Quick Reference
Essential Commands
bin/rails action_text:install # Install Action Text
bin/rails db:migrate # Run migrations
bin/rails active_storage:install # Install Active Storage (if not present)
Model Declaration
has_rich_text :content # Single rich text field
has_rich_text :body # Name it whatever you want
has_rich_text :content # Multiple fields OK
has_rich_text :summary # on the same model
Form Helpers
<%= form.rich_text_area :content %>
<%= form.rich_text_area :content, placeholder: "Write something..." %>
<%= form.rich_text_area :content, data: { controller: "editor" } %>
Rendering
<%= @article.content %> # Safe HTML
<%= @article.content.to_plain_text %> # Plain text
<%= truncate(@article.content.to_plain_text, length: 200) %> # Excerpt
Preloading (Prevent N+1)
Model.with_rich_text_fieldname # Preload text only
Model.with_rich_text_fieldname_and_embeds # Preload text + attachments
Key Files
| File | Purpose |
|------|---------|
| app/views/layouts/action_text/contents/_content.html.erb | Content wrapper (.trix-content or .lexxy-content) |
| app/views/active_storage/blobs/_blob.html.erb | Attachment rendering template |
| app/assets/stylesheets/actiontext.css | Default Action Text styles |
Anti-Patterns to Avoid
- Skipping the install generator —
has_rich_textwithoutaction_text:install= missing tables, JS, and partials - No content styling — Raw Action Text output needs CSS for
.trix-content/.lexxy-content - Ignoring N+1 — Always use
with_rich_text_X_and_embedsin list views - Adding a column to the model — Rich text lives in
action_text_rich_texts, not your table - Manual HTML sanitization — Action Text handles this; double-sanitizing breaks content
- Wrong stylesheet order with Lexxy — Lexxy CSS must load before your app CSS
- Missing Active Storage dependencies — No
libvips= broken image rendering - Not handling missing attachables — Deleted records render as empty boxes without
to_missing_attachable_partial_path