LaunchKit

What `rails new --api` actually removes

September 22, 2026

rails new --api is a decision about the next three years taken in the first ten seconds of a project, which is a bad moment to take it. The flag is easy to read as "the JSON one", and what it actually does is remove a specific list of things, some of which are cheap to add back and some of which you will add back badly.

Everything below is Rails 8.1.3.1, read out of railties-8.1.3.1 and actionpack-8.1.3.1 rather than recalled.

The flag writes one line, and the rest follows from it

rails new --api puts config.api_only = true into your generated config/application.rb. The template that writes it, lib/rails/generators/rails/app/templates/config/application.rb.tt, sits the line under a three-line comment: "Only loads a smaller set of middleware suitable for API only apps. Middleware like session, flash, cookies can be added back manually. Skip views, helpers and assets when generating a new resource."

The setter does more than store the value. In Rails::Application::Configuration:

def api_only=(value)
  @api_only = value
  generators.api_only = value

  @debug_exception_response_format ||= :api
end

So one assignment reaches three subsystems: the middleware stack reads config.api_only while it builds, the generators get retuned through Rails::Generators.api_only!, and the debug exception format flips to :api, which is why a development error in an api_only app comes back as text rather than as the familiar HTML error page with the source extract.

The flag is also not a meta option, and the Rails source says so in a comment on Rails::Generators::AppBase#imply_options: "In contrast, --api is not a meta option because it does other things besides implying options such as --skip-asset-pipeline." What it does imply is --skip-asset-pipeline and --skip-javascript, and --skip-javascript in turn implies --skip-hotwire.

The middleware an api_only stack never builds

Rails::Application::DefaultMiddlewareStack#build_stack is a single method with seven unless config.api_only guards in it, and reading them is the honest version of what the flag removes:

middleware.use ::Rack::MethodOverride unless config.api_only
# ...
middleware.use ::ActionDispatch::Cookies unless config.api_only

if !config.api_only && config.session_store
  middleware.use config.session_store, config.session_options
end

unless config.api_only
  middleware.use ::ActionDispatch::Flash
  middleware.use ::ActionDispatch::ContentSecurityPolicy::Middleware
  middleware.use ::ActionDispatch::PermissionsPolicy::Middleware if config.permissions_policy
end

middleware.use ::Rack::TempfileReaper unless config.api_only

Seven entries, and the interesting thing is which ones are not on the list. ActionDispatch::Static still runs, so an api_only app still serves whatever is in public/. Rack::ETag and Rack::ConditionalGet still run, so conditional GET works on JSON exactly as it does on HTML. ActionDispatch::RemoteIp, ActionDispatch::RequestId, ActionDispatch::Executor and Rails::Rack::Logger are all untouched. The stack is thinner by seven, not by half.

Rack::MethodOverride is the one people forget they lost. It is the middleware that turns a form POST carrying _method=DELETE into a DELETE, and an API served to a JavaScript client that sends real verbs never notices it is gone. The day somebody adds one HTML form to the same application, form_with method: :delete posts, and the router answers with a routing error for POST on a path that only accepts DELETE.

The controller modules ActionController::API leaves out

ActionController::API and ActionController::Base both declare a MODULES constant, and the difference between the two arrays is the whole story. Base includes 36 modules; API includes 17.

What API has that Base does not: ApiRendering and BasicImplicitRender, two modules.

What Base has that API does not, all 21 of them: AbstractController::Translation, AbstractController::AssetPaths, Helpers, ActionView::Layouts, Rendering, EtagWithTemplateDigest, EtagWithFlash, MimeResponds, ImplicitRender, ParameterEncoding, Cookies, Flash, FormBuilder, RequestForgeryProtection, ContentSecurityPolicy, PermissionsPolicy, AllowBrowser, Streaming, and the three HttpAuthentication controller method modules for Basic, Digest and Token.

Three of those are worth naming individually because they are the ones that surprise people. AbstractController::Translation is what defines the t and l shortcuts in a controller, so t("api.errors.invalid_credentials") raises NoMethodError in an ActionController::API subclass and you write I18n.t in full. MimeResponds is what defines respond_to do |format|, which is the one Rails names in its own documentation as the thing you are most likely to include back. And HttpAuthentication::Token::ControllerMethods is what gives you authenticate_with_http_token, so an API controller that wants a bearer token parses the Authorization header itself.

The class documentation states the boundary plainly: an API controller "doesn't include a number of features that are usually required by browser access only: layouts and templates rendering, flash, assets, and so on", and "Request, response, and parameters objects all work the exact same way as ActionController::Base."

The 204 that means somebody forgot to render

ActionController::Base includes ImplicitRender, which looks for a template matching the action and raises ActionController::MissingExactTemplate when there is none. ActionController::API includes BasicImplicitRender instead, and the quoted block below is the whole of it:

def send_action(method, *args)
  ret = super
  default_render unless performed?
  ret
end

def default_render
  head :no_content
end

