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.
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.
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 --apiwas 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::APIis 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". TheMODULESlist it composes in Action Pack 8.1 carriesStrongParameters,RateLimiting,Renderers::All,ConditionalGet,BasicImplicitRender,DataStreaming,Caching,DefaultHeaders,Logging,AbstractController::Callbacks,Rescue,InstrumentationandParamsWrapper. What is not in that list is the browser half: no cookies, no flash, no CSRF protection, no layouts and no template rendering.ParamsWrapperbeing in the list is the entry that surprises people, because it is the one that changes whatparamscontains.config.load_defaults 7.0and every version above it setaction_controller.wrap_parameters_by_defaultto true, and the Action Pack railtie turns that intowrap_parameters format: [:json]. OnApi::V1::UsersControllerin 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 beforeparams.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 --apidoes: it configures the application "to start with a more limited set of middleware than normal", it makesApplicationControllerinherit fromActionController::APIinstead ofActionController::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_onlyreadsfalse,config/application.rbrequiresaction_view/railtieandaction_cable/enginealongside 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.rbdeclares the API as two nested namespaces and two singular resources:Singular
resourcerather than pluralresourcesis the detail to read twice.GET /api/v1/userhas 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
v1from 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 anAcceptheader 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 withApi::V1::BaseControlleronceApi::V2::BaseControllerexists and the two disagree.One exchange for a token, then the token carries the request
POST /api/v1/sessiontakes an email address and a password, runs them throughUser.authenticate_by, checksuser&.confirmed?, and answers201with a token and a serialized user. Everything after that isAuthorization: Bearer <token>, pulled out ofrequest.authorizationby a regular expression inApi::V1::BaseControllerand resolved byApi::Auth::VerifyToken, which rescuesJWT::DecodeErrorand returnsnilrather than raising. Tokens are HS256, signed withAppConfig.jwt_secret, which iscredentials.jwt_secretwhen set andsecret_key_basewhen 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
subandexpclaims, 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
gated_bycomes fromapp/controllers/concerns/feature_gated.rband installs its check withprepend_before_action, notbefore_action. Order is the entire point. A disabled API is unreachable beforeauthenticate_api_user!has run, so a request with no token at all getshead :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
401to 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.apiis one of six keys inFeature::REGISTRY, alongsideai,referrals,blog,supportandsignup, each aData.define(:key, :default)withtrueas the default. Overrides live in a jsonb column onSetting, so switching the API off is a checkbox in/admin/featuresand not a deploy.The response shape is a plain Ruby object
Seven keys, chosen one at a time.
render json:callsas_json, so no serializer gem is in the Gemfile and nojbuildertemplate 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 touserscannot change a public response by accident. A migration that addsinternal_notesto 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, noapipie-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 acurlexample that pipes the token throughjq.The other record is executable.
spec/requests/api/v1/users_spec.rbandsessions_spec.rbassert 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-corsappears 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
SessionsControllerdeclaresrate_limit to: 10, within: 3.minutes, only: :create;Api::V1::SessionsControllerdeclares nothing, soPOST /api/v1/sessionis open to credential stuffing at whatever rate the host allows.ActionController::APIincludesRateLimiting, 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.