Welcome back to the "Ruby for AI" series. Up to this point, we have mostly been building Rails apps the classic way: models, controllers, views, forms, sessions. That is still useful. But once you start building AI products, internal tools, chat backends, agent workflows, or model-powered services, you often stop thinking in terms of HTML pages and start thinking in terms of APIs.
That is where Rails API mode becomes a very strong fit.
In this post, we will cover what Rails API mode is, how to create an API-only Rails app, how to return clean JSON, where serializers fit in, and why versioning matters once clients start depending on your endpoints.
The goal is not to turn Rails into some trendy microservice religion. The goal is to show you how Rails can become a clean, boring, reliable JSON backend for AI products.
What Rails API mode actually is
A normal Rails app gives you the full stack:
- views
- helpers
- cookies and sessions
- asset pipeline
- browser-oriented middleware
That is great when your app renders HTML directly.
But if your client is instead:
- a React frontend
- a mobile app
- a Chrome extension
- another service
- an AI agent or model workflow
then much of that browser-oriented stack is unnecessary.
Rails API mode is a slimmer Rails setup designed to serve JSON and act like an application backend rather than a server-rendered website.
You create one like this:
bin/rails new ai_backend --api
cd ai_backend
That --api flag removes a bunch of things you do not need for API-first work and keeps the parts you do need: routing, controllers, models, Active Record, validations, jobs, mailers if you want them, and the rest of Rails' backend muscle.
Why API mode makes sense for AI builders
AI apps usually have at least one of these patterns:
- frontend sends a prompt to the backend
- backend calls a model provider
- backend stores prompts, outputs, usage, and metadata
- clients poll or stream job status
- multiple consumers use the same backend
That is API territory.
For example, imagine a prompt playground app:
- web app sends prompt text to
/api/v1/completions - backend stores the request
- background job calls the LLM
- result gets saved and returned as JSON
Or maybe you are building an internal workflow tool:
- upload CSV
- backend processes rows
- AI classifies them
- frontend queries job progress via API
In both cases, Rails API mode is a natural fit. You still get Rails productivity, just without pretending you are building a page-rendered blog engine.
Build a simple API resource
Let us build a small resource called PromptRun.
bin/rails generate model PromptRun prompt:text output:text status:string model_name:string
bin/rails db:migrate
Now add a controller.
# app/controllers/prompt_runs_controller.rb
class PromptRunsController < ApplicationController
def index
prompt_runs = PromptRun.order(created_at: :desc)
render json: prompt_runs
end
def show
prompt_run = PromptRun.find(params[:id])
render json: prompt_run
end
def create
prompt_run = PromptRun.new(prompt_run_params.merge(status: "queued"))
if prompt_run.save
render json: prompt_run, status: :created
else
render json: { errors: prompt_run.errors.full_messages }, status: :unprocessable_entity
end
end
private
def prompt_run_params
params.require(:prompt_run).permit(:prompt, :model_name)
end
end
And wire the routes:
# config/routes.rb
Rails.application.routes.draw do
resources :prompt_runs, only: [:index, :show, :create]
end
That is already a working JSON API.
Example request:
curl -X POST http://localhost:3000/prompt_runs \
-H "Content-Type: application/json" \
-d '{
"prompt_run": {
"prompt": "Summarize this support ticket",
"model_name": "gpt-4.1"
}
}'
Example response:
{
"id": 1,
"prompt": "Summarize this support ticket",
"output": null,
"status": "queued",
"model_name": "gpt-4.1",
"created_at": "2026-04-04T09:00:00Z",
"updated_at": "2026-04-04T09:00:00Z"
}
That is the core pattern. The frontend or another service talks to Rails over HTTP, and Rails responds with structured JSON.
Why plain render json: is not enough forever
For tiny apps, this is fine:
render json: prompt_run
But once your API grows, this gets messy fast.
Problems show up like this:
- too many fields leaking out
- inconsistent response shapes
- duplicated formatting logic
- hard-to-change contracts once clients depend on them
That is where serializers help.
Use serializers to control your JSON shape
One clean option is jsonapi-serializer.
Add the gem:
gem "jsonapi-serializer"
Then:
bundle install
Now define a serializer.
# app/serializers/prompt_run_serializer.rb
class PromptRunSerializer
include JSONAPI::Serializer
attributes :prompt, :output, :status, :model_name, :created_at
end
Use it in the controller:
# app/controllers/prompt_runs_controller.rb
class PromptRunsController < ApplicationController
def show
prompt_run = PromptRun.find(params[:id])
render json: PromptRunSerializer.new(prompt_run).serializable_hash
end
end
Now your response shape is explicit and predictable.
That matters a lot for AI systems, because clients often become brittle when contracts drift. If your frontend, agent, worker, or partner integration expects one JSON structure and you casually change it, things break.
Add versioning before you think you need it
Beginners often skip versioning because it feels premature. Then six weeks later they have a frontend depending on one shape, an internal script depending on another, and no clean migration path.
Do this early instead:
# config/routes.rb
Rails.application.routes.draw do
namespace :api do
namespace :v1 do
resources :prompt_runs, only: [:index, :show, :create]
end
end
end
Then place your controller here:
# app/controllers/api/v1/prompt_runs_controller.rb
module Api
module V1
class PromptRunsController < ApplicationController
def index
prompt_runs = PromptRun.order(created_at: :desc)
render json: PromptRunSerializer.new(prompt_runs).serializable_hash
end
def show
prompt_run = PromptRun.find(params[:id])
render json: PromptRunSerializer.new(prompt_run).serializable_hash
end
def create
prompt_run = PromptRun.new(prompt_run_params.merge(status: "queued"))
if prompt_run.save
render json: PromptRunSerializer.new(prompt_run).serializable_hash,
status: :created
else
render json: { errors: prompt_run.errors.full_messages },
status: :unprocessable_entity
end
end
private
def prompt_run_params
params.require(:prompt_run).permit(:prompt, :model_name)
end
end
end
end
Now clients call /api/v1/prompt_runs.
Later, if you need a different response shape or auth strategy, you can introduce /api/v2 without breaking old consumers immediately.
That is not overengineering. That is just being less annoying to future-you.
A realistic AI workflow shape
Here is a more realistic controller flow for AI builders:
class Api::V1::PromptRunsController < ApplicationController
def create
prompt_run = PromptRun.new(prompt_run_params.merge(status: "queued"))
if prompt_run.save
RunPromptJob.perform_later(prompt_run.id)
render json: PromptRunSerializer.new(prompt_run).serializable_hash,
status: :accepted
else
render json: { errors: prompt_run.errors.full_messages },
status: :unprocessable_entity
end
end
end
And the job:
# app/jobs/run_prompt_job.rb
class RunPromptJob < ApplicationJob
queue_as :default
def perform(prompt_run_id)
prompt_run = PromptRun.find(prompt_run_id)
prompt_run.update!(status: "running")
# Pretend this is your model provider call
output = "Processed: #{prompt_run.prompt}"
prompt_run.update!(
output: output,
status: "completed"
)
rescue => e
prompt_run&.update!(status: "failed", output: e.message)
end
end
This is a classic Rails API pattern for AI work:
- API receives request
- record gets stored
- background job does model work
- client polls for results
Simple. Reliable. Easy to reason about.
Authentication in API mode is different
In a normal Rails app, sessions and cookies are common. In API mode, you often lean toward token-based auth instead.
That might be:
- bearer tokens
- JWT
- API keys
- session cookie auth for first-party SPAs if you choose it deliberately
The important thing is not blindly copying browser assumptions into an API app.
If your API serves mobile clients, integrations, or agent systems, tokens usually make more sense than classic session flows.
Common mistakes in Rails API projects
1. Treating it like a JSON dump
An API is a contract. Shape your responses intentionally.
2. Skipping status codes
Use the right ones:
200 OK201 Created202 Accepted422 Unprocessable Entity404 Not Found
Do not just return 200 for everything and hope the client guesses what happened.
3. Doing long model calls inline
If an AI request can take time, push it into a job. Do not make your controller hold the request open forever unless you are intentionally streaming.
4. Forgetting versioning
Version early. It saves pain later.
Final takeaway
Rails API mode is not some second-class Rails setup. For AI builders, it is often the more natural mode.
You still get:
- Active Record
- validations
- jobs
- routing
- controller structure
- all the Rails productivity people like
But you drop the browser-heavy parts you do not need and focus on returning clean JSON.
That is exactly what many AI products need: a solid backend that can accept requests, store state, enqueue work, and serve structured responses.
In the next Ruby for AI post, we will move into Hotwire and Turbo, where Rails starts getting very interesting again for building fast AI interfaces without drowning in frontend complexity.
Top comments (0)