An action that falls off the end without rendering answers 204 No Content. Rails documents it as a rule to follow rather than as a hazard: "you need to ensure your controller is calling either render or redirect_to in all actions, otherwise it will return 204 No Content."

Consider what that means on a guard clause. A before_action that returns early without rendering, an if branch with no else, a rescue that logs and swallows: each one produces a 204, and 204 is a success. response.ok? is false, response.successful? is true, fetch resolves, and most client libraries report it as a win with an empty body. Under ActionController::Base the same bug is a 500 with an exception name in it. This is the trade the thinner stack makes: it cannot tell a deliberate empty response from a missing one, because it has no template to look for.

Generators, and the files rails new --api deletes

Rails::Generators.api_only! is what config.api_only = true triggers, and the whole method is this:

def api_only!
  hide_namespaces "assets", "helper", "css", "js"

  options[:rails].merge!(
    api: true,
    assets: false,
    helper: false,
    template_engine: nil
  )

  options[:mailer] ||= {}
  options[:mailer][:template_engine] ||= :erb
end

So rails generate scaffold Post in an api_only app produces a controller and a model and no views, no helper and no stylesheet. The mailer keeps ERB explicitly, on the last two lines, because an API that sends no email is not what anybody meant.

The generator also deletes, once, at creation time. AppGenerator removes app/assets and app/helpers entirely, removes app/views/layouts/application.html.erb and app/views/pwa, removes config/initializers/content_security_policy.rb, and creates config/initializers/cors.rb with every line commented out and gem "rack-cors" commented out in the Gemfile to match. app/views itself survives as long as Action Mailer does, which is how the mailer templates keep a home.

Then it removes public/400.html, public/404.html, public/406-unsupported-browser.html, public/422.html, public/500.html, public/icon.png and public/icon.svg. That last deletion has a consequence nobody mentions, and the next section is it.

A 500 that a browser reads as an empty 404

ActionDispatch::PublicExceptions is the middleware that turns an unhandled exception into a response in production, and it branches on the requested format. For JSON it builds { status: 500, error: "Internal Server Error" } and serialises it. For HTML it goes looking for a file:

def render_html(status)
  path = "#{public_path}/#{status}.#{I18n.locale}.html"
  path = "#{public_path}/#{status}.html" unless (found = File.exist?(path))

  if found || File.exist?(path)
    render_format(status, "text/html", File.read(path))
  else
    [404, { Constants::X_CASCADE => "pass" }, []]
  end
end

In an api_only application public/500.html was deleted at generation time, so that else branch is the one that runs. A browser pointed at a broken endpoint of a Rails API only application gets 404 with an empty body and an X-Cascade: pass header, for a request that failed with a 500.

The reason this survives so long in a real project is that nothing in a normal test run reaches it. Request specs assert on JSON, config.consider_all_requests_local is true in development so DebugExceptions handles the error long before PublicExceptions sees it, and the format branch only misbehaves for text/html. The suite stays green. What finds it is an uptime monitor, or a support ticket from somebody who pasted an API URL into a browser bar, and both of them report "your API returns 404" for an endpoint that is actually crashing.

Writing to the session raises; reading it returns nil

session is available on every controller regardless of stack, because ActionController::Metal carries delegate :session, to: "@_request". What differs in an api_only application is that no session store was ever inserted into the middleware, so the ActionDispatch::Request::Session object is there and disabled.

The two halves behave differently. load_for_write! raises ActionDispatch::Request::Session::DisabledSessionError with the message "Your application has sessions disabled. To write to the session you must first configure a session store". load_for_read! is load! if !loaded? && exists?, and with sessions disabled exists? is false, so nothing loads, the backing hash stays empty, and session[:user_id] returns nil.

A loud failure on write and a silent nil on read is the worst pairing for the code people actually write. session[:user_id] = user.id in a sign-in path blows up in the first minute and gets fixed. if session[:return_to] in a redirect path is just a branch that never fires, forever, and it looks exactly like a branch whose condition happens to be false.

What it costs to put cookies and sessions back

Rails is explicit that the removals are reversible, and the guide names Rack::MethodOverride, ActionDispatch::Cookies and ActionDispatch::Flash as things you can re-insert. The mechanical part is two lines in config/application.rb:

config.middleware.use ActionDispatch::Cookies
config.middleware.use ActionDispatch::Session::CookieStore, key: "_yourapp_session"

Order matters, since the session store reads the cookie jar the previous middleware built, and ActionDispatch::Flash will need both of them under it. The controller side needs include ActionController::Cookies for the cookies helper, and include ActionController::Flash if you want flash.

The part that does not come back is the part you needed. RequestForgeryProtection is a controller module, not middleware, so re-inserting ActionDispatch::Cookies and a cookie session store gives you a browser-style session with no CSRF defence attached to it. Nothing warns. protect_from_forgery is not defined on your controllers until you include the module, and until then every cookie-authenticated endpoint accepts a cross-site POST. An api_only application that grew a login form is the exact shape where that happens, because the login form is the thing that made somebody add the session back in the first place.

