Ruby on Rails Tutorial: Build a Complete User CRUD Application with Bootstrap and Secure Authentication

Ruby on Rails crud Ruby on Rails

Ruby on Rails Tutorial: Build a Complete User CRUD Application with Bootstrap and Secure Authentication

AI Reading

Quick summary of this article

This tutorial provides a complete, step-by-step guide to building a user management system with Ruby on Rails. It covers everything from database setup and model creation to building a responsive Bootstrap 5 interface and writing automated tests. The focus is on creating a secure CRUD application with password hashing, data validation, and clean RESTful routes.

  • Uses Rails' `resources :users` route to automatically generate the 7 standard RESTful actions (index, new, create, show, edit, update, destroy).
  • Implements secure authentication with `has_secure_password` (BCrypt) and includes validations for name, email format, uniqueness, and password length.
  • Builds a reusable form partial with live error messages and a responsive Bootstrap 5 UI featuring tables, cards, and action buttons.
  • Includes automated tests (model and integration) to verify user creation, updates, deletion, validations, and email normalization.
  • Uses SQLite as the default database with no extra configuration needed, but offers flexibility to switch to PostgreSQL or MySQL.

Ruby on Rails Tutorial: Build a Complete User CRUD Application with Bootstrap and Secure Authentication

In this step-by-step tutorial, you will learn how to build a full-featured CRUD (Create, Read, Update, Delete) application in Ruby on Rails from scratch. We will build a complete User Management System with secure password hashing (BCrypt), data validations, clean RESTful controller actions, a modern Bootstrap 5 UI, and automated tests.

AI Reading

Quick summary of this tutorial

  • Learn how the 7 RESTful routes map to CRUD actions in Rails controllers.
  • Implement secure authentication with has_secure_password and Active Record validations.
  • Create reusable ERB form partials with live validation error feedback.
  • Style views with responsive Bootstrap 5 tables, cards, and high-contrast action buttons.
  • Write and execute automated unit and integration tests to ensure code reliability.

🗄️ Step 0: Database Configuration (SQLite by Default)

Rails uses SQLite as the default development database. No extra configuration is required. If you prefer PostgreSQL or MySQL, change the gem in your Gemfile and update config/database.yml. For this tutorial, we stick with SQLite.

Make sure your config/database.yml looks like this:

# config/database.yml
default: &default
  adapter: sqlite3
  pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
  timeout: 5000

development:
  <<: *default
  database: db/development.sqlite3

test:
  <<: *default
  database: db/test.sqlite3

production:
  <<: *default
  database: db/production.sqlite3

Then run:

rails db:create
rails db:migrate

🚀 Step 1: Generate the Database Migration

rails generate migration CreateUsers first_name:string last_name:string email:string password_digest:string
rails db:migrate

The generated migration in db/migrate/xxxxxx_create_users.rb looks like this:

class CreateUsers < ActiveRecord::Migration[8.0]
  def change
    create_table :users do |t|
      t.string :first_name
      t.string :last_name
      t.string :email
      t.string :password_digest

      t.timestamps
    end
  end
end

💾 Step 2: Implement the User Model

Open app/models/user.rb and paste:

# app/models/user.rb
class User < ApplicationRecord
  has_secure_password

  before_save :normalize_email

  validates :first_name, presence: true, length: { maximum: 50 }
  validates :last_name, presence: true, length: { maximum: 50 }
  validates :email, presence: true,
                    uniqueness: { case_sensitive: false },
                    format: { with: URI::MailTo::EMAIL_REGEXP },
                    length: { maximum: 255 }
  validates :password, length: { minimum: 6 }, allow_nil: true

  def full_name
    "#{first_name} #{last_name}".strip
  end

  private

  def normalize_email
    self.email = email.downcase.strip if email.present?
  end
end

🔗 Step 3: Configure RESTful Routes

# config/routes.rb
Rails.application.routes.draw do
  resources :users
  root "users#index"
end

This provides 7 standard routes:

HTTP Verb Path Controller#Action Purpose
GET /users users#index Display all users
GET /users/new users#new Render form for new user
POST /users users#create Save new user to database
GET /users/:id users#show Display specific user details
GET /users/:id/edit users#edit Render form to edit user
PATCH/PUT /users/:id users#update Save updated user data
DELETE /users/:id users#destroy Delete a user record

