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:
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
endThe 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:
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
endAnother 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.
rails generate migration CreatePosts title:string slug:string published:boolean published_at:datetimeThen 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:
<%= 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:
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
endOne 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:
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
endSEO: 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:
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
endThe 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:
<%= 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:
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
endRun 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:
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
endIn your Post view, subscribe to the stream:
<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:
# 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:
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
endAnother edge case: what if the post is deleted while comments are broadcasting? The turbo_stream_from helper will error. Add defensive checks:
class Comment < ApplicationRecord
broadcasts_to lambda { |comment|
comment.post if comment.post.present?
}, partial: 'comments/comment'
endPagination and performance
A popular post with thousands of comments will crash browsers if you render all of them. Use pagy for lightweight pagination:
class PostsController < ApplicationController
def show
@post = Post.find_by!(slug: params[:id])
@pagy, @comments = pagy(@post.comments.approved, items: 25)
end
endThen render with Turbo for infinite scroll:
<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_safecarelessly. - 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
pagyorkaminariwith Turbo infinite scroll. - Canonical URLs pointing to the wrong domain: If your app runs behind a load balancer,
request.urlmight 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.






Comments
Loading comments…