The honest accounting: three lines of middleware, two includes, one protect_from_forgery call, and a security property you have to remember to restore rather than one you inherit. None of that is hard. All of it is easy to get 80 percent of.

Why this boilerplate runs ActionController::API inside a full Rails app

The JSON API of this boilerplate is not an api_only application. No file in its config/ sets config.api_only, its config/application.rb requires action_view/railtie and action_cable/engine along with the rest, and it ships 48 controller classes against 30 directories of views. The JSON API is three of those controllers, under app/controllers/api/v1/.

The base controller reaches for ActionController::API directly:

class BaseController < ActionController::API
  include FeatureGated

  gated_by :api

  before_action :authenticate_api_user!

Inheriting from ActionController::API rather than from ApplicationController is the load-bearing line. ApplicationController in this codebase carries session authentication, the onboarding gate, CSRF protection and a layout, and every one of those is wrong for a bearer-token endpoint. Skipping it means the API controllers never inherit a browser callback they then have to remember to skip_before_action, which is the failure mode of the other arrangement: a skip_before_action list that drifts one line behind ApplicationController and lets a redirect-to-sign-in leak into a JSON response.

What the full application buys is that there is one of it. The marketing site, the admin console at /admin, the Stripe checkout and /api/v1/user are one router, one deploy, one set of credentials and one User model. The api_only version of the same product is two applications sharing a database, or one application plus a separate front end, and either way the admin screen you need in week three is a second thing to deploy. For a product sold to one developer who wants to ship this weekend, one deployment wins, and the cost of that position is in the next section.

The token half of this arrangement, Api::Auth::IssueToken, User.authenticate_by and the Authorization header parsing that stands in for the missing authenticate_with_http_token, is worked through in Rails JWT API authentication. The gated_by :api line on the first page of that controller belongs to a different mechanism, and a disabled feature answering 404 explains why the word prepend in it is what keeps a switched-off API from redirecting anonymous callers to a sign-in page.

What the full middleware stack costs the JSON endpoints

Every request to /api/v1/user in this codebase passes through ActionDispatch::Cookies, ActionDispatch::Session::CookieStore, ActionDispatch::Flash, ActionDispatch::ContentSecurityPolicy::Middleware, Rack::MethodOverride and Rack::TempfileReaper, because the application is not api_only and those are in the stack for the HTML half. bin/rails middleware prints all of them.

The cost is real and it is small. The session store does no work when nothing reads or writes the session, and the API controllers never do: a cookie jar that is never touched is parsed lazily and the response carries no Set-Cookie. Rack::MethodOverride declares ALLOWED_METHODS = %w[POST], so it reads nothing at all on the GET and PATCH this API serves. The flash loads from a session that was never loaded. Measured as throughput on a JSON endpoint, six middleware that mostly return immediately is not the thing that decides whether your API is fast.

The cost that is not small is conceptual, and it is worth stating rather than waving away. Cookies being present means a future contributor can write session[:something] in an API controller and it will work, in development and in tests, and the endpoint stops being stateless without anybody deciding that it should. An api_only application gets DisabledSessionError at that moment and the question gets asked out loud. What this codebase relies on instead is that Api::V1::BaseController does not inherit ApplicationController, so nothing in the API branch has a session helper in scope to copy from.

When rails new --api is the right answer

Take the flag when the application will never render HTML from Rails, and mean never. A backend for a mobile client with no web presence, a service whose only consumers are other services, a Rails app sitting behind a Next.js front end that owns every page a human sees. In those three, the deleted middleware is deleted weight and the api_only generators stop you accruing a app/views directory of three stale templates.

Skip the flag when any of these is plausible within a year: an admin screen, a Stripe checkout page, a password reset that lands on a form, a marketing page on the same domain, a status page, an OmniAuth callback. Every one of those wants cookies, and most want a template. A full Rails app that answers JSON from ActionController::API controllers gives up seven middleware entries of performance it was not going to notice, and keeps the option.

What would change this position: a team where the front end and the back end are owned by different people with different deploy cadences. At that point the second deployment already exists as an organisational fact, and building the Rails side as api_only stops pretending otherwise. The argument for the full app is an argument about a small team, and it stops holding when the team stops being small.

What this page does not cover

CORS beyond the fact that rails new --api writes a fully commented config/initializers/cors.rb and a commented gem "rack-cors". Getting the origins, the preflight and the credentials flag right is a page of its own, and it is only needed once a browser on another origin is a client.

Engines. rails plugin new --api writes config.generators.api_only = true into the engine class rather than config.api_only, which is the generator half with none of the middleware half, and the mounting application decides the stack.

Serializers and pagination. Both are decisions an api_only application and a full one make identically, so --api has no opinion about either.

Which namespace the endpoints sit in, and what a client sees when you cut a v2. Rails API versioning takes the Api::V1 namespace quoted above and argues the three strategies and the error shape they force. Nor does this page cover how any of it is written down for a consumer, which is API documentation generated from the request specs.

More on A JSON API in Rails

← All A JSON API in Rails articles