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

Banner image