ActionEditor is a Rails-native content editing framework. It provides structured block-based editing, rich text support, rendering, serialization, versioning, full-text search, and pluggable editor adapters — all following Rails conventions like Action Mailer, Action Text, and Active Storage.
ActionEditor is designed to be:
- Rails-first — feels like a natural extension of Rails conventions
- Editor-agnostic — no JavaScript editor is bundled; adapters translate between any editor and ActionEditor's canonical format
- Block-based — content is composed of typed, validated, serializable blocks
- Stable — strong separation between the storage format and the editor protocol
| Feature | Details |
|---|---|
| Block DSL | field :title, :string — typed, validated, documented |
| 16 built-in blocks | Hero, Heading, Paragraph, Quote, Image, Gallery, Video, Feature, Pricing, FAQ, Button, Divider, HTML, Markdown, Embed, Code, Container |
| 20 field types | string, text, rich_text, integer, float, decimal, boolean, color, date, datetime, url, email, phone, image, attachment, gallery, repeater, object, array, enum, relation, json |
| Editor adapters | Editor.js, Tiptap (ProseMirror), Lexical — swap without changing models |
| Rendering | Rails partials, HTML serializer (no context), Markdown, plain text |
| Rich text | Independent of Action Text — own ProseMirror-compatible format |
| Search | page.content.search_text — flat string for any search backend |
| Versioning | Schema version migrations (v1 → v2) |
| Hotwire | Stimulus controller + editor adapters |
| Generators | install, block, migration, adapter |
Add to your Gemfile:
gem "action_editor"Run:
bundle install
rails generate action_editor:install
rails db:migrateclass Page < ApplicationRecord
has_action_editor :content
endrails generate action_editor:migration Page content
rails db:migrateclass PagesController < ApplicationController
include ActionEditor::ControllerHelpers
def update
@page.update(page_params)
end
private
def page_params
params.require(:page).permit(:title, action_editor_params(:content))
end
end<%= render_action_editor(@page.content) %>page = Page.find(1)
page.content # => ActionEditor::Document
page.content.blocks # => ActionEditor::Document::BlockCollection
page.content.blocks.size # => 5
page.content.render # => HTML string (via partials in Rails context)
page.content.to_html # => HTML string (no context needed)
page.content.to_markdown # => Markdown string
page.content.to_text # => Plain text string
page.content.to_json # => Canonical ActionEditor JSON
page.content.search_text # => Flat text for search indexing
page.content.valid? # => true/false
page.content.errors_by_block # => { "block-uuid" => ["Title can't be blank"] }rails generate action_editor:block TestimonialSection quote:rich_text author:string avatar:imageOr by hand:
class TestimonialBlock < ActionEditor::Block::Base
field :quote, :rich_text, required: true
field :author, :string, required: true
field :role, :string
field :avatar, :image
field :rating, :integer, min: 1, max: 5, default: 5
validates :quote, presence: true
validates :author, presence: true
def search_text
"#{quote&.to_plain_text} #{author} #{role}"
end
end
ActionEditor.register TestimonialBlockAnd create a partial at app/views/action_editor/blocks/_testimonial.html.erb:
<div class="testimonial" id="ae-block-<%= block.block_id %>">
<blockquote><%= block.quote&.to_html&.html_safe %></blockquote>
<cite>
<%= block.author %>
<% if block.role.present? %>, <%= block.role %><% end %>
</cite>
</div>| Type | Ruby Type | Notes |
|---|---|---|
:string |
String | max_length:, min_length: |
:text |
String | Multi-line |
:rich_text |
RichText::Content |
ProseMirror JSON |
:integer |
Integer | min:, max: |
:float |
Float | |
:decimal |
BigDecimal | |
:boolean |
Boolean | |
:color |
String | Hex validated |
:date |
Date | ISO 8601 |
:datetime |
Time | ISO 8601 |
:url |
String | HTTP/HTTPS validated |
:email |
String | Format validated |
:phone |
String | |
:image |
Hash | { url:, alt:, caption: } |
:attachment |
Hash | Active Storage metadata |
:gallery |
Array | Array of image hashes |
:repeater |
Array | min_items:, max_items: |
:object |
Hash | Free-form |
:array |
Array | |
:enum |
String | values: %w[a b c] |
:relation |
Hash | { type:, id: } |
:json |
Hash/Array | Raw JSON |
ActionEditor ships with three server-side adapters. The adapter translates the editor's payload to/from the canonical ActionEditor document format.
def update
document = ActionEditor::Adapter::Tiptap.new.parse(params[:editor_output])
@page.content = document
@page.save
endActionEditor.configure do |config|
config.default_adapter = :tiptap # :editor_js | :tiptap | :lexical
end// app/javascript/application.js
import { Application } from "@hotwired/stimulus"
import { registerActionEditor } from "action_editor"
const application = Application.start()
registerActionEditor(application)Then in your form:
<%= action_editor_tag(form, :content, adapter: :tiptap) %># In a model callback:
after_save { update_column(:search_body, content.search_text) }
# Or use the async job:
ActionEditor::Search::IndexJob.perform_later(self.class.name, id)
# PostgreSQL full-text search:
Page.where("to_tsvector('english', search_body) @@ plainto_tsquery(?)", query)# In config/initializers/action_editor.rb
ActionEditor.configure do |config|
config.versioning_enabled = true
end
# Define a migration:
class V1ToV2Migration < ActionEditor::Versioning::Migration
def self.from_version = 1
def self.to_version = 2
def up(document_hash)
document_hash["blocks"].each do |block|
# e.g. rename a field
block["data"]["body"] = block["data"].delete("content")
end
document_hash
end
end
# Apply:
migrator = ActionEditor::Versioning::Migrator.new
migrator.register(V1ToV2Migration)
migrated = migrator.migrate(page.content, from: 1, to: 2)ActionEditor.configure do |config|
config.default_adapter = :editor_js # or :tiptap, :lexical
config.renderer = :partial
config.partial_prefix = "action_editor/blocks"
config.cache_rendered_blocks = true
config.sanitize_html = true
config.versioning_enabled = false
config.search_backend = :none # or :pg
config.attachment_service = :local
endrails generate action_editor:install # Install
rails generate action_editor:block Hero # Custom block
rails generate action_editor:migration Page content # Add JSONB columnbundle exec rake testSubscribe to ActionEditor events:
ActiveSupport::Notifications.subscribe("action_editor.render") do |event|
Rails.logger.debug "Rendered #{event.payload[:block_count]} blocks in #{event.duration.round(2)}ms"
endAvailable events: action_editor.render, action_editor.render_block, action_editor.serialize, action_editor.validate, action_editor.search_index.
MIT — see LICENSE.