Skip to content
ExponaBLOG
RUBY ON RAILS · DAY 034 / 100

Action Text: concepts, trade-offs and production lessons

Rich text content.

Expona Admin6 min read

Action Text: concepts, trade-offs and production lessons

Rich text editing is a cornerstone of modern web applications, yet it remains deceptively complex when you move beyond basic textarea inputs. Rails 6 introduced Action Text to solve this problem—a framework that bundles the Trix editor with attachment handling, sanitization, and database persistence. In this article, we'll explore the practical concepts, trade-offs and production lessons we've learned deploying Action Text in high-volume SaaS and e-commerce environments, covering everything from editor integration through to the subtle security pitfalls that catch most teams.

Understanding the Trix editor integration

Trix is a WYSIWYG editor that ships with Action Text out of the box. It handles formatting like bold, italic, lists, links, and blockquotes without exposing raw HTML to the user—a deliberate design choice that simplifies both development and security.

When you add Action Text to a new Rails app, the JavaScript and CSS are typically bundled via your asset pipeline or import map. If you're on Rails 7+ with import maps, Trix is loaded automatically when you include the action-text stylesheet:

ruby
app/views/layouts/application.html.erb
<%= stylesheet_link_tag "actiontext", "data-turbo-track": "reload" %>

The editor itself renders as a contenteditable div. Trix intercepts user input, validates it against its internal schema, and serializes it into a custom JSON format stored in your database. This JSON representation is crucial: it's not HTML, which means Trix can safely rebuild the editor state on page load without interpreting arbitrary markup.

A common pitfall is assuming Trix JSON and HTML are interchangeable. They're not. When you render a rich text field in a template, Action Text converts JSON to sanitized HTML at display time, not storage time. This separation is intentional and protects you from stored XSS—but only if you use the correct helper:

ruby
app/views/articles/show.html.erb
<%= @article.body %>
<!-- Correct: renders sanitized HTML -->

<%= @article.body.body.to_s %>
<!-- Wrong: bypasses sanitization -->

The first approach calls Rails' string-interpolation on the ActionText::RichText object, which invokes to_s and applies the default sanitizer. The second accesses the underlying JSON directly and outputs it raw—a dangerous shortcut we've seen cause security reviews to fail.

The has_rich_text declaration and data model

Adding rich text to a model is trivial syntactically but carries hidden implications:

ruby
app/models/article.rb
class Article < ApplicationRecord
  has_rich_text :body
  has_rich_text :summary
end

Behind the scenes, has_rich_text creates a one-to-one association with the ActionText::RichText table, a polymorphic join table that stores the JSON content and references your model via record_id and record_type. The declaration itself adds no columns to your model's table—the rich text lives entirely in a separate record.

This design has trade-offs. On the positive side, you avoid bloating your main table with large JSON columns; on the negative side, you introduce an extra database query and a join whenever you load rich text. If you're fetching 100 articles and displaying their bodies, you'll trigger 100 additional queries without eager loading:

ruby
app/controllers/articles_controller.rb
# Bad: N+1 queries
@articles = Article.all
@articles.each { |a| puts a.body }

# Good: preload rich text
@articles = Article.includes(:rich_text_body)

The includes method uses the association name with the rich_text_ prefix. If you've declared has_rich_text :body, you preload with :rich_text_body. This is non-obvious and trips up many developers.

Another consideration: storing rich text in a separate table means your model's created_at and updated_at don't automatically reflect edits to the body. If you need to track when content changed, you'll add timestamps to ActionText::RichText itself or use a callback:

ruby
app/models/article.rb
class Article < ApplicationRecord
  has_rich_text :body
  
  after_update_commit do
    body.update_column(:updated_at, Time.current) if body.present?
  end
end

Embedded attachments and file handling

Trix allows users to embed images and files directly into rich text. When a user drops an image, Trix uploads it via an Ajax POST to a dedicated endpoint, receives a signed URL, and embeds it as an <figure> tag with a data-attachment-id attribute. The attachment itself is stored in your configured storage backend (S3, local disk, etc.), and a reference is persisted in the ActionText::Attachment table.

This happens transparently, but production deployments reveal several concerns:

File size and rate limiting. By default, Trix has no size restrictions. A user can embed a 500MB video and crash your app. We've always enforced a per-file limit in the controller:

