RoR Build a JSON API
A JSON API lets other applications talk to your Rails app programmatically. Mobile apps, JavaScript frontends, and third-party services all use APIs to fetch and send data. Rails makes building a JSON API straightforward — your controllers respond with JSON instead of HTML, and your routes stay the same.
API vs HTML Response
HTML Response (browser visit):
GET /articles
Returns: full HTML page with layout, nav, footer
JSON Response (API call):
GET /articles
Accept: application/json
Returns: { "articles": [ { "id": 1, "title": "..." }, ... ] }
The same controller action can serve both HTML and JSON using Rails format handling.
Option 1: API-Only Rails App
Create a lightweight Rails app with no views or asset pipeline:
rails new myapi --api
This strips out middleware and modules your API does not need, making it faster and lighter.
Option 2: Add JSON Responses to an Existing App
In any controller, respond to both HTML and JSON requests:
class ArticlesController < ApplicationController
def index
@articles = Article.all
respond_to do |format|
format.html ← renders index.html.erb
format.json { render json: @articles } ← returns JSON
end
end
def show
@article = Article.find(params[:id])
respond_to do |format|
format.html
format.json { render json: @article }
end
end
end
Test the JSON response:
curl http://localhost:3000/articles.json curl -H "Accept: application/json" http://localhost:3000/articles
Controlling What JSON Returns
By default, render json: @article serializes all columns. Control the output with only and except:
render json: @article, only: [:id, :title, :body, :created_at] render json: @article, except: [:updated_at, :user_id] render json: @articles, only: [:id, :title]
Custom JSON Structure
Build a custom hash to control exactly what the response looks like:
def show
@article = Article.find(params[:id])
render json: {
id: @article.id,
title: @article.title,
body: @article.body,
author: @article.user.name,
published: @article.published?,
created_at: @article.created_at.strftime("%Y-%m-%d")
}
end
Full CRUD JSON API Controller
class Api::V1::ArticlesController < ApplicationController
skip_before_action :verify_authenticity_token
before_action :set_article, only: [:show, :update, :destroy]
def index
articles = Article.all.order(created_at: :desc)
render json: articles, only: [:id, :title, :body, :created_at]
end
def show
render json: @article
end
def create
article = Article.new(article_params)
if article.save
render json: article, status: :created
else
render json: { errors: article.errors.full_messages }, status: :unprocessable_entity
end
end
def update
if @article.update(article_params)
render json: @article
else
render json: { errors: @article.errors.full_messages }, status: :unprocessable_entity
end
end
def destroy
@article.destroy
render json: { message: "Article deleted." }, status: :ok
end
private
def set_article
@article = Article.find(params[:id])
rescue ActiveRecord::RecordNotFound
render json: { error: "Article not found." }, status: :not_found
end
def article_params
params.require(:article).permit(:title, :body, :published)
end
end
API Versioning in Routes
Version your API so changes do not break existing clients:
config/routes.rb
namespace :api do
namespace :v1 do
resources :articles
resources :users, only: [:index, :show]
end
namespace :v2 do
resources :articles ← new version with different response format
end
end
This creates URLs like /api/v1/articles and /api/v2/articles. Clients on v1 keep working when you release v2.
HTTP Status Codes
| Code | Symbol | When to Use |
|---|---|---|
| 200 | :ok | Successful GET, PUT, PATCH, DELETE |
| 201 | :created | Successful POST (new record created) |
| 204 | :no_content | Successful DELETE with no body returned |
| 400 | :bad_request | Malformed request from client |
| 401 | :unauthorized | Authentication required |
| 403 | :forbidden | Authenticated but not permitted |
| 404 | :not_found | Record does not exist |
| 422 | :unprocessable_entity | Validation errors |
| 500 | :internal_server_error | Server crashed |
Token Authentication for APIs
Browser sessions do not work for API clients. Use token-based authentication instead:
rails generate migration AddApiTokenToUsers api_token:string
rails db:migrate
class User < ApplicationRecord
before_create :generate_api_token
private
def generate_api_token
self.api_token = SecureRandom.hex(24)
end
end
class ApplicationController < ActionController::API
def authenticate_api_user!
token = request.headers["Authorization"]&.split(" ")&.last
@current_user = User.find_by(api_token: token)
render json: { error: "Unauthorized" }, status: :unauthorized unless @current_user
end
end
API clients send the token in the Authorization header:
curl -H "Authorization: Bearer abc123yourtokenhere" http://localhost:3000/api/v1/articles
JSON Serializers
For large APIs with complex response structures, use a serializer gem like jsonapi-serializer:
gem "jsonapi-serializer" bundle install rails generate serializer Article id title body published created_at
app/serializers/article_serializer.rb class ArticleSerializer include JSONAPI::Serializer attributes :id, :title, :body, :published, :created_at belongs_to :user has_many :comments end
In controller: render json: ArticleSerializer.new(@article).serializable_hash render json: ArticleSerializer.new(@articles).serializable_hash
Serializers separate your JSON shape from your model, making it easy to change the API response without touching business logic.