🔧 Step 4: Build the Users Controller

# app/controllers/users_controller.rb
class UsersController < ApplicationController
  before_action :set_user, only: %i[ show edit update destroy ]

  # GET /users
  def index
    @users = User.all.order(created_at: :desc)
  end

  # GET /users/1
  def show
  end

  # GET /users/new
  def new
    @user = User.new
  end

  # GET /users/1/edit
  def edit
  end

  # POST /users
  def create
    @user = User.new(user_params)

    respond_to do |format|
      if @user.save
        format.html { redirect_to user_url(@user), notice: "User was successfully created." }
        format.json { render :show, status: :created, location: @user }
      else
        format.html { render :new, status: :unprocessable_entity }
        format.json { render json: @user.errors, status: :unprocessable_entity }
      end
    end
  end

  # PATCH/PUT /users/1
  def update
    respond_to do |format|
      if @user.update(user_update_params)
        format.html { redirect_to user_url(@user), notice: "User was successfully updated." }
        format.json { render :show, status: :ok, location: @user }
      else
        format.html { render :edit, status: :unprocessable_entity }
        format.json { render json: @user.errors, status: :unprocessable_entity }
      end
    end
  end

  # DELETE /users/1
  def destroy
    @user.destroy!

    respond_to do |format|
      format.html { redirect_to users_url, status: :see_other, notice: "User was successfully deleted." }
      format.json { head :no_content }
    end
  end

  private

  def set_user
    @user = User.find(params[:id])
  rescue ActiveRecord::RecordNotFound
    redirect_to users_url, alert: "User not found."
  end

  def user_params
    params.require(:user).permit(:first_name, :last_name, :email, :password, :password_confirmation)
  end

  def user_update_params
    params_to_update = user_params
    if params_to_update[:password].blank?
      params_to_update = params_to_update.except(:password, :password_confirmation)
    end
    params_to_update
  end
end

🎨 Step 5: Design Responsive Bootstrap Views

5.1 The Shared Form Partial (app/views/users/_form.html.erb)

<%= form_with(model: user, local: true) do |form| %>
  <% if user.errors.any? %>
    <div class="alert alert-danger" role="alert">
      <h5 class="alert-heading"><%= pluralize(user.errors.count, "error") %> prohibited this user from being saved:</h5>
      <ul class="mb-0 ps-3">
        <% user.errors.full_messages.each do |message| %>
          <li><%= message %></li>
        <% end %>
      </ul>
    </div>
  <% end %>

  <div class="row g-3">
    <div class="col-md-6 mb-3">
      <%= form.label :first_name, "First Name", class: "form-label fw-semibold" %>
      <%= form.text_field :first_name, class: "form-control", placeholder: "e.g. John", required: true %>
    </div>

    <div class="col-md-6 mb-3">
      <%= form.label :last_name, "Last Name", class: "form-label fw-semibold" %>
      <%= form.text_field :last_name, class: "form-control", placeholder: "e.g. Doe", required: true %>
    </div>
  </div>

  <div class="mb-3">
    <%= form.label :email, "Email Address", class: "form-label fw-semibold" %>
    <%= form.email_field :email, class: "form-control", placeholder: "name@example.com", required: true %>
  </div>

  <div class="row g-3">
    <div class="col-md-6 mb-3">
      <%= form.label :password, class: "form-label fw-semibold" %>
      <% if user.persisted? %>
        <small class="text-muted d-block mb-1">(Leave blank to keep current password)</small>
      <% end %>
      <%= form.password_field :password, class: "form-control", placeholder: user.persisted? ? "New password (optional)" : "At least 6 characters", required: user.new_record? %>
    </div>

    <div class="col-md-6 mb-3">
      <%= form.label :password_confirmation, "Confirm Password", class: "form-label fw-semibold" %>
      <% if user.persisted? %>
        <small class="text-muted d-block mb-1">&nbsp;</small>
      <% end %>
      <%= form.password_field :password_confirmation, class: "form-control", placeholder: "Re-type password", required: user.new_record? %>
    </div>
  </div>

  <div class="d-flex justify-content-between align-items-center mt-4 pt-2 border-top">
    <%= link_to "Cancel", users_path, class: "btn btn-outline-secondary" %>
    <%= form.submit(user.persisted? ? "Update User" : "Create User", class: "btn btn-primary px-4") %>
  </div>
