The Ideal CMS Structure For Rails
On all publishing websites I have worked on, and many Rails sites, I've needed to publish static pages or collections of editorial content.
Sometimes these are basic app pages, like /contact/ or /support/, in which case it's easy enough to make a PagesController
, route each static page to a view file like contact.html.erb
, and move on with your day.
But oftentimes these are more than basic pages. These could be a collection of help docs at /docs/. Or if we're serving the public website from our main Rails app, it might include /blog/, /case-studies/, /resources/, /integrations/, /partners/, and dozens of other content collections.
In the case of these collections, the most common practice is to start out creating a standard blog implementation, and then copy and pasting that build structure each time you need a new content collection.
But, that leads to tons of code duplication, and now each time we make an upgrade related to those content types, we have to extrapolate it over every model. And when the marketing or content team wants a new content directory, it's cumbersome to launch and requires database migrations and probably some refactoring.
Need to add a featured_image
? You have to add it to every model.
Need a new field so you can set custom OpenGraph metadata? Add it to every model.
These are not the hardest problems to solve. You can share partials or build components, you can copy/paste code to a few places, but eventually it will probably lead to differences in how each of the models are handled and make it harder to manage.
I use what I think is a better approach: one shared posts model and many routed content collections:

Creating A Shared Posts Model & Serving It Across Many Content Collections
There is another pattern used by CMSs like WordPress and Ghost that I prefer however, and I'm going to walk through how I've built this in Rails.
Everything is a Post
Every Post has a post_type
, which might default to something like blog
.
Posts also typically have a few other key columns, with an initial migration that looks like this:
create_table :posts do |t|
t.string :title
t.string :slug
t.enum :status, null: false, enum_type: "post_status"
t.json :content # assuming we're storing JSON content, depends on editor
t.text :excerpt
t.string :meta_title
t.text :meta_description
t.datetime :published_at
t.integer :word_count
t.string :post_type
t.timestamps
end
This is a simple starting point. Depending on our site requirements, at any time we want to simplify our posts model, we can always break some of these columns off to separate tables. For example:
- We could move our meta_tag values to
post_metadata
, - We can store data specific to a single post_type in
custom_fields
, etc. - If we use ActionText we'd skip
t.json :content
and usehas_rich_text :content
in our model instead. - If we want to store content with revisions, we might set this up as a has_many on a post_content model instead, along with some logic for tracking the latest version. I can't find the original post, but I'm pretty sure this is roughly the model used by Basecamp when a user makes edits to an existing message or post
We don't publicly display anything as /posts/
While all of our content is stored as Posts, the Post model is entirely restricted to authorized users across Index, Show, Edit, etc.
This makes the routing simple, and the index at /posts/ effectively becomes the dashboard for our CMS. That will typically look like this in our Routes:
# Admin-only Post editing routes
authenticated :user, ->(user) { user.admin? } do
resources :posts
end
You can imagine another version where we check user.post_author? permissions instead of full admin permissions.
Posts can be displayed through any routed Collection we want
What makes this all work is that Posts can be displayed through as many public content collections as we want.
This is similar to patterns like Custom Post Types for WordPress which store in wp_posts table, or Collections in Webflow. But I'm borrowing most heavily from Ghost, where collections are just Posts which are routed through a tagging system rather than a post_type column.
We do this by registering a controller, routes, and 2 view files. For example:
Routes Example
# Public Blog and Case Study routes (read-only)
resources :blog, controller: 'blog', path: 'blog', param: :slug, only: %i[index show]
resources :case_studies, controller: 'case_studies', path: 'case-studies', param: :slug, only: %i[index show]
Controller Example
class BlogController < ApplicationController
def index
@posts = filtered_posts.published
end
private
def post_type
'blog'
end
def filtered_posts
Post.where(post_type: 'blog').where(status: 'published).where(indexable: true)
end
end
We could also descend from PostsController in order to share some methods like filtered_posts or add a controller concern.
I've replicated filtered_posts
here without pretty scope names in order to get a glimpse of what it's doing. In reality, we'd be setting up scopes in the Post model to make it simpler to call these:
scope :published, -> { where(status: 'published') }
scope :indexable, -> { where(indexable: true) }
scope :blog_posts, -> { where(post_type: 'blog') }
scope :case_studies, -> { where(post_type: 'case_study') }
View Files - Index & Show
Finally, we have 2 core view files for index and show.
For this example each page serves up basic page metadata and then passes all posts into Posts::IndexComponent
or Posts::ShowComponent
ViewComponents, which handle the actual formatting of posts.
<%= page_title("Blog") %>
<%= meta_tag :title, "Blog" %>
<%= meta_tag :description, "Here is my blog index meta description" %>
<%= meta_tag :url, blog_index_url %>
<%= canonical_url(blog_index_url) %>
<%= render Posts::IndexComponent.new(
posts: @posts,
admin_links: current_user&.admin?,
title: "Blog",
new_post_url: new_post_path(post_type: "blog"),
post_type: "blog"
) %>
/app/views/blog/index.html.erb
<%= page_title(@post.meta_title.presence || @post.title) %>
<%= meta_tag :title, @post.meta_title.presence || @post.title %>
<%= meta_tag :description, @post.meta_description.presence || @post.excerpt.presence || "Read #{@post.title} on the Site Name blog." %>
<%= meta_tag :url, blog_url(@post.slug) %>
<%= canonical_url(blog_url(@post.slug)) %>
<% if @post.featured_image.attached? %>
<%= meta_tag :image, url_for(@post.featured_image) %>
<%= meta_tag :image_alt, @post.title %>
<% end %>
<%= render Posts::ShowComponent.new(
post: @post,
admin_links: current_user&.admin?,
back_url: blog_index_path,
back_label: "â Back to blog"
) %>
app/views/blog/show.html.erb
Benefits
Centralized Feature Development
This is the big one.
When you need to add a new feature like featured images, OpenGraph metadata, or reading time estimates, you add it once to the Posts model and immediately every content collection gets it. No more copying the same migration across Blog, CaseStudy, HelpDoc, and Resource models. No more forgetting to update one collection and having inconsistent functionality across your site.
Need to do a fancy callback to calculate word_count after the post is updated? Just do it once.
Need to generate url slug if blank upon creation but then validate it each time the user saves? Just do it once.
Need to do special handling for ActiveStorage attachments on Trix that are intended to be published publicly and need to serve cleaner URLs? Just do it once.
Consistent SEO & Metadata Management
Every post gets the same meta_title
, meta_description
, and SEO handling regardless of whether it's a blog post or a case study.
Your sitemap generation becomes trivialâjust iterate individually through each collection of posts.
Structured data markup can be standardized with a single component that adapts based on post_type
. You'll never have that awkward moment where your blog has perfect SEO but your case studies are missing Open Graph tags.
Simplified Content Administration
Your CMS interface becomes incredibly clean. Instead of having separate admin sections for blog posts, case studies, help docs, and resources, you have one unified posts dashboard. You can filter by post_type
, but all the core functionalityâediting, publishing, schedulingâworks the same way everywhere. Your content team learns one interface instead of four.
Here's an example I built recently for Posts#index that includes param filters on top of the page. These were part of a quick spike that took 3 hours for the entire Posts + collections build out, so design isn't perfected, but it looks way better than your average internal admin screen in regards to editing content:

And here's the Post#edit view:

Reduced Code Duplication
Personally I think this is one area where this pattern shines. If I didn't do this approach, I would be racking my brain trying to figure out the best way to break up a lot of this stuff into concerns.
But overall this approach should cut down on a lot of find/replace type of commits.
Add New Content Collection in 15 Minutes With No Migrations
Need to add a new content collection like /testimonials/ or /press-releases/? It's literally just creating a new controller that inherits from PostsController, adding a route, and copy/pasting two view files to build off of an existing page template.
You can spin up a new content type in 15 minutes instead of spending a day building out new models, refactoring controller and model concerns, updating your admin config to match existing content models, etc.
Easy Search and Filtering
Want to add site-wide search?
You're searching one table instead of trying to union across multiple models.
Want to show "related content" that might pull from different collections?
Easyâjust query posts with different post_types.
Need to show recent activity across all content types in your admin dashboard?
âď¸ One query.
Common Concerns
Here are some common variations or concerns that show up and how I'd solve them:
What if I want to display the content differently on each post_type?
This is a common one! What if we don't want to display author name on one of the posts types? What if we want to show table of contents on one post_type but not all?
Most of the time, this is easily solved by just altering the index or show view templates. Those views can share as much or as little styling as we want, so it's easy to create different layouts by post_type.
It's also very easy to create different page layouts, eg a layout column which is set to default
but can be changed to full_width
vs slim
.
What if I need to store different data types for a post_type
?
There are a lot of patterns to solve this and it opens up discussions about delegated types and other stuff that honestly, I'm not the most qualified Rails developer to weigh in on.
However the pattern that I would reach that is similar to WordPress is simply to create a pattern like has_many custom_fields
, or perhaps a tag system. Then my custom_fields table would simply store a key value pattern for custom_fields, or for simpler labels I would use a tag.
You want case studies to have an industry category that allows you to filter sub-collections on the case studies index page?
Add a new tag
, then add special routes for those tags or serve them up via url param, eg /case-studies/?industry=saas
.
You want case studies to store customer quotes that can be displayed dynamically in your case_study#show template?
Add a custom_field
.
You want to store and entire section of custom HTML for a page that displays above the regular Post.content section?
Same deal - format it with a special partial or ViewComponent, and put the dynamic stuff in a custom_field
.
We could get into a lot of theoretical discussions about whether this is over-using a custom_field relationship and whether it makes it hard to validate and monitor the data being stored in custom_field, but how strict you are with that stuff is really up to you. If this is to support a few hundred pages of content on one app then all of this stuff will scale just fine.
What about URL structure and SEO implications?
This is one of the best benefits in my opinion.
All of your URLs need to have custom slugs, which this system handles well. If you want to validate uniqueness by post_type so that you have have /blog/special-slug and /case-studies/special-slug you can do that just fine. The routing handles the collection-specific paths while the underlying data structure remains unified.
For our other settings, you get consistent metadata handling, easier sitemap generation, and unified canonicalization logic. The main thing to watch out for is if you ever need to change a post's post_typeâyou'll want to set up proper redirects from the old URL to the new collection.
How do you handle different editorial workflows?
Not every content type needs the same publishing workflow. Your blog posts might go straight to published, but case studies might need approval from a sales manager first.
IMO that's usually better managed outside of your app in most small to mid-sized organizations. If you can manage a single repeatable content approval process across all content types, then you should be able to build that into this model the same as you would if all of the Posts were separate models.
What about site performance with large datasets?
Once you're dealing with thousands of posts across multiple types, you might want to optimize this structure a little. I haven't reached this point on Rails but I definitely have on some of the WordPress publishing sites I manage.
Claude suggests:
- think about indexing, such as composite indexes on (post_type, status, published_at) and (post_type, slug) since those are your most common query patterns.
- for really large datasets, you might consider moving to separate models and using concerns instead.
- implement some basic cachingâeg fragment caching for your index pages and Russian doll caching for individual posts.
How do you manage different content schemas?
Different post types often need different structured data markup. Blog posts need Article schema, case studies might need Service schema, and help docs might need HowTo schema.
I would handle this by adding schema logic to my ShowComponent that switches based on post_type. Most of the Schema is going to be generated off of Post Title, meta description, featured image, etc. Anything else can be pulled from custom fields.
How do security and permissions work across post types?
That's up to you - I always just piggyback whatever methods we have already implemented with Devise, CanCanCan, etc to check for (A) whether user is logged in and (B) whether they have admin privileges. In a more complex environment I would probably add a set of author privileges that is similar to admin but allows post editing access instead of admin privileges.
What happens when you need drastically different data models?
There's definitely a breaking point where the custom_fields approach becomes unwieldy. If one of your post types needs 15 custom fields with complex validation rules, or if you need heavy relational data that doesn't fit the key-value pattern, it might be time to extract that post_type to its own model.
The nice thing is that you can do this gradually. Keep the unified model for most content types and extract the complex ones as needed. You're not locked into the pattern forever.
Comments