action-text

Installation
SKILL.md

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_texts table (+ Active Storage tables if missing)
  • JavaScript imports for the editor
  • app/views/layouts/action_text/contents/_content.html.erb — content wrapper partial
  • app/views/active_storage/blobs/_blob.html.erb — attachment rendering partial
  • app/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 articles table — content lives in action_text_rich_texts
  • The attribute name is arbitrary (:content, :body, :description, etc.)
  • A model can have multiple rich text attributes: has_rich_text :body and has_rich_text :summary
  • Each has_rich_text creates a separate ActionText::RichText record

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)
  • libvips or ImageMagick for image processing
  • image_processing gem 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

  1. Skipping the install generatorhas_rich_text without action_text:install = missing tables, JS, and partials
  2. No content styling — Raw Action Text output needs CSS for .trix-content/.lexxy-content
  3. Ignoring N+1 — Always use with_rich_text_X_and_embeds in list views
  4. Adding a column to the model — Rich text lives in action_text_rich_texts, not your table
  5. Manual HTML sanitization — Action Text handles this; double-sanitizing breaks content
  6. Wrong stylesheet order with Lexxy — Lexxy CSS must load before your app CSS
  7. Missing Active Storage dependencies — No libvips = broken image rendering
  8. Not handling missing attachables — Deleted records render as empty boxes without to_missing_attachable_partial_path
Related skills

More from thinkoodle/rails-skills

Installs
4
GitHub Stars
4
First Seen
8 days ago