LaunchKit

A JSON API in Rails

What ActionController::API actually changes, why an application that serves HTML can still ship a JSON module, and how the /api/v1 namespace in this boilerplate is versioned, gated and documented.

A Rails application that already serves HTML gets asked for a JSON API at a predictable moment: the first mobile client, the first customer integration, the first script somebody on the team wants to run against production. The question that arrives with it is whether rails new --api was the right call eight months ago. Usually the answer is that the choice was never as total as it looks, because the thing doing the work is a controller superclass and nothing stops one application from using both.

What Rails hands you before you write a controller

ActionController::API is described in its own class documentation as "a lightweight version of ActionController::Base, created for applications that don't require all functionalities that a complete Rails controller provides". The MODULES list it composes in Action Pack 8.1 carries StrongParameters, RateLimiting, Renderers::All, ConditionalGet, BasicImplicitRender, DataStreaming, Caching, DefaultHeaders, Logging, AbstractController::Callbacks, Rescue, Instrumentation and ParamsWrapper. What is not in that list is the browser half: no cookies, no flash, no CSRF protection, no layouts and no template rendering.

ParamsWrapper being in the list is the entry that surprises people, because it is the one that changes what params contains. config.load_defaults 7.0 and every version above it set action_controller.wrap_parameters_by_default to true, and the Action Pack railtie turns that into wrap_parameters format: [:json]. On Api::V1::UsersController in this codebase the resolved wrapper key is "user", derived from the controller name. So a client that sends {"name": "Jane"} with a JSON content type has that body re-wrapped before params.expect(user: %i[name company_name]) reads it, and a client that sends {"user": {"name": "Jane"}} works too. Two different request bodies, one passing test, and the reason is a module you did not know you had included.

API-only mode is a decision about the application, not about a controller

The Rails guide on API applications lists exactly three things rails new --api does: it configures the application "to start with a more limited set of middleware than normal", it makes ApplicationController inherit from ActionController::API instead of ActionController::Base, and it configures "the generators to skip generating views, helpers, and assets when you generate a new resource". The middleware half is the only one that is genuinely application-wide, and it is the half that is awkward to undo, because it is what removes cookie support from every request the app will ever serve.

This boilerplate takes the other branch. config.api_only reads false, config/application.rb requires action_view/railtie and action_cable/engine alongside the rest, and the product is a full HTML application with Turbo Streams, a Stripe checkout and an admin console. The JSON API is one namespace inside it, sitting on the thinner controller stack by inheritance rather than by configuration. API-only mode, and whether you want it works through what the shorter middleware stack actually contains, what breaks when a browser client needs a cookie anyway, and the case for starting a mobile backend with the flag on.

The cost of the choice made here is real and worth naming: an HTML application carries middleware and gems the API endpoints never touch, and boot time and memory pay for that. What buying it back would cost is a second deployable, two Gemfiles and a shared model layer that now has to be a gem or an engine. For a product sold as one repository, one application is the cheaper answer.

The version is in the URL from the first commit

config/routes.rb declares the API as two nested namespaces and two singular resources:

namespace :api do
  namespace :v1 do
    resource :session, only: :create   # POST /api/v1/session -> token
    resource :user, only: %i[show update] # GET/PATCH /api/v1/user (token holder)
  end
end

Singular resource rather than plural resources is the detail to read twice. GET /api/v1/user has no id segment in it, because the only user this endpoint will ever return is the one the token names. An id in that URL would be an invitation to pass somebody else's.

Nesting v1 from the first endpoint costs one directory level and buys the ability to add a second version without touching the first. Versioning a Rails API covers the alternatives that put the version in an Accept header or a query parameter, why the URL version is the one almost everybody actually ships, and the part nobody enjoys, which is what you do with Api::V1::BaseController once Api::V2::BaseController exists and the two disagree.

One exchange for a token, then the token carries the request

POST /api/v1/session takes an email address and a password, runs them through User.authenticate_by, checks user&.confirmed?, and answers 201 with a token and a serialized user. Everything after that is Authorization: Bearer <token>, pulled out of request.authorization by a regular expression in Api::V1::BaseController and resolved by Api::Auth::VerifyToken, which rescues JWT::DecodeError and returns nil rather than raising. Tokens are HS256, signed with AppConfig.jwt_secret, which is credentials.jwt_secret when set and secret_key_base when not, and they expire 24 hours after they are issued.

