Rails Action Text: Rich Text Editing, Custom Attachments, PostgreSQL Search, and Production Pitfalls with Trix
Rails Action Text powers rich text editing with Trix. Learn attachments, custom renderers, PostgreSQL search, N+1 fixes, and production pitfalls to avoid.
Before Action Text shipped in Rails 6, I spent three days on a client project building a “simple” rich text editor for a document management system. Trix as a standalone gem, a custom sanitizer to strip XSS payloads, base64-encoded image uploads inline — which I discovered six months later averaged 8 MB per row in the database — a separate Paperclip attachment model for the “real” uploads, and a custom view renderer that tried to reconstruct the document from a stored JSON blob. It worked. It was also a security risk we patched four times, a maintenance trap two subsequent engineers each broke in different ways, and something I am still slightly ashamed of whenever I look at it.
Rails Action Text ships a complete answer to all of that. I have been running it in production since Rails 6.0 and it is one of the quieter success stories in the Rails ecosystem: genuinely well-designed, shipped with real tests, and solving 90% of what production products need from a rich text field. Here is everything I have learned, including the mistakes you will make if nobody warns you first.
What Rails Action Text Actually Is
Trix is the editor — the JavaScript component that runs in the browser. Action Text is the Rails integration layer on top of Trix. It handles four things:
Storage. A separate action_text_rich_texts table stores the Trix document as HTML, keyed by a polymorphic association to the parent record. There is no body column on your posts table.
The Attachable protocol. Any ActiveRecord model — or plain Ruby object — can implement ActionText::Attachable to become embeddable in a Trix document. Think @mentions, product cards, embedded video pointers, anything your content team wants to drag into a document.
Server-side rendering. When you render post.body, Rails resolves attachment sgid tokens into rendered HTML partials. The editor stores a compact token; the view gets the real UI.
Sanitization. Before rendering, Action Text runs stored HTML through Rails HTML sanitizer with a configurable allowed-tag list. Scripts and inline event handlers are stripped automatically.
The key thing to internalize: Trix stores its internal state as JSON, but Action Text’s body accessor returns an ActionText::RichText object whose .to_s is sanitized HTML ready for render. You never deal with raw Trix JSON in application code.
Getting Started with Rails Action Text
Install Action Text:
bin/rails action_text:install
bin/rails db:migrate
This adds a migration for action_text_rich_texts, wires up JavaScript, and adds a stylesheet import. Then declare rich text on your model:
class Post < ApplicationRecord
has_rich_text :body
has_rich_text :summary
end
has_rich_text defines a body accessor. There is no column change on posts. In the form:
<%= form_with model: @post do |f| %>
<%= f.label :body %>
<%= f.rich_text_area :body %>
<% end %>
Permit it in your controller:
def post_params
params.require(:post).permit(:title, :body, :summary)
end
Render it in a view:
<%= @post.body %>
That is the complete setup. ActiveRecord handles saving and loading; the body accessor returns the ActionText::RichText object, which renders as sanitized HTML.
The action_text_rich_texts Schema
Understanding the schema saves debugging time. The table looks like this:
CREATE TABLE action_text_rich_texts (
id bigint PRIMARY KEY,
name varchar NOT NULL,
body text,
record_type varchar NOT NULL,
record_id bigint NOT NULL,
created_at timestamp NOT NULL,
updated_at timestamp NOT NULL
);
CREATE UNIQUE INDEX ON action_text_rich_texts (record_type, record_id, name);
Each has_rich_text :body on Post creates a row with name = "body", record_type = "Post", record_id = post.id. A second has_rich_text :summary gets its own row with name = "summary". Both live in the same table.
The body column stores HTML, not raw Trix JSON. Trix serializes internally, and Action Text stores the sanitized HTML representation. Attachment tokens live inline as <action-text-attachment sgid="..."> elements.
Custom Attachments: The Attachable Protocol
This is where Rails Action Text becomes genuinely powerful. Any ActiveRecord model can become attachable by including ActionText::Attachable:
class Person < ApplicationRecord
include ActionText::Attachable
def to_trix_content_attachment_partial_path
"people/trix_content_attachment"
end
end
Create the partial at app/views/people/_trix_content_attachment.html.erb:
<%= link_to "@#{person.name}", person_path(person), class: "mention text-indigo-600 font-medium" %>
That is the complete @mention implementation. The Trix editor exposes a search interface for attachable objects — wire it up with an Attachables controller:
class AttachablesController < ApplicationController
def index
@people = Person.where("name ILIKE ?", "%#{params[:query]}%").limit(10)
render json: @people.map { |p|
{ sgid: p.attachable_sgid, content: p.name, description: p.email }
}
end
end
Hook the frontend search to your endpoint in JavaScript (see the Action Text docs for the attachmentsController configuration). When a user selects a result, Trix stores the sgid. On render, Action Text resolves the sgid, calls to_trix_content_attachment_partial_path, and substitutes the token with your rendered partial — server-side, on every request.
You can make any Ruby class attachable, not just ActiveRecord. It needs to respond to attachable_sgid (which GlobalID::Identification provides) and have a resolvable partial. A small value object:
class StatusBadge
include GlobalID::Identification
attr_reader :id, :label, :color
def self.find(id)
STATUSES.fetch(id) { raise ActiveRecord::RecordNotFound }
end
def initialize(id, label, color)
@id = id
@label = label
@color = color
end
def to_trix_content_attachment_partial_path
"status_badges/trix_content_attachment"
end
STATUSES = {
"critical" => new("critical", "CRITICAL", "red"),
"warning" => new("warning", "WARNING", "amber"),
"ok" => new("ok", "OK", "green")
}.freeze
end
The partial renders the badge; Action Text resolves the sgid on each page render. No database joins, no storage beyond the token string.
Rendering Contexts: In-Editor vs. Published View
Action Text renders attachments in two contexts. :in_editor is the real-time preview inside the Trix editor as you type. The rendered view (:caption or the default) is what visitors see on save.
You can branch on context inside a single partial:
<%# app/views/people/_trix_content_attachment.html.erb %>
<% if options[:in_editor] %>
<span class="mention-chip bg-indigo-100 text-indigo-800 px-2 py-0.5 rounded text-sm">
@<%= person.name %>
</span>
<% else %>
<%= link_to "@#{person.name}", person_path(person), class: "mention-link" %>
<% end %>
This keeps the editor experience clean while giving you full control over the published rendering.
Content Sanitization in Rails Action Text
Action Text uses Rails HTML sanitizer with a default allowed-tag list. It strips scripts and inline event handlers automatically. Customize the configuration for your app’s needs:
# config/initializers/action_text.rb
ActionText::ContentHelper.sanitizer = Rails::HTML5::SafeListSanitizer.new
ActionText::ContentHelper.allowed_tags = %w[
div p br blockquote h1 h2 h3 h4 h5 h6
ul ol li strong em del a
figure figcaption
action-text-attachment
]
ActionText::ContentHelper.allowed_attributes = %w[
href target rel class id
sgid content-type filename filesize previewable url caption width height
]
Two rules that will save you pain: always keep action-text-attachment in the allowed tags and always keep sgid in the allowed attributes. Strip either one and every embedded attachment in every document goes blank — no error, just silent disappearance.
If you ingest content from external sources (import jobs, API endpoints, webhooks), sanitize before assignment:
safe_html = ActionText::ContentHelper.sanitize(raw_html_from_api)
post.body = ActionText::Content.new(safe_html)
post.save!
Never assign untrusted HTML directly. The editor enforces sanitization client-side, but the model assignment path does not automatically re-sanitize on save in older Rails versions.
Full-Text Search on Rails Action Text Bodies
The body column in action_text_rich_texts stores HTML. For PostgreSQL full-text search, strip the tags and index the text content. Combined with the pg_search gem, the approach is:
class AddSearchIndexToActionTextRichTexts < ActiveRecord::Migration[7.1]
def up
execute <<~SQL
CREATE INDEX action_text_rich_texts_body_search_idx
ON action_text_rich_texts
USING gin(
to_tsvector('english',
coalesce(regexp_replace(body, '<[^>]*>', ' ', 'g'), '')
)
)
WHERE record_type = 'Post' AND name = 'body';
SQL
end
def down
execute "DROP INDEX IF EXISTS action_text_rich_texts_body_search_idx;"
end
end
Add a search scope to the model:
class Post < ApplicationRecord
has_rich_text :body
scope :search_body, ->(query) {
joins(
"INNER JOIN action_text_rich_texts ON " \
"action_text_rich_texts.record_type = 'Post' AND " \
"action_text_rich_texts.record_id = posts.id AND " \
"action_text_rich_texts.name = 'body'"
).where(
"to_tsvector('english', coalesce(regexp_replace(action_text_rich_texts.body, '<[^>]*>', ' ', 'g'), '')) " \
"@@ plainto_tsquery('english', ?)",
query
)
}
end
Usage:
Post.search_body("production performance").with_rich_text_body_and_embeds
Chain .with_rich_text_body_and_embeds after the search scope — otherwise you trigger an N+1 when rendering results, which leads directly to the next section.
N+1 Queries: The Most Common Rails Action Text Production Bug
After nineteen years of Rails, I can predict with high confidence the first performance bug any team hits after shipping Rails Action Text: forgetting to eager-load. Every has_rich_text accessor triggers a separate query to action_text_rich_texts if you are not explicit about loading.
# This generates N+1 — one extra query per post
Post.limit(20).each { |post| puts post.body }
Action Text ships scopes for this. Use them:
# Eager-loads the rich text record
Post.with_rich_text_body.limit(20)
# Eager-loads the rich text record AND all embedded Active Storage blobs
Post.with_rich_text_body_and_embeds.limit(20)
# Eager-loads all has_rich_text attributes at once
Post.with_all_rich_text.limit(20)
In production, always default to _and_embeds. If a body contains image attachments and you use bare with_rich_text_body, the attachment blobs are still lazy-loaded during render, each as a separate query. The difference in a list view of twenty posts with five embedded images each is 100 queries vs. 3.
For custom attachable models, Action Text does not automatically preload them. If you embed Person records, each is fetched individually during render. Preload them manually before rendering:
posts = Post.with_rich_text_body_and_embeds.limit(20)
# Collect all embedded Person ids across all post bodies
person_ids = posts.flat_map { |post|
post.body.attachments.filter_map { |attachment|
attachment.attachable.id if attachment.attachable.is_a?(Person)
}
}
# Load them all in one query; Person#find resolves from identity map
Person.where(id: person_ids).load
This preloads into ActiveRecord’s identity map. When Action Text renders each attachment partial, Person.find(id) returns the already-loaded object without a database round-trip.
If your application has many models with rich text and complex attachable structures, a dedicated read replica takes the query pressure off your primary — but fixing the N+1 is always the first step.
Testing Rails Action Text
In system tests, interact with Trix via the .trix-content selector:
class PostsSystemTest < ApplicationSystemTestCase
test "creates a post with rich body" do
visit new_post_path
fill_in "Title", with: "Production Patterns"
within(".trix-content") { find("div").click.send_keys("Rails is great") }
click_on "Publish"
assert_selector "h1", text: "Production Patterns"
assert_text "Rails is great"
end
end
For integration and unit tests, assign rich text as a plain HTML string — Action Text accepts both strings and ActionText::Content objects:
test "post body stores html content" do
post = Post.create!(
title: "Test",
body: "<p>Hello <strong>world</strong></p>"
)
assert post.body.to_s.include?("Hello")
assert post.body.to_s.include?("<strong>world</strong>")
end
For attachable objects in tests, create the attachable first and embed its sgid manually:
test "body renders embedded person mentions" do
alice = people(:alice)
post = Post.create!(
title: "Team Update",
body: ActionText::Content.new(
%(<action-text-attachment sgid="#{alice.attachable_sgid}"></action-text-attachment>)
)
)
rendered = render_rich_text(post.body)
assert_includes rendered, alice.name
end
A small test helper render_rich_text can use ActionText::ContentHelper.render_action_text_content if you need to inspect the rendered output in unit tests.
Production Pitfalls in Rails Action Text
Orphaned Blobs After Record Deletion
Active Storage blobs embedded through Trix are cleaned up when an ActionText::RichText record is destroyed — but the blobs in your S3 bucket remain if you use dependent: :destroy on the polymorphic association without also purging blobs. Run a scheduled job:
# Purge unattached blobs older than 2 days (safe margin for in-progress uploads)
ActiveStorage::Blob.unattached
.where("active_storage_blobs.created_at < ?", 2.days.ago)
.find_each(&:purge_later)
Wire this to your background job scheduler — Solid Queue recurring jobs or a cron task — and run it nightly.
Renaming Rich Text Attributes
If you rename has_rich_text :body to has_rich_text :content, existing data stays in action_text_rich_texts under name = "body". The new accessor finds nothing. Write a migration:
class RenameActionTextBodyToContent < ActiveRecord::Migration[7.1]
def up
ActionText::RichText
.where(record_type: "Post", name: "body")
.update_all(name: "content")
end
def down
ActionText::RichText
.where(record_type: "Post", name: "content")
.update_all(name: "body")
end
end
This is the kind of thing you miss if you treat action_text_rich_texts as invisible infrastructure. It is not — it is a real table that participates in data migrations.
Table Size in High-Volume Applications
Trix-generated HTML is verbose. A single paragraph with a heading and a bold phrase produces markup like:
<h2>Deployment</h2><div>Make sure to run <strong>migrations</strong> first.</div><br>
In a system with 500K posts and bodies averaging 4 KB each, the action_text_rich_texts table hits 2 GB fast. The unique index on (record_type, record_id, name) handles point lookups well, but unindexed LIKE searches against body will hurt. If you need full-text search, add the GIN index as described above; do not fall back to LIKE '%keyword%'.
Content Migration from Legacy Rich Text Systems
If you are migrating from a system that stored HTML in a regular text column, seed action_text_rich_texts directly:
Post.find_each do |post|
next if post.legacy_body.blank?
ActionText::RichText.create!(
record: post,
name: "body",
body: post.legacy_body
)
end
Run this in a background job, not inline. For very large tables, batch with find_in_batches and use insert_all for performance.
When to Consider Alternatives
Rails Action Text with Trix is the right default for the vast majority of Rails applications that need rich text. When to look elsewhere:
Collaborative real-time editing. Trix is single-user. For Google Docs-style simultaneous editing with operational transforms or CRDTs, look at Tiptap with Yjs over Action Cable. Trix will never support this.
Complex document structure. Trix supports a focused subset of HTML: paragraphs, headings, lists, quotes, bold, italic, links, attachments. Tables, column layouts, footnotes, nested structures — none of these fit the Trix model. ProseMirror-based editors (Tiptap, Quill, Notion-style block editors) handle these, but you lose the Action Text attachment pipeline and server-side rendering.
API-first applications. If a React or mobile client renders its own UI, server-side attachment rendering is less useful. In that case, store portable content (ProseMirror JSON, Markdown) in a regular text column and let the client handle rendering. has_rich_text buys you nothing it could not get from a plain text column.
For team wikis, blog platforms, CMS fields, email composition, documentation tools, admin notes, support ticket bodies — Action Text with Trix is production-proven and boring in the best possible way. Boring is what you want in a rich text stack.
Frequently Asked Questions
How does Rails Action Text store content in the database?
Action Text does not add a column to your model’s table. It uses a separate action_text_rich_texts table with a polymorphic association. Each has_rich_text declaration creates rows there keyed by record_type, record_id, and name. The body column stores sanitized HTML — not the raw Trix JSON. Queries and eager-loading go through this table.
How do I search Rails Action Text content in PostgreSQL?
Join to action_text_rich_texts, strip HTML tags with regexp_replace(body, '<[^>]*>', ' ', 'g'), and run a PostgreSQL full-text query with to_tsvector and plainto_tsquery. Add a GIN index on the tsvector expression filtered by record_type and name for fast search. Avoid LIKE '%keyword%' — it will not use any index.
How do I prevent N+1 queries with Rails Action Text?
Use Post.with_rich_text_body_and_embeds (not bare Post.all) whenever you plan to render a list of records with rich text bodies. The _and_embeds variant eager-loads both the ActionText::RichText record and any Active Storage blobs. For embedded attachable objects like @-mentioned users, collect their ids from the loaded bodies and preload them manually with a single WHERE IN query before rendering.
Can I use Rails Action Text in API-only mode?
You can call has_rich_text in an API-only app and store content via the model, but the Trix editor and server-side partial rendering are view-layer concerns that will not apply. The body.to_s method returns sanitized HTML you can expose via JSON. In practice, most API-only apps store Markdown or ProseMirror JSON in a plain text column and render on the client, which is simpler for that context.
Need help integrating rich text, content search, or document workflows into your Rails application? TTB Software specializes in Rails architecture for products that need to get it right the first time. Nineteen years of Rails means we know where Action Text shines and where it runs out of road.
Related Articles
Rails PostgreSQL Exclusion Constraints: Prevent Booking Overlaps with tsrange and btree_gist
Rails PostgreSQL exclusion constraints stop double bookings at the database layer. Use tsrange, btree_gist, and Rails...
Building Ledenboek: Encoding Dutch Association Governance in Rails
How we built a member management SaaS for Dutch associations, and why the hard part was not the CRUD but the governan...
Rails PgHero: Postgres Health Dashboard for Slow Queries, Missing Indexes, and Space Monitoring
Rails PgHero setup guide: mount the Postgres health dashboard, find slow queries with pg_stat_statements, get index s...