I remember scrolling through tutorial after tutorial, watching videos, reading documentation, and still feeling like I had never actually built anything that mattered.
I could copy code. I could follow along. But the moment someone said, "Now go build something on your own," I froze.
Then one evening, frustrated and a little tired, I decided to stop waiting for the perfect tutorial and just build a blog. Nothing fancy. Just posts. Titles. Content. A page where things actually showed up.
If you are at that same crossroads right now, this guide is for you. We are going to build a fully working blog together using Ruby on Rails 8, step by step, with real code and real explanations along the way.
Let us get started.
What You Will Build
By the end of this guide, you will have a working blog application that can:
- Display a list of articles
- Show a single article
- Create new articles
- Edit and delete existing articles
Simple. Focused. Real.
Prerequisites
Before we write a single line of code, make sure you have the following installed:
- Ruby 3.2 or higher
- Rails 8.0 or higher
- SQLite3 (comes bundled with Rails by default)
- A terminal you are comfortable using
To check your versions, run:
ruby -v
rails -v
If you see something like Ruby 3.3.0 and Rails 8.0.0, you are ready.
Step 1: Create Your Rails Application
Open your terminal and run:
rails new blog_app
cd blog_app
Rails will generate a complete project structure for you. You will see folders like app/, config/, db/, and more.
This is not magic. This is Rails giving you a well-organized home before you even write a line of your own code.
To verify everything works, start the server:
bin/rails server
Visit http://localhost:3000 in your browser. If you see the Rails welcome page, congratulations. Your app is alive.
Step 2: Understand the Structure Before You Touch It
Before we generate anything, take 60 seconds to look at what Rails created.
app/
controllers/ # Where your logic lives
models/ # Where your data lives
views/ # Where your pages live
config/
routes.rb # Where your URLs are defined
db/
schema.rb # Your database structure
If you want a deeper understanding of how Models, Views, and Controllers work together, read this:
Rails MVC Tutorial for Beginners. It will make everything in this guide feel even more natural.
Step 3: Generate the Article Scaffold
Rails has a powerful generator that creates everything you need in one command. Run this:
bin/rails generate scaffold Article title:string body:text
Here is what just happened. Rails created:
- A migration file to add an articles table to your database
- An Article model
- An ArticlesController with all CRUD actions
- Views for listing, showing, creating, and editing articles
- Routes for your article URLs
Now run the migration to apply the database changes:
bin/rails db:migrate
Start your server again and visit http://localhost:3000/articles.
You now have a working blog. But we are not done. Let us understand what was built and then make it better.
Step 4: Look at Your Routes
Open config/routes.rb. You will see:
Rails.application.routes.draw do
resources :articles
end
This single line creates seven routes for you. Run this command to see them all:
bin/rails routes
You will see routes like:
GET /articles articles#index
GET /articles/:id articles#show
GET /articles/new articles#new
POST /articles articles#create
GET /articles/:id/edit articles#edit
PATCH /articles/:id articles#update
DELETE /articles/:id articles#destroy
Seven routes. One line. That is Rails working for you.
Let us also set the home page of the application to the articles list. Update your routes.rb:
Rails.application.routes.draw do
root "articles#index"
resources :articles
end
Now visiting http://localhost:3000 will take you directly to your articles list.
Step 5: Explore the Controller
Open app/controllers/articles_controller.rb. You will find all seven actions already written for you.
class ArticlesController < ApplicationController
before_action :set_article, only: %i[ show edit update destroy ]
# GET /articles or /articles.json
def index
@articles = Article.all
end
# GET /articles/1 or /articles/1.json
def show
end
# GET /articles/new
def new
@article = Article.new
end
# GET /articles/1/edit
def edit
end
# POST /articles or /articles.json
def create
@article = Article.new(article_params)
respond_to do |format|
if @article.save
format.html { redirect_to @article, notice: "Article was successfully created." }
format.json { render :show, status: :created, location: @article }
else
format.html { render :new, status: :unprocessable_entity }
format.json { render json: @article.errors, status: :unprocessable_entity }
end
end
end
# PATCH/PUT /articles/1 or /articles/1.json
def update
respond_to do |format|
if @article.update(article_params)
format.html { redirect_to @article, notice: "Article was successfully updated.", status: :see_other }
format.json { render :show, status: :ok, location: @article }
else
format.html { render :edit, status: :unprocessable_entity }
format.json { render json: @article.errors, status: :unprocessable_entity }
end
end
end
# DELETE /articles/1 or /articles/1.json
def destroy
@article.destroy!
respond_to do |format|
format.html { redirect_to articles_path, notice: "Article was successfully destroyed.", status: :see_other }
format.json { head :no_content }
end
end
private
# Use callbacks to share common setup or constraints between actions.
def set_article
@article = Article.find(params.expect(:id))
end
# Only allow a list of trusted parameters through.
def article_params
params.expect(article: [ :title, :body ])
end
end
Notice how clean this is. Each action has one job. The controller coordinates. It does not cook the food, it just takes the order and delivers the result. Exactly like the waiter analogy you may have read about before.
Step 6: Add Validations to Your Model
Right now, someone could create an article with no title and no content. That is not a great experience.
Open app/models/article.rb and add:
class Article < ApplicationRecord
validates :title, presence: true, length: { minimum: 5 }
validates :body, presence: true, length: { minimum: 10 }
end
Now try creating an empty article. Rails will stop you and display the validation errors automatically because the scaffold already handles this in the views.
Your data is now protected. That is your Model doing its job as a guardian.
Step 7: Make the Views Feel Like Yours
Rails generated views for you, but let us look at the most important ones and understand what they do.
Open app/views/articles/index.html.erb:
<h1>Articles</h1>
<% @articles.each do |article| %>
<article>
<h2><%= link_to article.title, article %></h2>
<p><%= truncate(article.body, length: 100) %></p>
<%= link_to "Read more", article %>
</article>
<% end %>
<%= link_to "New Article", new_article_path, class: "btn btn-primary" %>
This is ERB, which stands for Embedded Ruby. The <% %> tags execute Ruby code. The <%= %> tags execute and output the result to the page.
Notice @articles comes from the controller. The view never touches the database directly. That separation is intentional and powerful.
Step 8: Add a Published Status
Let us add a real feature: the ability to publish or draft an article.
Generate a new migration:
bin/rails generate migration AddPublishedToArticles published:boolean
Open the generated migration file in db/migrate/ and update it to set a default value:
class AddPublishedToArticles < ActiveRecord::Migration[8.0]
def change
add_column :articles, :published, :boolean, default: false, null: false
end
end
Run the migration:
bin/rails db:migrate
Now update the model to use this field:
class Article < ApplicationRecord
validates :title, presence: true, length: { minimum: 5 }
validates :body, presence: true, length: { minimum: 10 }
scope :published, -> { where(published: true) }
scope :drafts, -> { where(published: false) }
end
And update the index action in your controller only to show published articles:
def index
@articles = Article.published
end
Now only articles with published: true will appear on your blog index page. Drafts stay hidden until you are ready.
But wait. If you try to create an article right now and check the published checkbox, nothing will actually get saved. Rails has a security layer called strong parameters, and it will silently ignore any field you have not explicitly allowed. Let us fix that.
Step 8.1: Permit the Published Param in the Controller
Open app/controllers/articles_controller.rb and find the article_params method at the bottom. It currently looks like this:
def article_params
params.expect(article: [ :title, :body ])
end
Update it to include :published:
def article_params
params.expect(article: [ :title, :body, :published ])
end
Notice the use of params.expect here. This is a cleaner Rails 8 syntax that replaces the older require(...).permit(...) pattern. It is more explicit and reads naturally: "I expect an article with these fields."
Without this change, even if a user checks the published checkbox in the form, Rails will strip the value out before saving. The column exists in the database, but the controller was never letting it through. Now it will.
Step 8.2: Add the Published Checkbox to the Form
The form that Rails generated for creating and editing articles lives in app/views/articles/_form.html.erb. This is a partial, which means it is shared between the new and edit pages.
Open it and add the following block before the submit button:
<div>
<%= form.label :published, style: "display: block" %>
<%= form.checkbox :published %>
</div>
This renders a labeled checkbox that is bound directly to the published attribute on the article. When the user checks it and submits the form, Rails will receive published: true. When left unchecked, it will pass published: false.
Combined with the scope you added to the model and the updated index action in the controller, the full flow now works end to end:
- A user creates an article and checks the published checkbox
- The form submits published: true
- The controller permits it through article_params
- Rails saves it to the database
- The index action fetches only Article.published
- The article appears on the blog
That is the complete published/draft system, fully wired up.
Step 9: Watch Out for N+1 Queries
This is where many developers hit a wall they did not see coming.
Imagine your articles now belong to authors. You might write:
def index
@articles = Article.published
end
And in the view:
<% @articles.each do |article| %>
<%= article.author.name %>
<% end %>
This looks harmless. But Rails is firing one database query for every single article to load the author. With 50 articles, that is 51 queries. With 500, it is 501.
The fix is simple:
def index
@articles = Article.published.includes(:author)
end
This is called eager loading. It tells Rails to fetch all the authors in one query instead of one per article.
It could save your app from a performance disaster the moment traffic picks up.
Step 10: Add Basic Styling
Rails 8 ships with a minimal setup that works great out of the box. You can add a CSS file to make your blog look a bit friendlier.
Open app/assets/stylesheets/application.css and add:
body {
font-family: Georgia, serif;
max-width: 800px;
margin: 0 auto;
padding: 2rem;
background-color: #fafafa;
color: #222;
}
h1, h2 {
color: #1a1a2e;
}
a {
color: #4a90d9;
text-decoration: none;
}
article a:hover {
text-decoration: underline;
}
article {
margin-bottom: 2rem;
}
strong {
display: block;
margin-bottom: .2rem;
}
.btn {
padding: .5rem 1rem;
font-size: 1rem;
border-radius: .2rem;
color: #fff;
cursor: pointer;
}
input, textarea {
border: 1px solid #ddd;
border-radius: .2rem;
}
.form-label {
display: block;
margin-bottom: .5rem;
}
.form-control {
display: block;
width: 100%;
padding: .375rem .75rem;
font-size: 1rem;
color: #212529;
background-color: #fff;
border: 1px solid #dee2e6;
}
.input-group {
margin-bottom: 1rem;
}
Step 10.1: Update the Form Partial to Use the New Classes
The CSS classes you just wrote will do nothing until the form knows about them. Open app/views/articles/_form.html.erb and replace everything inside it with:
<%= form_with(model: article) do |form| %>
<% if article.errors.any? %>
<div style="color: red">
<h2><%= pluralize(article.errors.count, "error") %> prohibited this article from being saved:</h2>
<ul>
<% article.errors.each do |error| %>
<li><%= error.full_message %></li>
<% end %>
</ul>
</div>
<% end %>
<div class="input-group">
<%= form.label :title, class: "form-label" %>
<%= form.text_field :title, class: "form-control" %>
</div>
<div class="input-group">
<%= form.label :body, class: "form-label" %>
<%= form.textarea :body, rows: 3, class: "form-control" %>
</div>
<div class="input-group">
<%= form.label :published %>
<%= form.checkbox :published %>
</div>
<div>
<%= form.submit class: "btn btn-primary" %>
</div>
<% end %>
Here is what changed and why it matters.
Each field is now wrapped in a div with the class input-group, which gives it consistent bottom spacing so the fields breathe. The title and body labels carry the class form-label, which makes them display as block elements with a small margin below, so the label sits cleanly above its input. The inputs themselves carry form-control, which adds padding, a readable font size, and a subtle border. The submit button has the btn class, which gives it a blue background with a hover effect.
The published checkbox keeps its simpler layout since checkboxes sit inline next to their label and do not need the full form-control treatment.
When you refresh the new article page now, the form will look polished and intentional instead of raw browser defaults. Small details like these are what make a project feel finished rather than just functional.
Not fancy. But clean. And yours.
Step 10.2: Style the Show Page
The show page is what your readers see when they click on an article. Right now, it uses the default scaffold output, which gets the job done but does not match the style you just built.
Open app/views/articles/show.html.erb and replace its contents with:
<p style="color: green"><%= notice %></p>
<%= render @article %>
<div>
<%= link_to "Edit this article", edit_article_path(@article), class: "btn btn-primary" %> |
<%= link_to "Back to articles", articles_path %>
<%= button_to "Destroy this article", @article, method: :delete, class: "btn btn-danger" %>
</div>
A few things worth noticing here. The flash notice at the top uses an inline green color to confirm successful actions like creating or updating an article. The render @article line is Rails shorthand that automatically looks for the _article.html.erb partial and renders it with the current article, which we will set up in the next step.
The Edit link carries the btn btn-primary class, and the Destroy button carries the btn btn-danger. You will need to add those two variants to your CSS. Open app/assets/stylesheets/application.css and add these after your existing .btn rules:
.btn-primary {
background-color: #4a90d9;
color: #fff;
}
.btn-primary:hover {
background-color: rgba(74, 144, 217, 0.95);
color: #fff;
}
.btn-danger {
background-color: #d9534f;
color: #fff;
border: none;
padding: .5rem 1rem;
margin-top: 1rem;
border-radius: .2rem;
cursor: pointer;
}
.btn-danger:hover {
background-color: rgba(217, 83, 79, 0.9);
}
This gives your Edit action a calm blue and your Destroy button a clear red, so readers and editors can always tell at a glance which action is which. Good design communicates intent before anyone reads a word.
Step 10.3: Style the Article Partial
The _article.html.erb partial is the piece that renders the actual article content on both the show page and anywhere else you want to reuse it. Right now, it probably shows raw field labels with no visual structure.
Open app/views/articles/_article.html.erb and replace its contents with:
<div id="<%= dom_id article %>">
<div class="article-detail">
<strong>Title:</strong>
<%= article.title %>
</div>
<div class="article-detail">
<strong>Body:</strong>
<%= article.body %>
</div>
</div>
The dom_id helper generates a unique HTML id for each article, like article_1 or article_5. This is useful if you ever want to target a specific article with JavaScript or CSS later.
Each field is wrapped in a div with the class article-detail. Add that class to your stylesheet:
.article-detail {
margin-bottom: 1rem;
line-height: 1.6;
}
This adds breathing room between the title and body, and the line-height makes the body text easier to read at a glance.
With these two steps done, your blog now has a consistent visual language from the list page all the way through to the individual article view. Every page feels like part of the same thing, because it is.
Step 11: Test Your Blog End to End
Let us do a quick walkthrough to make sure everything works.
Start the server:
bin/rails server
Visit http://localhost:3000/articles/new and create a new article. Set published to true.
Go to http://localhost:3000. Your article should appear.
Click on it. The show page should display the full content.
Try editing it. Try deleting it.
If everything works, you just built a blog from scratch.
What You Just Accomplished
Let us take a breath and look at what you built:
- A Rails 8 application with a real database
- Full CRUD for articles
- Validation to protect your data
- A published/draft system
- Awareness of performance with eager loading
- A working, styled blog you can show people
That is not a tutorial exercise. That is a foundation you can grow.
Where to Go From Here
Your blog works. But good developers keep pushing. Here are your next steps:
- Add user authentication with Devise
- Add categories or tags to your articles
- Add pagination with Pagy or Kaminari
- Deploy to Hetzner, Render, or Fly.io, so the world can see it
- Add ActionText for rich content editing
Every one of those features will teach you something new. Every one of them is a skill employers look for.
The most important thing you can do right now is not find the next tutorial. It is to open your terminal, run rails new, and build something else.
Because the developers who grow fastest are not the ones who read the most. They are the ones who build the most.
So keep building. Keep pushing. Keep going.