Build a Notion-like editor with Rails

Notion had for a long-time a neat block-based editor. It allows you to type away with a paragraph-element by default, but allows you to choose other block elements, like h1, ul and so on. This allows you to style the elements inline as well, keeping things super clear.

This article shows you the basic data modeling and logic to set up. Enhancing it with JavaScript (Stimulus) and making things pretty will happen in a following article. Exactly how I would do it in real apps too.

Data model

I am keeping this set up basic, with just a page model to hold the blocks, but in a real application a page might belong to something like a Collection. I am also not adding all possible blocks, but feel free to reach out if you need specific guidance.

First the Page model:

rails g model Page
# Let's add the association already into app/models/page.rb
class Page < ApplicationRecord
  has_many :blocks, dependent: :destroy
end

Simple enough. For the Blocks I will be using DelegatedType. This allows you to have shared attribute in one Block model, while having specific Block attributes in their own. It works perfect for the editor.

rails g model Block page:belongs_to blockable:belongs_to{polymorphic}

Then the different Blocks:

rails generate model Block::Text content:text
rails generate model Block::Heading level:integer content:string

Let’s run rails db:migrate and make some changes to the the model files:

# app/models/block.rb
class Block < ApplicationRecord
  belongs_to :page

  delegated_type :blockable, types: %w[Block::Text Block::Heading]
end

# app/models/block/text.rb
class Block::Text < ApplicationRecord
  has_one :block, as: :blockable, dependent: :destroy
end

# app/models/block/heading.rb
class Block::Heading < ApplicationRecord
  has_one :block, as: :blockable, dependent: :destroy

  validates :level, inclusion: {in: 1..6}
end

Let’s also seed the database so we have some semi-real data to look at:

# db/seeds.rb
page = Page.create

blocks = [
  { type: "Block::Heading", attributes: { level: 1, content: "Welcome to Rails Wonderland" } },
  { type: "Block::Text", attributes: { content: "Once upon a time, in a land full of gems, there was a brave developer named Ruby." } },
  { type: "Block::Heading", attributes: { level: 2, content: "The Quest for the Perfect Gem" } },
  { type: "Block::Text", attributes: { content: "Ruby embarked on a quest to find the perfect gem, one that would solve all N+1 queries." } },
  { type: "Block::Heading", attributes: { level: 3, content: "Enter the Realm of Active Record" } },
  { type: "Block::Text", attributes: { content: "In the mystical realm of Active Record, Ruby learned the ancient art of associations." } },
  { type: "Block::Heading", attributes: { level: 3, content: "The Trials of Migration" } },
  { type: "Block::Text", attributes: { content: "With every migration, Ruby grew stronger, mastering the power of schema changes." } },
  { type: "Block::Text", attributes: { content: "And thus, the legend of Ruby and the Rails was born, inspiring developers across the world." } }
]

blocks.each do |block_data|
  blockable = block_data[:type].constantize.create(block_data[:attributes])

  Block.create(page: page, blockable: blockable)
end

And run it: rails db:seed.

And finally a basic route, controller + view and partials:

# config/routes.rb
Rails.application.routes.draw do
  resources :pages, only: %w[show]
end

# app/controllers/pages_controller.rb
class PagesController < ApplicationController
  def show
    @blocks = Page.find(params[:id]).blocks
  end
end
# app/views/pages/show.html.erb
<%= render @blocks %>

# app/views/blocks/_block.html.erb
<%= render "blocks/blockable/#{block.blockable_name}", block: block %>

# app/views/blocks/blockable/_block_heading.html.erb
<%= content_tag "h#{block.block_heading.level}", block.block_heading.content %>

# app/views/blocks/blockable/_block_text.html.erb
<%= content_tag :p, block.block_text.content %>

Wow, that was a lot. But if you navigate to http://localhost:3000/pages/1 you should see the rendering of your first block-based page. Yay! 🎉

Basic editor

With the basic modeling in place and being able to render the page’s blocks, let’s make the page editable.

First the route, controller and view:

# config/routes.rb
Rails.application.routes.draw do
  resources :pages, only: %w[show edit] do
    resources :blocks, module: :pages, only: %w[create update]
  end
end

# app/controllers/pages_controller.rb
class PagesController < ApplicationController
  before_action :set_page, only: %w[show edit]

  def show
    @blocks = @page.blocks
  end

  def edit
  end

  private

  def set_page
    @page = Page.find(params[:id])
  end
end
# app/views/pages/edit.html.erb
<ol id="blocks">
  <%= render partial: "pages/block", collection: @page.blocks %>
</ol>

# app/views/pages/_block.html.erb
<li>
  <%= form_with model: [block.page, block] do |form| %>
    <%= form.fields_for :blockable do |blockable_form| %>
      <%= render "blocks/editable/#{block.blockable_type.underscore}", form: blockable_form %>
    <% end %>

    <%= form.submit %>
  <% end %>
</li>

Because each block has its own “blockable” which in turn can have different fields for each, let’s create a different partial for each:

# app/views/blocks/editable/block/_heading.html.erb
<%= form.text_field :level %>
<br>
<%= form.text_area :content %>

# app/views/blocks/editable/block/_text.html.erb
<%= form.text_area :content %>

Let’s continue by also allowing to actually update ánd create new blocks as well:

# app/controllers/pages/blocks_controller.rb
class Pages::BlocksController < ApplicationController
  before_action :set_page, only: %w[create update]

  def create
    @page.blocks.create!(
      blockable: params[:blockable_type].constantize.new(new_block_params)
    )

    redirect_to edit_page_path(@page)
  end

  def update
    Block.find(params[:id]).update(existing_block_params)

    redirect_to edit_page_path(@page)
  end

  private

  def set_page
    @page = Page.find(params[:page_id])
  end

  def new_block_params
    params.permit(blockable_attributes: [:level])[:blockable_attributes].to_h.compact_blank
  end

  def existing_block_params
    params.require(:block).permit(:id, blockable_attributes: [:id, :level, :content])
  end
end

Now let’s add a few buttons to create a new block. Update the pages#edit page:

<ol id="blocks">
  <%= render partial: "pages/block", collection: @page.blocks %>
</ol>

<%= button_to "Add paragraph", page_blocks_path(@page), params: {blockable_type: "Block::Text"} %>
<%= button_to "Add h1",
    page_blocks_path(@page),
    params: {
      blockable_type: "Block::Heading",
      blockable_attributes: {level: 1}
    }
%>
<%= button_to "Add h2",
    page_blocks_path(@page),
    params: {
      blockable_type: "Block::Heading",
      blockable_attributes: {level: 2}
    }
%>
<%# etc %>

This is what you should have now:

What’s next?

Now all the basics are in place. New blocks can be created and existing blocks can be updated. In an upcoming article I want to expand on this foundation and add Turbo and a small Stimulus controller to improve the UX and also include Tailwind CSS to make things look a fair bit better.

Stay tuned! 👂

Get UI & Product Engineering Insights for Rails Apps (and product updates!)

Published at . Have suggestions or improvements on this content? Do reach out.

UI components Library for Ruby on Rails apps

$ 99 one-time
payment

Explore
  • One-time Payment

  • Access to the Entire Library

  • Built using ViewComponent

  • Designed with Tailwind CSS

  • Enhanced with Hotwire

Fractional Rails UI Product Engineer

$ 2k month

Hire
  • UI Modernization

  • Fractional UI and feature improvement

  • JavaScript untaming

  • No full-time commitment

Launch a Rails SaaS app in a month

$ 15k one-time

Book a call
  • Modern Rails app

  • Ready for paying customers in one month

  • 2 - 3 core features

  • You own every line of code