<% end %>

5.2 The Index List (app/views/users/index.html.erb)

<div class="container mt-4">
  <div class="d-flex justify-content-between align-items-center mb-4">
    <h1 class="h2 mb-0">Users Management</h1>
    <%= link_to "Add New User", new_user_path, class: "btn btn-primary" %>
  </div>

  <% if @users.any? %>
    <div class="card shadow-sm border-0">
      <div class="table-responsive">
        <table class="table table-hover align-middle mb-0">
          <thead class="table-dark">
            <tr>
              <th scope="col" class="ps-3" style="width: 50px;">#</th>
              <th scope="col">Name</th>
              <th scope="col">Email</th>
              <th scope="col">Created At</th>
              <th scope="col" class="text-end pe-3" style="min-width: 220px;">Actions</th>
            </tr>
          </thead>
          <tbody>
            <% @users.each_with_index do |user, index| %>
              <tr>
                <td class="ps-3 text-muted"><%= index + 1 %></td>
                <td><strong><%= user.full_name %></strong></td>
                <td><a href="mailto:<%= user.email %>" class="text-decoration-none"><%= user.email %></a></td>
                <td><%= user.created_at.strftime("%b %d, %Y") %></td>
                <td class="text-end pe-3">
                  <%= link_to "View", user_path(user), class: "btn btn-sm btn-outline-info me-1" %>
                  <%= link_to "Edit", edit_user_path(user), class: "btn btn-sm btn-outline-warning me-1" %>
                  <%= button_to "Delete", user_path(user), method: :delete, class: "btn btn-sm btn-outline-danger", form: { style: 'display: inline-block;' }, data: { confirm: "Are you sure?" } %>
                </td>
              </tr>
            <% end %>
          </tbody>
        </table>
      </div>
    </div>
  <% else %>
    <div class="alert alert-info text-center" role="alert">
      No users found. <%= link_to "Create the first user", new_user_path, class: "alert-link" %>.
    </div>
  <% end %>
</div>

5.3 The Show View (app/views/users/show.html.erb)

<div class="container mt-4">
  <div class="card shadow-sm border-0">
    <div class="card-header bg-primary text-white">
      <h2 class="h4 mb-0">User Details</h2>
    </div>
    <div class="card-body">
      <dl class="row">
        <dt class="col-sm-3 fw-semibold">Full Name</dt>
        <dd class="col-sm-9"><%= @user.full_name %></dd>

        <dt class="col-sm-3 fw-semibold">Email</dt>
        <dd class="col-sm-9"><%= @user.email %></dd>

        <dt class="col-sm-3 fw-semibold">Created At</dt>
        <dd class="col-sm-9"><%= @user.created_at.strftime("%B %d, %Y at %I:%M %p") %></dd>

        <dt class="col-sm-3 fw-semibold">Updated At</dt>
        <dd class="col-sm-9"><%= @user.updated_at.strftime("%B %d, %Y at %I:%M %p") %></dd>
      </dl>

      <div class="d-flex gap-2 mt-3">
        <%= link_to "Edit", edit_user_path(@user), class: "btn btn-warning" %>
        <%= link_to "Back to Users", users_path, class: "btn btn-secondary" %>
        <%= button_to "Delete", @user, method: :delete, class: "btn btn-danger", data: { confirm: "Are you sure?" } %>
      </div>
    </div>
  </div>
</div>

5.4 The New View (app/views/users/new.html.erb)

<div class="container mt-4">
  <div class="row justify-content-center">
    <div class="col-lg-8">
      <div class="card shadow-sm border-0">
        <div class="card-header bg-success text-white">
          <h2 class="h4 mb-0">Create New User</h2>
        </div>
        <div class="card-body">
          <%= render "form", user: @user %>
        </div>
      </div>
    </div>
  </div>
</div>

5.5 The Edit View (app/views/users/edit.html.erb)

