Capstone: A Rails Blog App
Reviewed & published by Brayan K
This is where it all comes together. You'll build a blog with posts and comments, wiring up associations, validations, forms, and authentication — every concept from the Rails track in one real application.
Part of the free Ruby course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
By the end you'll have a conventional, tested Rails app you can extend and deploy, plus a quick-reference recap of the entire track.
What You'll Build in This Capstone
- Models & migrations for posts and comments
- Associations with dependent: :destroy
- Validations on titles and bodies
- Forms and strong parameters
- Authentication gating create actions
- Tests (RSpec) and a deploy plan
1️⃣ Models, Migrations, and Associations
Start by generating the project and the Post, Comment, and User models. Connect them with associations and protect the data with validations.
# THE PLAN: a blog where users write Posts and readers leave Comments.
# This pulls together everything from the Rails track.
# 1) Generate the project and models (terminal commands):
#
# rails new blog
# bin/rails generate model Post title:string body:text user:references
# bin/rails generate model Comment body:text post:references
# bin/rails db:migrate
# 2) Associations + validations tie the data together.
class Post < ApplicationRecord
belongs_to :user
has_many :comments, dependent: :destroy # delete comments with the post
validates :title, presence: true, length: { minimum: 3 }
validates :body, presence: true
end
class Comment < ApplicationRecord
belongs_to :post
validates :body, presence: true
end
class User < ApplicationRecord
has_many :posts, dependent: :destroy
has_secure_password # secure auth from the API/auth lesson
validates :email, presence: true, uniqueness: true
end2️⃣ Controllers: Forms, Params, and Auth
Controllers are where forms, strong parameters, validations, and authentication meet. A before_action :require_login gates the create actions, and strong parameters keep input safe.
# 3) Controllers: forms, strong parameters, validations, and auth all meet here.
class PostsController < ApplicationController
before_action :require_login, only: [:new, :create] # authentication gate
def index
@posts = Post.all # the index view iterates over these
end
def show
@post = Post.find(params[:id])
@comment = Comment.new # for the inline comment form
end
def new
@post = current_user.posts.build
end
def create
@post = current_user.posts.build(post_params)
if @post.save
redirect_to @post, notice: "Post published!" # flash on success
else
render :new, status: :unprocessable_entity # show validation errors
end
end
private
def post_params
params.require(:post).permit(:title, :body) # strong parameters
end
end
class CommentsController < ApplicationController
before_action :require_login
def create
@post = Post.find(params[:post_id])
@comment = @post.comments.build(comment_params)
if @comment.save
redirect_to @post, notice: "Comment added!"
else
redirect_to @post, alert: "Comment could not be saved."
end
end
private
def comment_params
params.require(:comment).permit(:body)
end
end3️⃣ Tests and Deploy
Add tests (here with RSpec) so you can change the app with confidence, then deploy it to a hosting platform to share with the world.
# 4) Tests give you confidence to change the app. Here using RSpec
# (add the rspec-rails gem, then bin/rails generate rspec:install).
# spec/models/post_spec.rb
require "rails_helper"
RSpec.describe Post, type: :model do
it "is invalid without a title" do
post = Post.new(title: "", body: "hi")
expect(post).not_to be_valid
end
it "destroys its comments when destroyed" do
user = User.create!(email: "a@b.io", password: "secret")
post = user.posts.create!(title: "Hello world", body: "Body")
post.comments.create!(body: "Nice!")
expect { post.destroy }.to change { Comment.count }.by(-1)
end
end
# 5) Deploy: with the app tested, ship it to a hosting platform
# (for example Render, Fly.io, or Heroku). A typical flow:
#
# git init && git add . && git commit -m "Blog app"
# # push to your host and run: bin/rails db:migrate
#
# Congratulations — that is a complete, conventional Rails app:
# models + migrations, associations, validations & callbacks,
# forms & strong parameters, REST + auth, tests, and a deploy.4️⃣ The Rules, in Plain Ruby (runnable)
The three blocks above need a Rails app around them. The rules they express do not: validates is a method that collects error messages, and permit is a filter that keeps only the keys you named. Here both are written by hand in ordinary Ruby, so you can run them anywhere and watch a tampered form field vanish.
# The rules a Rails model enforces, written in plain Ruby so you can run them
# anywhere. Rails generates this machinery for you; seeing it by hand shows
# what `validates` and `permit` actually do.
class Post
attr_reader :title, :body, :errors
def initialize(title:, body:)
@title, @body = title, body
@errors = []
end
# Mirrors: validates :title, presence: true, length: { minimum: 3 }
# validates :body, presence: true
def valid?
@errors = []
@errors << "Title can't be blank" if title.to_s.strip.empty?
@errors << "Title is too short (minimum is 3 characters)" if title.to_s.strip.length < 3
@errors << "Body can't be blank" if body.to_s.strip.empty?
@errors.empty?
end
end
# Mirrors: params.require(:post).permit(:title, :body)
# Anything not on the allow-list is dropped — that is the whole point.
def permit(params, *allowed)
params.select { |key, _| allowed.include?(key) }
end
raw = { title: "Hi", body: "First post", admin: true } # admin: true came from a tampered form
safe = permit(raw, :title, :body)
puts "permitted: #{safe.keys.join(", ")}"
post = Post.new(**safe)
puts "valid? #{post.valid?}"
puts post.errors.first
good = Post.new(title: "Hello world", body: "First post")
puts "valid? #{good.valid?}"Your turn. Do the same for Comment: one presence validation and a params filter that keeps only :body. Replace each ___ with the value in its hint, then run it.
# 🎯 YOUR TURN — a Comment model with one validation and a params filter.
# Save as comment.rb and run with: ruby comment.rb
class Comment
attr_reader :body, :errors
def initialize(body:)
@body = body
@errors = []
end
# Mirrors: validates :body, presence: true
def valid?
@errors = []
# 1) A body made only of spaces is still blank.
@errors << "Body can't be blank" if body.to_s.strip.___ # 👉 replace ___ with empty?
@errors.empty?
end
end
# Mirrors: params.require(:comment).permit(:body)
def permit(params, *allowed)
# 2) Keep a key only when it is on the allow-list.
params.select { |key, _| allowed.___(key) } # 👉 replace ___ with include?
end
raw = { body: "Nice post!", post_id: 7, spam_link: "http://x" }
safe = permit(raw, :body)
puts "permitted keys: #{safe.keys.inspect}"
puts Comment.new(**safe).valid?
blank = Comment.new(body: " ")
puts blank.valid?
puts blank.errors.first
# ✅ Expected output:
# permitted keys: [:body]
# true
# false
# Body can't be blankCapstone Extension: Make It Yours
Extend the blog with tags, authorization, search, an API, or more tests — each using a skill from the track.
# 🎯 CAPSTONE EXTENSION: extend the blog yourself.
#
# Pick one or more and build it using skills from the whole track:
#
# • Tags: add a Tag model and a has_many :through join so posts
# can have many tags (Associations lesson).
# • Authorization: only let a post's author edit or delete it
# (compare current_user to post.user before allowing the action).
# • Search: add an index action filter, e.g. Post.where("title LIKE ?", ...).
# • API: expose /api/posts returning render json: with status codes.
# • Tests: add request specs for creating a post while logged out
# (expect a redirect to the login page).
#
# ✅ Goal: a richer blog that still follows Rails conventions end to end.
# your code hereCommon Errors (and the fix)
- Forgot to migrate — Run bin/rails db:migrate after generating models, or tables won't exist.
- belongs_to required error — A post needs a user; build it via current_user.posts.build.
- Orphaned comments — Add dependent: :destroy so deleting a post removes its comments.
- Unpermitted params — Whitelist each field with permit, or it silently vanishes.
- Skipping the login gate — Without before_action :require_login, anyone can post.
Pro Tips
- 💡 Build incrementally: get posts working, then add comments, then auth — one layer at a time.
- 💡 Write a test as you go: a failing-then-passing spec documents each feature.
- 💡 Deploy early: shipping a tiny version first surfaces production issues sooner.
📋 Quick Reference — The Whole Rails Track
| Lesson | Key Idea | Signature Code |
|---|---|---|
| Intro to Rails | MVC, convention over configuration | rails new blog |
| ActiveRecord | Models & migrations | rails db:migrate |
| Associations | Relate models | has_many :comments |
| Validations & Callbacks | Clean, reactive data | validates :title, presence: true |
| Forms & Strong Params | Safe user input | params.require(:post).permit(:title) |
| REST APIs & Auth | JSON + identity | has_secure_password |
| Capstone | Build, test, deploy | a complete blog app |
🎉 Track Complete!
- ✅ You built a blog with posts and comments end to end
- ✅ Associations, validations, forms, and auth all working together
- ✅ Strong parameters keep input safe; flash messages guide users
- ✅ Tests (RSpec or Minitest) protect the app as it grows
- ✅ You have a plan to deploy and extend it
- ✅ You finished the Ruby on Rails track — congratulations!
Practice quiz
In the blog, what associates a Post with its Comments?
- Post belongs_to :comments
- Post has_many :comments and Comment belongs_to :post
- Comment has_many :posts
- Both use has_and_belongs_to_many
Answer: Post has_many :comments and Comment belongs_to :post. A Post has_many :comments while each Comment belongs_to :post.
Which command scaffolds a brand-new blog project?
- rails generate blog
- rails new blog
- rails server blog
- bundle new blog
Answer: rails new blog. rails new blog creates the project directory.
After defining a Post model, what creates its database table?
- rails console
- rails db:migrate
- rails routes
- rails test
Answer: rails db:migrate. rails db:migrate runs the migration that builds the posts table.
Where do you whitelist the fields a Comment form may submit?
- In the model
- In the view
- In the routes file
- In a strong-parameters method via require/permit
Answer: In a strong-parameters method via require/permit. Strong parameters (params.require.permit) live in the controller.
Which validation stops a Post being saved with a blank title?
- validates :title, presence: true
- validates :title, length: 0
- validates :title, unique: true
- validates_title :present
Answer: validates :title, presence: true. presence: true rejects blank titles.
To require login before creating a post, you would use a:
- migration
- database index
- before_action that checks current_user
- flash message
Answer: before_action that checks current_user. A before_action filter enforces authentication before the action runs.
Which gem is commonly used to write tests for the blog?
- Webpacker
- RSpec
- Puma
- Sidekiq
Answer: RSpec. RSpec is a popular testing framework for Rails apps.
What does dependent: :destroy on Post has_many :comments ensure?
- Comments are archived
- Deleting a post deletes its comments
- Comments cannot be deleted
- Posts cannot be deleted
Answer: Deleting a post deletes its comments. It removes a post's comments when the post is destroyed.
What renders a list of posts in the index view?
- A migration loop
- Iterating @posts in the ERB template
- render json only
- The routes file
Answer: Iterating @posts in the ERB template. The index view iterates over @posts set by the controller.
A good final step before sharing the blog publicly is to:
- Delete the tests
- Deploy it to a hosting platform
- Remove all validations
- Disable CSRF protection
Answer: Deploy it to a hosting platform. Deploying makes the finished app available to real users.
Continue this course
- Previous: REST APIs & Authentication
- Next: Testing with RSpec — describe/it, matchers, let/subject, mocks and stubs — the dominant Ruby test tool
- Quick reference: Ruby cheat sheet
Frequently asked questions
How do the pieces of this blog fit together?
It is the whole track in one app. Models and migrations define the data (posts, comments, users). Associations connect them — a Post has_many :comments and belongs_to :user. Validations keep records clean (a post needs a title). Controllers receive requests, run strong parameters to whitelist fields, save records, and set flash messages. Views render forms with form_with and list posts. Authentication (has_secure_password plus sessions) identifies the user, and a before_action gate enforces login. Tests verify it all, and finally you deploy. Each lesson was one layer; the capstone stacks them.
Do I need RSpec, or can I use the built-in tests?
You can use either. Rails ships with Minitest by default, and it is perfectly capable for testing models, controllers, and full requests — no extra gems needed. RSpec is a popular alternative with a very readable describe/it syntax and a large ecosystem; you add the rspec-rails gem and run a generator to set it up. The testing concepts are the same in both: arrange a scenario, exercise the code, and assert the result. The capstone shows RSpec, but the same specs translate directly to Minitest if you prefer the default.
What is the simplest way to deploy a Rails app?
Push your code to a host that supports Rails and run your database migrations there. Platforms like Render, Fly.io, and Heroku can detect a Rails app, install gems, and serve it with a production web server such as Puma. The usual steps are: commit your code to git, configure a production database (commonly PostgreSQL), set required environment variables and the secret key base, push to the platform, and run bin/rails db:migrate on the server. Start simple — get the app live first, then add custom domains, background jobs, and caching as you need them.
Where should I go after finishing this track?
Build something real and keep the loop tight: add a feature, write a test, deploy. Strong next topics include background jobs (Active Job with Sidekiq) for slow work like sending email, caching for performance, Turbo and Stimulus (Hotwire) for snappy interactions without much JavaScript, and Action Mailer for email. On the data side, learn about query performance, indexes, and avoiding N+1 queries with includes. The official Rails Guides are excellent reference, and reading the source of a well-known open-source Rails app will teach you idioms no tutorial covers.