A JSON API in Rails
rails new --api
--api generates a smaller Rails: no views, no asset pipeline, and a base controller that expects JSON. The commands need the gem, the network and a compiler, so they ran on the host in the ruby:3.4 image; the gem install rails output is in lesson 140.
gem install rails
rails new api --api
cd api
The model and the route
bin/rails generate model Todo title:string done:boolean
bin/rails db:migrate
The generator writes a model with no rules:
class Todo < ApplicationRecord
end
With that model an empty title is a valid row, and the error branch of the controller never runs. The host sent one; the id is 2 because a valid row with id 1 existed in the same run:
curl -s -w ' [%{http_code}]' -X POST localhost:3000/todos -H 'Content-Type: application/json' -d '{"todo":{"title":""}}'
{"id":2,"title":"","done":null,"created_at":"2026-11-05T00:43:05.177Z","updated_at":"2026-11-05T00:43:05.177Z"} [201]
Add the rule the API promises:
class Todo < ApplicationRecord
validates :title, presence: true
end
Two routes are enough for a list and a create. The only: option keeps the CRUD surface from growing by accident:
Rails.application.routes.draw do
resources :todos, only: [:index, :create]
end
A controller that renders JSON
render json: serialises the value with to_json and sets the content type. The status is the part that a client checks, so the success and the failure each say it:
class TodosController < ApplicationController
def index
render json: Todo.order(:created_at)
end
def create
todo = Todo.new(todo_params)
if todo.save
render json: todo, status: :created
else
render json: { errors: todo.errors.full_messages },
status: :unprocessable_entity
end
end
private
def todo_params
params.require(:todo).permit(:title, :done)
end
end
status: :created is 201 and :unprocessable_entity is 422. A client that looks only at the body cannot tell a created row from a rejected one, which is why the status is not optional.
Two requests
bin/rails server
The validation run started from an empty database, so the first list is empty:
curl -s localhost:3000/todos
[]
A valid title creates a row. The -w option appends the status, which is the part a client checks:
curl -s -w ' [%{http_code}]' -X POST localhost:3000/todos -H 'Content-Type: application/json' -d '{"todo":{"title":"write the lesson"}}'
{"id":1,"title":"write the lesson","done":null,"created_at":"2026-11-05T00:45:53.484Z","updated_at":"2026-11-05T00:45:53.484Z"} [201]
The same request with an empty title now reaches the error branch:
curl -s -w ' [%{http_code}]' -X POST localhost:3000/todos -H 'Content-Type: application/json' -d '{"todo":{"title":""}}'
{"errors":["Title can't be blank"]} [422]
curl -s localhost:3000/todos
[{"id":1,"title":"write the lesson","done":null,"created_at":"2026-11-05T00:45:53.484Z","updated_at":"2026-11-05T00:45:53.484Z"}]
The done column is null because the generated migration gives it no default. Model the column as t.boolean :done, default: false and a new row starts as not done, which is what the application means.
The server log shows the pair as Completed 201 Created in 103ms and Completed 422 Unprocessable Content in 13ms. A malformed body returns the 422 from the controller with the validation messages, and an unknown route returns the Rails 404. Both are worth one curl each, because a client that only sees the happy path discovers them in production.
What to watch for
API mode does not include CORS. A browser client on another origin needs
rack-cors and an explicit list of origins; the
failure looks like a network error in the console, not a 4xx.
render json: model exposes every column the model
has. A column added later is published without a decision, so a public
API renders an explicit hash or a serializer and keeps the schema private.
This is the last lesson of the chapter. The next chapter starts with Gems and Bundler.
Article author: Arthur Isaev