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

Building a blog with Rails: patterns, pitfalls and fixes

A complete feature walkthrough.

Expona Admin9 min read

Building a blog with Rails: patterns, pitfalls and fixes

By day 71 of a Rails journey, you've likely built simple CRUD screens, but a production blog demands more: rich content, SEO consideration, real-time interactivity, and a data model that scales. We'll walk through the complete feature set needed to ship a blog that users trust and search engines discover, examining the patterns that work and the mistakes that slow teams down.

Setting up the core models

A blog's foundation rests on three interconnected models: Post, Comment, and Tag. The Post is the main content unit. Comments are user-generated responses. Tags enable category and discovery. Here's how we structure them:

ruby
class Post < ApplicationRecord
  has_many :comments, dependent: :destroy
  has_many :taggings, dependent: :destroy
  has_many :tags, through: :taggings
  has_rich_text :body

  validates :title, :slug, presence: true
  validates :slug, uniqueness: true
  before_validation :generate_slug, if: :title_changed?

  scope :published, -> { where(published: true).order(published_at: :desc) }
  scope :recent, -> { order(created_at: :desc) }

  private

  def generate_slug
    self.slug = title.parameterize if slug.blank?
  end
end

class Comment < ApplicationRecord
  belongs_to :post
  belongs_to :user, optional: true

  validates :body, presence: true
  validates :user_id, uniqueness: { scope: :post_id }, message: "has already commented" unless :user_id.nil?

  scope :approved, -> { where(approved: true).order(created_at: :desc) }
end

class Tag < ApplicationRecord
  has_many :taggings, dependent: :destroy
  has_many :posts, through: :taggings

  validates :name, presence: true, uniqueness: true

  def to_param
    name.parameterize
  end
end

class Tagging < ApplicationRecord
  belongs_to :post
  belongs_to :tag
end

The slug field replaces auto-incrementing IDs in URLs, giving us /posts/building-a-blog-with-rails instead of /posts/42. This matters for SEO and shareability—if you rename a post, a numeric ID doesn't tell you what changed. The to_param override on Tag ensures tag links are clean without additional routing logic. The uniqueness constraint on Comment prevents duplicate submissions—a common frustration when Turbo retries fail silently or a user double-clicks submit.

One edge case worth handling: what happens if two posts are created simultaneously with the same title? The slug collision validation will reject the second one. Add a migration to ensure the slug column has a database-level uniqueness constraint, and consider appending a timestamp for conflicts:

ruby
def generate_slug
  base_slug = title.parameterize
  self.slug = base_slug
  counter = 1
  while Post.where(slug: self.slug).exists?
    self.slug = "#{base_slug}-#{counter}"
    counter += 1
  end
end

Another consideration: comments from unauthenticated users. The optional: true on the user association lets you accept guest comments, but you lose moderation control. In practice, teams either require login or use a CAPTCHA on guest comment forms. Test both flows—the difference in user experience is significant.

Rich text with Action Text

Rails 6+ ships with Action Text, which provides a rich HTML editor backed by Trix and stored in a separate ActiveStorage blob. This beats storing raw HTML in a text column because it's versioned, sanitized, and separates content from the database.

ruby
rails generate migration CreatePosts title:string slug:string published:boolean published_at:datetime

Then add the association to your Post model—we've already included has_rich_text :body above. Action Text creates the underlying ActionText::RichText record automatically when you save content.

In your view, render it safely:

ruby
<%= simple_format(@post.body) %>

This escapes HTML by default, and Action Text's output is already sanitized by the Loofah gem, which strips dangerous tags like <script> while preserving formatting. For custom formatting—say, syntax highlighting for code blocks or embedding YouTube videos—use a renderer:

ruby
class CustomRenderer < Redcarpet::Render::HTML
  def block_code(code, language)
    CodeRay.highlight(code, language || 'text', :html)
  end

  def paragraph(text)
    return text if text.include?('<iframe')
    "<p>#{text}</p>"
  end
end

