Building a Blog with RailsPress
Create a fully-featured blog with recent posts, categories, tags, search, RSS feeds, and SEO optimization.
Managing Content in the Admin
RailsPress provides a full admin interface at /railspress/admin. Here you can:
- Create Posts: Write blog posts with rich text content using the Lexxy editor
- Manage Categories: Organize posts into categories
- Manage Tags: Add tags for cross-cutting topics
Creating a Post
- Navigate to
/railspress/admin/posts/new - Enter a title (slug auto-generates)
- Write content using the rich text editor with support for bold, italic, headings, quotes, code blocks, lists, images, and links
- Select a category and add comma-separated tags
- Set status to "Published" when ready
- Click "Create Post"
Markdown Mode
The post editor supports switching between rich text and markdown editing modes. Click the toggle button in the editor toolbar to switch modes.
Switching to Markdown Mode
- Click the markdown toggle button in the editor toolbar
- Your rich text content is converted to markdown syntax
- Edit directly in markdown format
- Ideal for developers who prefer markdown
Switching Back to Rich Text
- Click the toggle button again
- Markdown is converted back to HTML
- Continue editing with the visual editor
Supported Markdown Syntax
| Syntax | Result |
|---|---|
# Heading |
H1 heading |
## Heading |
H2 heading |
**bold** |
Bold text |
*italic* |
Italic text |
~~strikethrough~~ |
Strikethrough |
`code` |
Inline code |
``` |
Code block |
> quote |
Blockquote |
- item |
Unordered list |
1. item |
Ordered list |
[text](url) |
Link |
 |
Image |
--- |
Horizontal rule |
Important Notes
- Content is automatically converted when you switch modes
- On form submission, markdown is converted to HTML for storage
- Complex HTML (custom styles, nested tables) may not convert cleanly to markdown
- The rich text editor preserves all formatting when switching back
Building the Frontend Blog
RailsPress provides models and data, but leaves frontend presentation to your application. This gives you full control over design and URL structure.
Step 1: Create a Blog Controller
class BlogController < ApplicationController
def index
@posts = Railspress::Post.published
.includes(:category, :tags)
.ordered
.rp_page(params[:page])
end
def show
@post = Railspress::Post.published.find_by!(slug: params[:slug])
@related_posts = @post.category&.posts
&.published
&.where.not(id: @post.id)
&.ordered
&.limit(3) || []
end
def category
@category = Railspress::Category.find_by!(slug: params[:slug])
@posts = @category.posts
.published
.includes(:tags)
.ordered
.rp_page(params[:page])
end
def tag
@tag = Railspress::Tag.find_by!(slug: params[:slug])
@posts = @tag.posts
.published
.includes(:category)
.ordered
.rp_page(params[:page])
end
def search
@query = params[:q].to_s.strip
@posts = if @query.present?
Railspress::Post.published
.rp_search(@query)
.includes(:category, :tags)
.ordered
.rp_page(params[:page])
else
Railspress::Post.none
end
end
end
Search and pagination choices
rp_search, rp_page, and rp_per_page_count are RailsPress's dependency-free defaults, added in 1.4.3. The engine admin and API use only these namespaced names, so they never compete with the search or pagination API your host app already uses.
rp_page(n) returns an ordinary Active Record relation offset to that page, 20 records per page by default. It intentionally has no per method and no page-metadata methods, so .rp_page(params[:page]).per(10) raises NoMethodError: undefined method 'per'. rp_search(query) is an adapter-aware, case-insensitive substring match on the post title, so it works on SQLite, MySQL, and PostgreSQL alike.
Host controllers own their public search and pagination behavior. Every RailsPress scope returns a plain ActiveRecord::Relation, so use your preferred stack directly:
# Kaminari
@posts = Railspress::Post.published.includes(:category, :tags).ordered
.page(params[:page]).per(10)
# Pagy (in the controller, with the Pagy::Backend concern included)
@pagy, @posts = pagy(Railspress::Post.published.includes(:category, :tags).ordered, limit: 10)
# No gem: use the built-in RailsPress scope, 20 per page
@posts = Railspress::Post.published.includes(:category, :tags).ordered.rp_page(params[:page])
Before 1.4.3, RailsPress defined a generic page scope that could win over Kaminari's depending on gem load order, which meant deferring the RailsPress require in your Gemfile. That workaround is no longer needed: the engine calls only rp_page, so Kaminari, Pagy, and will_paginate keep their own page and per.
search, page, per_page_countThrough the RailsPress 1.x compatibility period, the generic search, page, and per_page_count names are still defined, but only when your model does not already provide them. They emit deprecation warnings through the :railspress deprecator and are removed in RailsPress 2.0. Use rp_search, rp_page, and rp_per_page_count, or your own pagination gem, in new code.
Step 2: Add Routes
Rails.application.routes.draw do
# Mount RailsPress admin
mount Railspress::Engine => "/railspress"
# Blog frontend routes
get "blog", to: "blog#index", as: :blog
get "blog/search", to: "blog#search", as: :blog_search
get "blog/category/:slug", to: "blog#category", as: :blog_category
get "blog/tag/:slug", to: "blog#tag", as: :blog_tag
get "blog/:slug", to: "blog#show", as: :blog_post
end
Available Scopes
RailsPress provides these scopes out of the box:
| Scope | Description |
|---|---|
Railspress::Post.published |
Posts with status "published" and a published_at date |
Railspress::Post.drafts |
Posts with status "draft" |
Railspress::Post.ordered |
Posts ordered by created_at descending |
Railspress::Post.recent |
Last 10 posts (combines ordered with limit(10)) |
Recent Posts Feed
<div class="blog">
<h1>Blog</h1>
<%= render "blog/search_form" %>
<div class="posts">
<% @posts.each do |post| %>
<article class="post-card">
<h2>
<%= link_to post.title, blog_post_path(slug: post.slug) %>
</h2>
<div class="post-meta">
<time datetime="<%= post.published_at.iso8601 %>">
<%= post.published_at.strftime("%B %d, %Y") %>
</time>
<% if post.category %>
<span class="category">
<%= link_to post.category.name, blog_category_path(slug: post.category.slug) %>
</span>
<% end %>
</div>
<div class="post-excerpt">
<%= truncate(strip_tags(post.content.to_plain_text), length: 200) %>
</div>
<div class="post-tags">
<% post.tags.each do |tag| %>
<%= link_to tag.name, blog_tag_path(slug: tag.slug), class: "tag" %>
<% end %>
</div>
<%= link_to "Read more", blog_post_path(slug: post.slug), class: "read-more" %>
</article>
<% end %>
</div>
<%= render "blog/pagination", collection: @posts %>
</div>
Individual Post Pages
<article class="post">
<% if @post.header_image.attached? %>
<figure class="post-hero">
<%= image_tag @post.header_image.variant(resize_to_limit: [1200, 630]),
alt: @post.title,
style: @post.focal_point_css(:header_image) %>
</figure>
<% end %>
<header class="post-header">
<h1><%= @post.title %></h1>
<div class="post-meta">
<time datetime="<%= @post.published_at.iso8601 %>">
<%= @post.published_at.strftime("%B %d, %Y") %>
</time>
<% if @post.category %>
<span class="category">
in <%= link_to @post.category.name, blog_category_path(slug: @post.category.slug) %>
</span>
<% end %>
</div>
</header>
<div class="post-content">
<%= @post.content %>
</div>
<footer class="post-footer">
<% if @post.tags.any? %>
<div class="post-tags">
<strong>Tags:</strong>
<% @post.tags.each do |tag| %>
<%= link_to tag.name, blog_tag_path(slug: tag.slug), class: "tag" %>
<% end %>
</div>
<% end %>
<nav class="post-navigation">
<%= link_to "Back to Blog", blog_path %>
</nav>
</footer>
</article>
<% if @related_posts.any? %>
<aside class="related-posts">
<h3>Related Posts</h3>
<ul>
<% @related_posts.each do |post| %>
<li>
<%= link_to post.title, blog_post_path(slug: post.slug) %>
<time><%= post.published_at.strftime("%b %d") %></time>
</li>
<% end %>
</ul>
</aside>
<% end %>
Displaying the Header Image
When enable_post_images is on, each post has a header_image Active Storage attachment. Always guard on attached?, because posts without an image return an unattached proxy.
There are three ways to render it:
<%# 1. The original, unprocessed upload %>
<%= image_tag @post.header_image, alt: @post.title %>
<%# 2. An inline variant — any Active Storage transformation %>
<%= image_tag @post.header_image.variant(resize_to_limit: [1200, 630]), alt: @post.title %>
<%# 3. A named variant declared in your initializer %>
<%= image_tag @post.header_image.variant(:hero), alt: @post.title %>
Named variants are the ones worth reaching for. Declare them once with post_image_variants and refer to them by name everywhere; RailsPress adds format: :webp to each one automatically. See Named Post Image Variants for the full configuration.
Railspress.configure do |config|
config.enable_post_images
config.post_image_variants = {
hero: { resize_to_fill: [1920, 1080] },
card: { resize_to_fill: [800, 600] },
thumb: { resize_to_fill: [400, 400] }
}
end
Combining variants with focal points
A variant that crops (resize_to_fill) decides which part of the image survives. The focal point decides where the browser anchors the image inside its box. Use both: pass the variant to image_tag and the focal point to style, with object-fit: cover in your CSS.
<% if @post.header_image.attached? %>
<figure class="post-hero" style="aspect-ratio: 16 / 9;">
<%= image_tag @post.header_image.variant(:hero),
alt: @post.title,
style: @post.focal_point_css(:header_image) %>
</figure>
<% end %>
.post-hero img {
width: 100%;
height: 100%;
object-fit: cover; /* focal_point_css sets object-position */
}
focal_point_css returns an object-position declaration such as object-position: 62% 30%. For per-context crops and custom overrides, see Focal Points: View Helpers.
Open Graph and meta tags
For meta tags you need a URL rather than an <img> tag. rp_featured_image_url returns one, and nil when post images are disabled or nothing is attached:
<% if (og_image = rp_featured_image_url(@post, variant: { resize_to_limit: [1200, 630] })) %>
<meta property="og:image" content="<%= og_image %>">
<% end %>
Rendering the original attachment works out of the box. Generating any variant requires an Active Storage processor gem and its native library in every environment. See Active Storage & Image Variants.
Category Pages
Category pages follow the same pattern as the index, but filtered by category. See the controller code above for the implementation.
Tag Pages
Tag pages work similarly to categories. Use the tag action in your controller.
Search Functionality
Search Form Partial
<%= form_with url: blog_search_path, method: :get, class: "search-form", data: { turbo_frame: "_top" } do |form| %>
<%= form.search_field :q,
value: @query,
placeholder: "Search posts...",
class: "search-input",
autofocus: @query.present? %>
<%= form.submit "Search", class: "search-button" %>
<% end %>
Full-Text Search (PostgreSQL)
For better search with PostgreSQL, update the search action to include ActionText content:
def search
@query = params[:q].to_s.strip
@posts = if @query.present?
Railspress::Post.published
.joins("LEFT JOIN action_text_rich_texts ON ...")
.where("railspress_posts.title ILIKE :q OR ...", q: "%#{@query}%")
.includes(:category, :tags)
.distinct
.ordered
.rp_page(params[:page])
else
Railspress::Post.none
end
end
RSS Feed
Controller Action
def feed
@posts = Railspress::Post.published
.includes(:category)
.ordered
.limit(20)
respond_to do |format|
format.rss { render layout: false }
end
end
Route
get "blog/feed", to: "blog#feed", as: :blog_feed, defaults: { format: :rss }
RSS Builder View
xml.instruct! :xml, version: "1.0"
xml.rss version: "2.0", "xmlns:atom" => "..." do
xml.channel do
xml.title "Your Blog Name"
xml.description "Your blog description"
xml.link blog_url
xml.language "en"
@posts.each do |post|
xml.item do
xml.title post.title
xml.link blog_post_url(slug: post.slug)
xml.guid blog_post_url(slug: post.slug), isPermaLink: true
xml.pubDate post.published_at.rfc822
xml.description strip_tags(post.content.to_plain_text).truncate(300)
end
end
end
end
SEO Optimization
Meta Tags Helper
module BlogHelper
def blog_meta_title(post = nil)
if post
post.meta_title.presence || post.title
else
"Blog | Your Site Name"
end
end
def blog_meta_description(post = nil)
if post
post.meta_description.presence || truncate(strip_tags(post.content.to_plain_text), length: 160)
else
"Read our latest articles and updates."
end
end
end
Layout Head Section
Use content_for to set SEO metadata in your post views:
<% content_for :title, blog_meta_title(@post) %>
<% content_for :meta_description, blog_meta_description(@post) %>
<% content_for :canonical_url, blog_post_url(slug: @post.slug) %>
<% content_for :og_type, "article" %>