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

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
end

2️⃣ 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
end

3️⃣ 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 blank

Capstone 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 here

Common Errors (and the fix)

Pro Tips

📋 Quick Reference — The Whole Rails Track

LessonKey IdeaSignature Code
Intro to RailsMVC, convention over configurationrails new blog
ActiveRecordModels & migrationsrails db:migrate
AssociationsRelate modelshas_many :comments
Validations & CallbacksClean, reactive datavalidates :title, presence: true
Forms & Strong ParamsSafe user inputparams.require(:post).permit(:title)
REST APIs & AuthJSON + identityhas_secure_password
CapstoneBuild, test, deploya complete blog app

🎉 Track Complete!

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

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.