One pitfall: Action Text attachments (images, videos) rely on ActiveStorage. Ensure your storage is configured—local disk for development, S3 for production. Test image uploads in CI early; broken attachments destroy credibility. In production, we've seen teams forget to set the S3 bucket CORS policy, causing image upload forms to fail silently with browser-level errors that don't appear in Rails logs.

Another real-world scenario: what happens when you migrate from a legacy blog that stored HTML directly? You'll need to convert those strings into Action Text records. Write a one-time rake task:

ruby
task convert_html_to_action_text: :environment do
  Post.where(body: nil).find_each do |post|
    post.body = ActionText::Content.new(post.legacy_html)
    post.save!
  end
end

SEO: meta tags, friendly URLs and sitemaps

Three layers make a blog discoverable. Without them, you're writing for a wall.

Meta tags: Use the meta-tags gem to set title, description, and Open Graph tags per post:

ruby
gem 'meta-tags'

class PostsController < ApplicationController
  def show
    @post = Post.find_by!(slug: params[:id])
    set_meta_tags title: @post.title,
                   description: @post.excerpt || @post.body.to_plain_text[0..155],
                   og: {
                     type: 'article',
                     title: @post.title,
                     description: @post.excerpt,
                     image: @post.featured_image.url,
                     url: post_url(@post)
                   },
                   twitter: {
                     card: 'summary_large_image',
                     creator: '@yourhandle'
                   }
  end
end

The excerpt field is critical—it appears in Google snippets and social shares. If missing, the gem truncates body text, which can cut off mid-sentence. Always set it explicitly. Test your meta tags with Google's Rich Results Test and Twitter's Card Validator before shipping.

Canonical URLs: Prevent duplicate content penalties by setting a canonical link in your layout:

ruby
<%= tag.link rel: 'canonical', href: request.url %>

This is especially important if you serve the same post at multiple URLs (e.g., with and without query parameters for analytics). A common mistake: canonical links that point to the wrong protocol or hostname. If your Rails app runs behind a load balancer or proxy, ensure request.url reflects the correct domain. Test by inspecting view source on production.

Sitemaps: Use the sitemap_generator gem to auto-generate an XML sitemap:

ruby
gem 'sitemap_generator'

# config/sitemap.rb
SitemapGenerator::Sitemap.default_host = 'https://yourblog.com'

SitemapGenerator::Sitemap.create do
  Post.published.each do |post|
    add post_path(post), 
        lastmod: post.updated_at, 
        priority: 0.8, 
        changefreq: 'weekly'
  end

  Tag.find_each do |tag|
    add tag_path(tag), 
        priority: 0.6, 
        changefreq: 'monthly'
  end
end

Run rake sitemap:refresh nightly via a scheduled job (Sidekiq with rufus-scheduler or a cron task). Submit the sitemap URL to Google Search Console. One common mistake: forgetting to exclude draft posts—search engines will index unpublished content if it's in the sitemap. Another: not updating the lastmod date; Google prioritizes recently changed posts for crawling. Use post.updated_at, not created_at.

Real-world edge case: after a mass update, your sitemap explodes in size. Google's sitemap protocol limits 50,000 URLs per file. The sitemap_generator gem handles splitting automatically, but verify the index file references all parts correctly.

Real-time comments with Turbo Streams

Live comments dramatically improve engagement. Turbo Streams broadcast updates to all viewers when a new comment is approved:

ruby
class Comment < ApplicationRecord
  belongs_to :post
  belongs_to :user, optional: true

  broadcasts_to :post, inserts_by: :prepend, partial: 'comments/comment'

  after_create_commit do
    if approved?
      broadcast_prepend_to post
    end
  end
end

In your Post view, subscribe to the stream:

ruby
<div id="<%= dom_id(@post) %>">
  <%= turbo_stream_from @post %>

  <div id="comments">
    <%= render @post.comments.approved %>
  </div>
</div>

When a comment is approved (via admin panel or moderation queue), all browsers watching that post receive the update instantly without a page refresh. This requires Action Cable—ensure it's configured in production. For horizontally scaled deployments with multiple Rails servers, use Redis as the cable adapter so all servers subscribe to the same broadcast channel:

ruby
# config/cable.yml
production:
  adapter: redis
  url: <%= ENV['REDIS_URL'] %>
  channel_prefix: "yourblog_%{environment}"

A critical gotcha: comments must be moderated before broadcast. Without approval, you broadcast spam. Always add an approved boolean and only broadcast after moderation. We've seen teams broadcast on after_create_commit, which means every comment—including malicious ones—hits all connected clients immediately. Add a moderation step:

ruby
class CommentModeratorJob
  include Sidekiq::Job

  def perform(comment_id)
    comment = Comment.find(comment_id)
    return if comment.approved?

    if looks_like_spam?(comment)
      comment.update(approved: false)
    else
      comment.update(approved: true)
      comment.broadcast_prepend_to comment.post
    end
  end

  private

  def looks_like_spam?(comment)
    # Call Akismet or similar spam detection service
    Akismet.spam?(comment.body, author: comment.author_name)
  end
end

Another edge case: what if the post is deleted while comments are broadcasting? The turbo_stream_from helper will error. Add defensive checks:

ruby
class Comment < ApplicationRecord
  broadcasts_to lambda { |comment| 
    comment.post if comment.post.present? 
  }, partial: 'comments/comment'
end

Pagination and performance

A popular post with thousands of comments will crash browsers if you render all of them. Use pagy for lightweight pagination:

ruby
class PostsController < ApplicationController
  def show
    @post = Post.find_by!(slug: params[:id])
    @pagy, @comments = pagy(@post.comments.approved, items: 25)
  end
end

Then render with Turbo for infinite scroll:

ruby
<div id="comments">
  <%= render @comments %>
</div>

<div id="pagination">
  <%= render 'pagination', pagy: @pagy %>
</div>

In the pagination partial, use Turbo to load the next page without a full refresh. This pattern scales to millions of comments.

Common mistakes

  • Skipping slug generation: Numeric IDs in URLs hurt SEO and make shares fragile. Use slugs everywhere. When you rename a post, set up redirects from old slugs to new ones using gems like friendly_id.
  • Not escaping user input: Comments are stored as plain text, not rich text. Rails escapes by default in views, but double-check your view helpers and ensure you're not using html_safe carelessly.
  • Forgetting N+1 queries: When rendering post lists, eager-load tags and comments: Post.includes(:tags, :comments).published. Use bullet gem in development to catch these automatically.
  • Unhandled ActiveStorage failures: If S3 is down, image uploads fail silently. Add error handling and fallback images. Log all upload failures to a monitoring service like Sentry.
  • Broadcasting to unapproved content: Your Turbo Stream setup should only broadcast after moderation, not on create. Test that spam comments don't appear on other users' screens.
  • Ignoring pagination: Loading all 10,000 comments on a popular post crashes browsers. Use pagy or kaminari with Turbo infinite scroll.
  • Canonical URLs pointing to the wrong domain: If your app runs behind a load balancer, request.url might not match your public domain. Use an explicit constant or environment variable instead.
  • Forgetting to test sitemaps in staging: Generate the sitemap, download it, and validate it's well-formed XML with the right post counts before deploying.
  • Not handling draft posts in RSS feeds: If you add RSS later, ensure draft posts don't leak. Always filter by published: true.

Key takeaways

  • Model your blog around Post, Comment, and Tag with proper validations and scopes to keep queries clean. Handle slug collisions gracefully.
  • Use Action Text for body content; it handles sanitization and rich editing out of the box. Configure ActiveStorage for your environment before shipping.
  • SEO isn't an afterthought—build meta tags, canonical URLs, and sitemaps from day one. Test with Google's tools and monitor search console for crawl errors.
  • Turbo Streams bring real-time interactivity without maintaining a separate frontend framework, but only broadcast approved content to avoid spam. Use Redis for multi-server deployments.
  • Always eager-load associations, paginate large result sets, and test your external dependencies (storage, cache, cable) in staging before deploying to production. Use monitoring to catch failures users won't report.
  • Plan for scale from the start. A blog that works with 10 posts might choke at 10,000. Optimize queries, cache aggressively, and test performance with realistic data volumes before launch.
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