How To Build A Blog With Ruby On Rails 8(Step-by-Step Guide)

Learn how to build a blog with Ruby on Rails 8 step by step. Real code, real examples, and beginner-friendly explanations.

Jean Emmanuel Cadet
By Jean Emmanuel Cadet Full-Stack Ruby on Rails Developer
How to Build a Blog with Ruby on Rails 8(Step-by-Step Guide)

• 14 minutes read

Share with friends
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:

  1. A user creates an article and checks the published checkbox
  2. The form submits published: true
  3. The controller permits it through article_params
  4. Rails saves it to the database
  5. The index action fetches only Article.published
  6. 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.

If you want to go deep on this topic and learn how to spot and fix these issues like a pro, read this: Fix N+1 Queries in Rails: A Developer's Journey to Speed.

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.

💌 Don’t miss out! Join my newsletter for web development tips, tutorials, and insights delivered straight to your inbox.

Thanks for reading & Happy coding! 🚀

Follow me on:

From My Dev Desk — Code, Curiosity & Coffee

A friendly newsletter where I share: Tips, lessons, and small wins from my dev journey, straight to your inbox.

    No spam. Unsubscribe anytime.