That is the whole mechanism, and the page that argues about it is one hub over: Rails JWT API authentication covers the sub and exp claims, the constant-time credential check, and the thing a stateless token cannot do, which is stop working before its expiry.

A switched-off API answers 404, and decides that first

class BaseController < ActionController::API
  include FeatureGated

  gated_by :api

  before_action :authenticate_api_user!
end

gated_by comes from app/controllers/concerns/feature_gated.rb and installs its check with prepend_before_action, not before_action. Order is the entire point. A disabled API is unreachable before authenticate_api_user! has run, so a request with no token at all gets head :not_found, and a request with a perfectly good token gets the same. Nothing in the response distinguishes a module the operator turned off from a route that was never written.

The alternative ordering is the one a reasonable person writes first: authenticate, then check the flag. It answers 401 to an anonymous caller hitting a disabled API, which tells that caller the endpoint exists and is merely closed to them. For a module a buyer may have deleted from their product entirely, that is the wrong answer.

api is one of six keys in Feature::REGISTRY, alongside ai, referrals, blog, support and signup, each a Data.define(:key, :default) with true as the default. Overrides live in a jsonb column on Setting, so switching the API off is a checkbox in /admin/features and not a deploy.

The response shape is a plain Ruby object

class UserSerializer
  def initialize(user)
    @user = user
  end

  def as_json(*)
    {
      id: user.id,
      email_address: user.email_address,
      name: user.name,
      company_name: user.company_name,
      role: user.role,
      confirmed: user.confirmed?,
      onboarded: user.onboarded?
    }
  end

  private

  attr_reader :user
end

Seven keys, chosen one at a time. render json: calls as_json, so no serializer gem is in the Gemfile and no jbuilder template is involved. The argument for it at this size is that the public shape of the API is a file somebody can read in ten seconds, and that adding a column to users cannot change a public response by accident. A migration that adds internal_notes to the table leaks nothing, because nothing asked for it.

What that costs is honesty about scale. Ten models with associations, sparse fieldsets and conditional includes is where a plain object starts losing to a library, and the signal to switch is the second time a serializer takes a flag argument to decide what to include.

Writing down what the API answers

No OpenAPI tooling is in the product's Gemfile. No rswag, no apipie-rails, no swagger generation. The written record is a Markdown file, engines/boilerplate_documentation/docs/api.md, served by a local-only engine at /boilerplate/documentation, holding the base controller, the token table, the three endpoints and a curl example that pipes the token through jq.

The other record is executable. spec/requests/api/v1/users_spec.rb and sessions_spec.rb assert the status codes and the payload keys, and the shared example "a token-authenticated endpoint" asserts the 401 contract in one line per endpoint. A spec cannot be read by a customer, and a Markdown page cannot fail when a route changes under it. Documenting a Rails API takes the trade seriously, including the option most teams reach for and then abandon, which is generating a schema from the test suite.

What this hub does not cover

CORS. No rack-cors appears in the product's Gemfile, which is fine for a mobile client or a server-to-server caller and not fine the moment a browser on another origin makes the first request. Adding it is a gem and an initializer, and the decision about allowed origins is not one a boilerplate can make for you.

Rate limiting on the token endpoint. The HTML SessionsController declares rate_limit to: 10, within: 3.minutes, only: :create; Api::V1::SessionsController declares nothing, so POST /api/v1/session is open to credential stuffing at whatever rate the host allows. ActionController::API includes RateLimiting, so closing that gap is one line, and the reason it is not already written is that the sensible limit depends on who your clients are.

Pagination, because there is no collection endpoint here to paginate. Nor webhooks going out, nor GraphQL, nor an API that third parties register applications against, which is OAuth provider work and a different product.

Articles on this topic

  • Rails API versioning, and the error shape it forces you to pick

    Why the Api::V1 namespace exists, which of the three versioning strategies to choose and what would change that, what actually breaks the day v2 ships, and what a Rails JSON API returns when the token is bad or the feature is switched off.

  • What `rails new --api` actually removes

    config.api_only drops seven middleware, swaps ActionController::Base for ActionController::API and retunes the generators. What each one costs when you want it back, and why this boilerplate runs ActionController::API inside a full Rails app instead.

  • Rails API documentation, generated from the specs that already run

    Rails ships no API documentation generator. What exists in 2026 is rswag, apipie-rails and rspec-openapi, and only one of them documents an endpoint without a second DSL to keep in sync. What that costs in test-suite noise, measured against this boilerplate's own request specs.