ruby
config/initializers/active_storage.rb
Rails.application.config.active_storage.variant_processor = :mini_magick
Rails.application.config.active_storage.analyzers = [ActiveStorage::Analyzer::ImageAnalyzer::ImageMagick, ActiveStorage::Analyzer::VideoAnalyzer]
Rails.application.config.active_storage.web_image_content_types = %w[image/jpeg image/png image/gif image/webp]

And in your attachment endpoint validation:

ruby
app/controllers/rails/active_storage/direct_uploads_controller.rb
# Add custom validation before create
class DirectUploadsController < ActiveStorage::DirectUploadsController
  before_action :validate_blob_size
  
  private
  
  def validate_blob_size
    if params[:blob][:byte_size].to_i > 50.megabytes
      render json: { error: 'File too large' }, status: :unprocessable_entity
    end
  end
end

Image variants and performance. If you're storing user images, you'll want thumbnails and responsive variants. Action Text embeds the full attachment, but you can generate and serve variants in your views:

ruby
app/models/article.rb
class Article < ApplicationRecord
  has_rich_text :body
  
  def body_with_variants
    body.body.fragments.map do |fragment|
      if fragment.is_a?(ActionText::Attachment)
        # Generate a variant and update the attachment's URL
        fragment.tap { |a| a.node['src'] = url_for(a.attachable.variant(resize: '600x400')) }
      else
        fragment
      end
    end
  end
end

Cleanup of orphaned attachments. If a user uploads an image and then deletes the rich text without saving, the attachment remains in your storage and database. We've implemented a background job to purge orphaned attachments older than 24 hours:

ruby
app/jobs/cleanup_orphaned_attachments_job.rb
class CleanupOrphanedAttachmentsJob < ApplicationJob
  def perform
    ActionText::RichText.all.each do |rt|
      rt.body.fragments.select { |f| f.is_a?(ActionText::Attachment) }.each do |attachment|
        attachment.attachable.purge if attachment.attachable.updated_at < 1.day.ago
      end
    end
  end
end

Sanitized output and security

Action Text uses Rails::HTML::Sanitizer to strip or escape dangerous tags and attributes. By default, it permits basic formatting tags (b, i, strong, em, p, br, ul, ol, li, a, blockquote) and a whitelist of safe attributes like href and src.

The sanitization happens during rendering, not storage. Your JSON is stored as-is; the HTML output is filtered. This is safe because Trix doesn't allow users to write raw HTML—all content is built via the editor UI—but it means custom or malicious JSON in the database could theoretically be exploited if rendering logic is bypassed.

We've learned to customize the sanitizer only when absolutely necessary:

ruby
config/initializers/action_text.rb
ActionText::ContentHelper.sanitizer = Rails::HTML::Sanitizer.new(
  permitted_tags: %w[b strong i em p br ul ol li blockquote a figure figcaption],
  permitted_attributes: %w[href src alt title data-attachment-id data-attachable-type data-attachable-sgid]
)

Never whitelist script, style, or event handler attributes. If you need dynamic behavior (like embedded videos), use iframe with a strict CSP header instead of allowing arbitrary JavaScript.

A common mistake: trusting user-uploaded images without scanning them. Always run virus checks and image validation on attachments, especially if they're user-facing. We use tools like ImageMagick's identify and ClamAV for production systems handling sensitive data.

Common mistakes

  • Rendering raw JSON: Using .body.body.to_s instead of .body bypasses sanitization entirely.
  • Missing eager loading: Forgetting to includes(:rich_text_body) causes N+1 queries in list views.
  • Unbounded file uploads: Allowing users to upload files of any size without validation will exhaust storage and memory.
  • Orphaned attachment cleanup: Never implemented, leading to storage bloat over months or years.
  • Over-permissive sanitization: Whitelisting tags or attributes beyond the default set can reintroduce XSS vulnerabilities.
  • Ignoring attachment variants: Embedding full-resolution images degrades page load performance and wastes bandwidth.

Key takeaways

  • Action Text is a complete solution for rich text, but its strength lies in separating JSON storage from HTML rendering—respect that boundary.
  • Trix JSON is not HTML; always render rich text through the proper helper to ensure sanitization.
  • Eager load rich_text associations to avoid N+1 queries in production.
  • Validate and limit attachment sizes; implement cleanup jobs for orphaned files.
  • Customize sanitization rules sparingly and only after understanding the security implications.
  • Test rich text rendering with realistic content, including images, links, and edge cases like very long documents.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles