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

  1. Navigate to /railspress/admin/posts/new
  2. Enter a title (slug auto-generates)
  3. Write content using the rich text editor with support for bold, italic, headings, quotes, code blocks, lists, images, and links
  4. Select a category and add comma-separated tags
  5. Set status to "Published" when ready
  6. 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
![alt](url) 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

app/controllers/blog_controller.rb
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:

app/controllers/blog_controller.rb
# 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])
No require-order workaround

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.

Deprecated: search, page, per_page_count

Through 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

config/routes.rb
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

app/views/blog/index.html.erb
<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

app/views/blog/show.html.erb
<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:

ERB
<%# 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.

config/initializers/railspress.rb
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.

ERB
<% 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 %>
CSS
.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:

ERB
<% if (og_image = rp_featured_image_url(@post, variant: { resize_to_limit: [1200, 630] })) %>
  <meta property="og:image" content="<%= og_image %>">
<% end %>
Variants need an image processor

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 Form Partial

app/views/blog/_search_form.html.erb
<%= 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:

app/controllers/blog_controller.rb
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

app/controllers/blog_controller.rb
def feed
  @posts = Railspress::Post.published
                           .includes(:category)
                           .ordered
                           .limit(20)

  respond_to do |format|
    format.rss { render layout: false }
  end
end

Route

config/routes.rb
get "blog/feed", to: "blog#feed", as: :blog_feed, defaults: { format: :rss }

RSS Builder View

app/views/blog/feed.rss.builder
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

app/helpers/blog_helper.rb
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:

app/views/blog/show.html.erb (top)
<% 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" %>