<div class="container mt-4">
  <div class="row justify-content-center">
    <div class="col-lg-8">
      <div class="card shadow-sm border-0">
        <div class="card-header bg-warning text-dark">
          <h2 class="h4 mb-0">Edit User</h2>
        </div>
        <div class="card-body">
          <%= render "form", user: @user %>
        </div>
      </div>
    </div>
  </div>
</div>

5.6 Flash Messages and Layout

Add this inside <body> of app/views/layouts/application.html.erb (before <%= yield %>):

<div class="container mt-3">
  <% flash.each do |type, message| %>
    <div class="alert alert-<%= type == 'notice' ? 'success' : 'danger' %> alert-dismissible fade show" role="alert">
      <%= message %>
      <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
    </div>
  <% end %>
</div>

And include Bootstrap 5 in the <head>:

<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>

🧪 Step 6: Write Automated Tests

6.1 Model Test (test/models/user_test.rb)

require "test_helper"

class UserTest < ActiveSupport::TestCase
  def setup
    @user = User.new(
      first_name: "John",
      last_name: "Doe",
      email: "john@example.com",
      password: "password123",
      password_confirmation: "password123"
    )
  end

  test "should be valid" do
    assert @user.valid?
  end

  test "first_name should be present" do
    @user.first_name = "   "
    assert_not @user.valid?
  end

  test "last_name should be present" do
    @user.last_name = "   "
    assert_not @user.valid?
  end

  test "email should be present" do
    @user.email = "   "
    assert_not @user.valid?
  end

  test "email should be unique (case insensitive)" do
    duplicate_user = @user.dup
    duplicate_user.email = @user.email.upcase
    @user.save
    assert_not duplicate_user.valid?
  end

  test "email should be normalized to lowercase" do
    @user.email = "JOHN@EXAMPLE.COM"
    @user.save
    assert_equal "john@example.com", @user.reload.email
  end

  test "password should be at least 6 characters" do
    @user.password = "12345"
    @user.password_confirmation = "12345"
    assert_not @user.valid?
  end

  test "full_name returns concatenated first and last name" do
    assert_equal "John Doe", @user.full_name
  end
end

6.2 Integration Test (test/integration/users_test.rb)

require "test_helper"

class UsersTest < ActionDispatch::IntegrationTest
  def setup
    @user = User.create!(
      first_name: "Jane",
      last_name: "Smith",
      email: "jane@example.com",
      password: "password",
      password_confirmation: "password"
    )
  end

  test "index page shows users" do
    get users_path
    assert_response :success
    assert_select "h1", "Users Management"
    assert_select "td", text: "Jane Smith"
  end

  test "can create a new user" do
    get new_user_path
    assert_response :success

    assert_difference "User.count", 1 do
      post users_path, params: {
        user: {
          first_name: "Bob",
          last_name: "Builder",
          email: "bob@example.com",
          password: "secret123",
          password_confirmation: "secret123"
        }
      }
      follow_redirect!
    end

    assert_response :success
    assert_select "div.alert-success", text: /User was successfully created/
  end

  test "can update a user" do
    patch user_path(@user), params: {
      user: {
        first_name: "Janet"
      }
    }
    follow_redirect!
    assert_response :success
    assert_select "div.alert-success", text: /User was successfully updated/
    @user.reload
    assert_equal "Janet", @user.first_name
  end

  test "can delete a user" do
    assert_difference "User.count", -1 do
      delete user_path(@user)
      follow_redirect!
    end
    assert_response :success
    assert_select "div.alert-success", text: /User was successfully deleted/
  end

  test "edit form shows validation errors" do
    get edit_user_path(@user)
    patch user_path(@user), params: {
      user: {
        first_name: "",
        email: "invalid"
      }
    }
    assert_response :unprocessable_entity
    assert_select "div.alert-danger"
  end
end

Run tests with:

rails test

✅ Step 7: Run the Server

rails server

Visit http://localhost:3000 and start managing your users!


🎉 Final Notes

  • All CRUD operations work out‑of‑the‑box.
  • Passwords are securely hashed using BCrypt.
  • Bootstrap 5 gives a modern, responsive UI.
  • Tests ensure your code is reliable.

You now have a complete Rails CRUD application. Happy coding! 🚀

Leave a Reply

Your email address will not be published. Required fields are marked *