How To Version A Rails API: A Complete Guide
Learn how to version a Rails API with URL, header, and namespace strategies for backward compatibility and scalable API design.
• 30 min read
• 30 min read
Learn how to version a Rails API with URL, header, and namespace strategies for backward compatibility and scalable API design.
• 30 min read
• 30 min read
Every API starts out simple. You build a few endpoints, ship them to a mobile app or a frontend client, and everything works exactly as planned. Then, a few months later, product requirements change. You need to rename a field, restructure a JSON response, or remove something that no longer makes sense. That is the exact moment when API versioning stops being a "nice to have" and becomes a requirement for keeping your application stable.
If you change an API response without any versioning strategy in place, every client consuming that API can break at once. A mobile app that has already been submitted to the App Store cannot be updated instantly. A frontend built by another team might depend on a field you just renamed. A third party integration might silently fail because a key it expects is no longer there. None of these clients did anything wrong. They built against a contract, and that contract changed without warning.
This is where backward compatibility comes in. Backward compatibility means that older clients can keep working even after you introduce new functionality or restructure your API. Versioning is the primary tool Rails developers use to achieve this. Instead of forcing every client to update at the exact same moment, you let multiple versions of your API exist side by side, giving consumers time to migrate on their own schedule.
In this guide, you will learn what API versioning actually means, why it matters for a Rails API, and how to implement it using Rails 8 conventions. You will build a fully working versioned API for a blog application, complete with namespaced routes, version-specific controllers, shared business logic, JWT authentication, and RSpec request specs. By the end, you will have a practical, production-ready pattern you can reuse in your own projects.
If you have not yet built a Rails API from scratch, it's worth reading how to build a JSON API using Ruby on Rails first. That article covers the foundational setup this one builds on, including controllers, serialization, and routing basics for a Rails API.
API versioning is the practice of managing changes to an API in a way that allows multiple versions to exist at the same time. Instead of a single, ever-changing API surface, you expose distinct versions, each with its own contract. Clients choose which version they want to use, and that version's behavior stays stable until the client chooses to migrate.
APIs need versioning because they are contracts between a server and its consumers. When you build a public or even semi-public API, you are making an implicit promise that a request sent today will behave the same way tomorrow. Once real clients depend on that behavior, changing it without warning breaks trust and breaks functionality at the same time.
The relationship between API changes and existing clients is the core problem versioning solves. A client, whether it's a mobile app, a JavaScript frontend, or another company's backend, is written against a specific shape of data. If your API returns a title field and a client's code reads response.title, that client works. If you rename title to headline, the client's code breaks immediately, with no warning and no graceful failure.
It helps to separate changes into two categories:
Breaking changes are modifications that can cause an existing client to fail or behave incorrectly. Examples include renaming a field, removing a field, changing a field's data type from a string to an object, changing the required parameters for a request, or changing how errors are formatted.
Non-breaking changes are modifications that existing clients can safely ignore. Examples include adding a new optional field to a response, adding a new endpoint, or adding an optional query parameter that has a sensible default when omitted.
Understanding this distinction is the foundation of a healthy versioning strategy. Not every change requires a new version. Only changes that break the existing contract do.
There are several practical, real-world reasons Rails developers introduce API versioning rather than just changing endpoints in place.
Supporting existing mobile applications is often the biggest driver. A mobile app that has already been released cannot be force-updated the moment your backend changes. Users may be running a version of the app that is months old. If your API breaks compatibility, those users start seeing errors or crashes, and there is nothing they can do about it until they update the app, if they update it at all.
Supporting frontend applications matters too, especially in larger organizations where the frontend and backend are maintained by different teams or deployed on different schedules. Even a few minutes of desync between a backend deploy and a frontend deploy can produce broken pages if the API contract shifted underneath the frontend.
Maintaining third-party integrations is another common reason. If external companies or partners consume your API, you often have no visibility into their release schedules at all. A partner integration built two years ago might still be running against your API today. Versioning lets you support that integration without freezing your ability to build new features.
Introducing breaking changes safely is really the heart of the matter. Sometimes a breaking change is genuinely necessary. Maybe your data model changed, or a field you exposed publicly turned out to be a security problem. Versioning gives you a mechanism to introduce that change without forcing every consumer to update instantly.
Giving clients time to migrate is what makes versioning humane rather than disruptive. Instead of an overnight breaking change, you can support the old version for a defined period, publish documentation about the new version, and let consumers move at a reasonable pace.
Maintaining backward compatibility is the umbrella goal all of the above reasons serve. The point of versioning is not to make your codebase more complex for its own sake. It's to protect the people and systems that depend on you.
That said, versioning is not always necessary. If you're building an internal API that only your own frontend consumes, and both are deployed together as part of the same release process, you may not need versioning at all. In that case, you control both sides of the contract, and you can update them in lockstep. Versioning earns its complexity when you have external consumers, mobile clients, or any situation where you can't guarantee the client and server update at the same time.
There are a handful of established approaches to API versioning, and most production APIs use some combination of them. Understanding each one helps you make an informed decision rather than copying whatever pattern you saw in a tutorial.
URL versioning puts the version directly in the request path, such as /api/v1/articles or /api/v2/articles. This is the most visible and most commonly used approach, especially in the Rails ecosystem, because it maps naturally onto Rails routing and controller namespaces.
Header versioning uses a custom HTTP header, such as X-API-Version: 2, to indicate which version of the API the client wants. The URL stays the same across versions, and the server inspects the header to determine how to respond.
Accept header versioning, sometimes called content negotiation versioning, encodes the version inside the Accept header using a custom media type, such as Accept: application/vnd.codecurious.v2+json. This is considered the most "correct" approach from a pure REST perspective, since the URL represents a resource and the format negotiation belongs in headers.
Query parameter versioning passes the version as part of the query string, such as /api/articles?version=2. It's simple to implement but generally considered the weakest approach for long-term API design.
Each of these has real tradeoffs. URL versioning is the easiest to understand, test, and cache, but it means the same logical resource technically lives at different URLs depending on version, which some REST purists dislike. Header-based approaches keep URLs clean and stable, but they're harder to test manually, harder to explore in a browser, and slightly less discoverable for new developers integrating with your API.
For a typical Rails API, especially one being built by a small to mid-sized team, URL versioning is the most practical approach. It requires no custom middleware, it works out of the box with Rails routing, it's trivial to document, and it's immediately understandable to any developer who looks at your codebase. The rest of this guide focuses on implementing URL versioning, but the underlying patterns for versioned controllers, shared logic, and testing apply just as well if you later decide to layer header-based negotiation on top.
With URL versioning, the version number becomes part of the resource path itself:
GET /api/v1/articles
GET /api/v2/articles
Each version is a distinct namespace. A request to /api/v1/articles is handled entirely separately from a request to /api/v2/articles, even though they might ultimately query the same underlying data.
Advantages
URL versioning is extremely discoverable. Any developer can open your API documentation, see /api/v1/articles, and immediately understand which version they're hitting just by looking at the URL. There's no need to inspect request headers or dig into documentation to figure out what version is active.
It also plays well with HTTP caching. Since the version is part of the URL, caching layers, CDNs, and browser caches can treat /api/v1/articles and /api/v2/articles as entirely distinct resources without any special configuration.
From a developer experience standpoint, URL versioning is the easiest to test manually. You can paste a URL into a browser, use curl, or drop it into Postman, and immediately see which version you're working with.
Disadvantages
The main criticism of URL versioning is that, strictly speaking, the URL is supposed to identify a resource, not a representation of that resource. Changing the URL for what is conceptually "the same" articles resource can be seen as a violation of REST principles.
It can also lead to more code duplication if you're not careful about sharing logic between versions, since each version effectively gets its own controller namespace.
Despite these tradeoffs, URL versioning remains the dominant pattern in real-world Rails APIs because its benefits around simplicity, caching, and discoverability tend to outweigh the theoretical REST concerns for most applications.
Instead of encoding the version in the URL, header-based versioning keeps the URL the same and asks the client to specify a version through an HTTP header.
A request might look like this:
GET /api/articles
X-API-Version: 2
The server reads the X-API-Version header and routes the request internally based on its value. This can be implemented in Rails using a before_action that inspects request.headers["X-API-Version"] and dispatches to the appropriate logic, or through custom routing constraints.
Accept header versioning takes this a step further by embedding the version inside a custom media type in the Accept header:
GET /api/articles
Accept: application/vnd.codecurious.v2+json
This approach treats versioning as a content negotiation problem rather than a routing problem, which aligns closely with how HTTP was originally designed to handle different representations of the same resource.
Advantages of header-based versioning include cleaner URLs that don't change between versions, which some API consumers and documentation tools prefer. It also keeps the "resource identity" argument intact, since /api/articles always refers to the same resource regardless of version.
Disadvantages include reduced discoverability, since you can't tell which version you're hitting just by looking at a URL. It's also more cumbersome to test manually, since most people don't set custom headers when quickly checking an endpoint in a browser. Debugging can be trickier too, since two developers looking at the exact same URL might get completely different responses depending on the headers their tools send.
For teams that are just getting started with API versioning, header-based approaches introduce meaningful complexity for a benefit that mostly matters at a larger scale or in more strictly REST-compliant systems.
Query parameter versioning passes the version as part of the query string:
GET /api/articles?version=2
This is simple to implement and simple to test, since you can just append ?version=2 to any request. However, it's generally considered the weakest of the common versioning strategies for a few reasons. Query parameters are often used for filtering, sorting, and pagination, and mixing a structural concept like API version into that same space can get confusing. Query parameters are also easy to omit accidentally, which means you need solid default behavior for when the parameter isn't present at all.
That said, query parameter versioning can still be useful in specific situations, such as quick internal tools, prototypes, or APIs where you want an extremely low-friction way for developers to opt into a beta version of an endpoint without changing the base URL structure.
When you compare these approaches side by side, a few patterns emerge.
Simplicity favors URL versioning. It requires no custom header parsing, no content negotiation logic, and maps directly onto Rails' existing routing and namespacing features.
Maintainability also tends to favor URL versioning for small to mid-sized teams, since the version boundary is explicit in your folder structure. You can look at app/controllers/api/v1 and app/controllers/api/v2 and immediately understand what exists in each version.
Client experience is strong with URL versioning because it's the easiest to understand and integrate with, especially for developers who are new to your API.
Documentation is simpler with URL versioning too, since your API docs can just present separate sections per version, matching the URL structure exactly.
Caching strongly favors URL versioning, since distinct URLs are cached independently without any special configuration.
Routing in Rails is built around path-based namespacing, so URL versioning requires the least custom code to implement.
Backward compatibility can be achieved with any of these strategies, but URL versioning makes the compatibility boundary the most visible and the easiest to reason about.
For most Rails API projects, especially ones without a strict internal mandate for pure REST content negotiation, URL versioning is the practical recommendation. It's what the rest of this guide implements, and it's the pattern you'll see across the vast majority of production Rails APIs.
Now let's build this out. Assume you're working with a Rails 8 API-only application, or an API namespace inside a full Rails application, for a blog platform with an Article model. If you haven't set up the base JSON API yet, the Rails JSON API guide walks through creating the initial controllers and serializers this versioning setup builds on top of.
The core idea is to organize your controllers into version-specific namespaces:
app/controllers/api/v1/
app/controllers/api/v2/
Each namespace holds its own controllers, but they can share models, services, and other business logic that doesn't need to change between versions. This structure keeps version-specific code isolated while avoiding unnecessary duplication of the logic that stays the same.
Namespaces are useful here because they let Rails automatically handle both routing and class naming. A controller placed at app/controllers/api/v1/articles_controller.rb becomes Api::V1::ArticlesController, and Rails' routing DSL maps naturally onto that same nested structure.
Start with your config/routes.rb file. Define your first version like this:
namespace :api do
namespace :v1 do
resources :articles
end
end
This generates routes like:
GET /api/v1/articles api_v1_articles GET
GET /api/v1/articles/:id api_v1_article GET
POST /api/v1/articles api_v1_articles POST
PATCH /api/v1/articles/:id api_v1_article PATCH
DELETE /api/v1/articles/:id api_v1_article DELETE
Notice the route helper names, api_v1_articles_path and api_v1_article_path. Rails automatically prefixes these with the namespace names, which means version two routes get their own distinct helpers without any collisions.
When it's time to introduce a second version, you add a parallel namespace:
namespace :api do
namespace :v1 do
resources :articles
end
namespace :v2 do
resources :articles
end
end
Now you have two completely independent sets of routes and route helpers, api_v1_articles_path and api_v2_articles_path, both pointing to their own controllers. Existing clients hitting /api/v1/articles are completely unaffected by anything you build in the v2 namespace. This isolation is exactly what makes URL versioning so predictable.
With the routes in place, create the corresponding controllers:
# app/controllers/api/v1/articles_controller.rb
module Api
module V1
class ArticlesController < Api::BaseController
def index
articles = Article.published.order(created_at: :desc)
render json: articles.as_json(only: [:id, :title, :body])
end
def show
article = Article.find(params[:id])
render json: article.as_json(only: [:id, :title, :body])
end
end
end
end
# app/controllers/api/v2/articles_controller.rb
module Api
module V2
class ArticlesController < Api::BaseController
def index
articles = Article.published.order(created_at: :desc).includes(:author)
render json: articles.as_json(
only: [:id, :title, :body],
include: { author: { only: [:id, :name] } }
)
end
def show
article = Article.find(params[:id])
render json: article.as_json(
only: [:id, :title, :body],
include: { author: { only: [:id, :name] } }
)
end
end
end
end
Both controllers inherit from a shared Api::BaseController, which is where common behavior like authentication and error handling lives. Each version can evolve independently after that. In this example, v1 returns a simple article payload, while v2 adds an embedded author object, which reflects a real product decision to expose author information in the API without breaking clients still relying on the original v1 shape.
A common mistake is duplicating everything between versions, which quickly turns your API into two, three, or more parallel codebases that all need to be maintained separately. The goal is to share as much as possible while isolating only the parts that genuinely differ.
Services should generally be shared. If you have a PublishArticleService that handles publishing logic, both v1 and v2 controllers can call the exact same service object. The business rule "how does an article get published" typically doesn't change based on API version.
Concerns are a good place for behavior shared across controllers within the same version, or even across versions, such as pagination logic or common authentication checks.
Serializers are one of the most common places where version differences actually live, since the JSON shape returned to clients is usually the thing that changes between versions. It's reasonable to have Api::V1::ArticleSerializer and Api::V2::ArticleSerializer as distinct classes, even while the underlying data they pull from is identical.
Presenters work similarly to serializers and can be a clean way to isolate version-specific formatting logic without touching your models at all.
Query objects for anything more complex than a simple where clause should typically be shared, since the underlying data retrieval logic rarely needs to differ between API versions.
Shared business logic, meaning validations, callbacks, and domain rules that live on your models, should almost always be shared. A model's validates :title, presence: true rule shouldn't have a "v1 version" and a "v2 version." That logic lives at the data layer, not the API presentation layer.
As a general principle: things that describe "how the business works" should be shared, and things that describe "how data is presented to a specific version of the API" should be version-specific.
The clearest way to understand versioning in practice is to look at how a JSON response can change between versions for the exact same underlying resource.
Version 1 might return:
{
"id": 1,
"title": "Building Rails APIs"
}
Version 2 might return:
{
"id": 1,
"title": "Building Rails APIs",
"author": {
"id": 5,
"name": "Jane Doe"
}
}
At first glance, this looks like a purely additive change, and technically adding a field is usually non-breaking. But there's an important nuance. If a v1 client uses strict JSON schema validation, or if it iterates over response keys and assumes a fixed set, even an "additive" change at the wrong version boundary can cause unexpected behavior. This is exactly why versions exist as distinct, frozen contracts. As long as v1 continues returning exactly the shape it always has, and the new author field only appears in v2, existing clients remain completely unaffected regardless of how they're implemented.
This is the practical payoff of versioning. You get the freedom to make whatever changes you want in v2 without needing to worry about how v1 clients will react, because they're never touched.
Authentication is one area where teams often get tempted to duplicate logic across versions unnecessarily, but in most cases it should stay centralized.
If your API uses JWT authentication, as covered in the Rails API authentication with JWT guide, that same authentication mechanism can typically be shared across every API version. Versioning and authentication solve two different problems: versioning manages how your API's data contract evolves over time, while authentication manages who is allowed to access your API at all. There's rarely a good reason for those two concerns to be tangled together.
A shared Api::BaseController is a natural place to put this logic:
# app/controllers/api/base_controller.rb
module Api
class BaseController < ActionController::API
before_action :authenticate_request!
private
def authenticate_request!
header = request.headers["Authorization"]
token = header&.split(" ")&.last
decoded = JsonWebToken.decode(token)
@current_user = User.find(decoded[:user_id]) if decoded
rescue ActiveRecord::RecordNotFound, JWT::DecodeError
render json: { error: "Unauthorized" }, status: :unauthorized
end
end
end
Both Api::V1::ArticlesController and Api::V2::ArticlesController inherit from this base controller, meaning they automatically get the same authentication behavior without any version-specific duplication.
Where version-specific authorization rules do sometimes make sense is at a more granular level. Maybe v2 introduces a new permission system that v1 never had, in which case you might add version-specific authorization checks on top of the shared authentication layer. The key distinction is that "who is this request coming from" stays centralized, while "what is this specific version allowed to do with that identity" can vary.
A common misconception is that API versions require separate database schemas or separate models. In the vast majority of cases, they don't.
Your database represents the actual state of your application's data. An Article has a title, a body, and an author, regardless of which API version a client happens to be using to read that data. There is no reason to duplicate that model or its underlying table structure per API version.
What does need to vary is the presentation layer, meaning serializers, presenters, and controller logic that decides what shape of data gets sent back for a given version. Shared models and a shared database, combined with version-specific presentation, is the pattern that scales well.
Service objects fit into this cleanly too. A PublishArticleService or CreateCommentService typically encodes business rules that don't change based on API version, so those services can be called identically from every version's controllers.
Where database changes can create real compatibility problems is when a schema change directly affects what an existing version relies on. If you drop a column that v1's serializer still reads from, you'll get errors regardless of how well your version namespaces are organized. This is why it's important to think of your versioned API contract and your database schema as related but distinct concerns. A field can disappear from a v2 response without ever touching the database, simply by changing what the v2 serializer includes. But if you physically remove a column that any active version still depends on, you need to update that version's code first, or keep the column around until every version that needs it has been retired.
Breaking changes are any modification to your API that can cause an existing, correctly-implemented client to fail or misbehave. Common examples include:
Whenever you need to introduce one of these changes, that's the signal to create a new API version rather than modifying the existing one in place. This is exactly what versioning protects against. Existing clients keep talking to the version they were built against, completely unaffected by whatever breaking change you needed to make, while new or updated clients can adopt the new version whenever they're ready.
Not every change requires a new version, and treating every change as breaking leads to unnecessary version sprawl. Changes that are generally safe to introduce without bumping the version include:
The guiding question is always: will an existing, correctly-implemented client behave differently after this change? If the answer is no, you likely don't need a new version. Developers who create a new version for every small tweak end up maintaining far more versions than necessary, which increases long-term maintenance cost without providing real benefit to clients.
Versions shouldn't live forever. Once a new version is stable and adopted, older versions eventually need to be retired, but doing so requires a clear, communicated process rather than an abrupt removal.
Deprecation announcements should be made well ahead of time, ideally through documentation, changelogs, or direct communication with known API consumers.
Documentation for a deprecated version should clearly state that it is deprecated, along with a link to the current recommended version.
Migration guides help consumers understand exactly what changed and how to update their integration, ideally with concrete before-and-after examples.
Deprecation timelines give consumers a specific date or window during which the old version will continue to work, so they can plan their migration realistically.
Usage monitoring is essential here. Before removing a version, you want actual data showing how much traffic is still hitting it. You can track this in Rails with simple logging or analytics inside your version-specific base controllers, tagging requests by version so you can measure usage over time.
Sunset dates communicate the final date after which a version will stop working entirely. Many APIs also return a Sunset HTTP header on deprecated endpoints as a machine-readable signal, in addition to human-readable documentation.
Handled well, deprecation feels like a gradual, predictable process rather than a surprise. Handled poorly, it feels exactly like the breaking change versioning was supposed to prevent in the first place.
Every supported API version deserves its own test coverage, since each version is effectively its own contract that needs to be verified independently.
Here's a realistic RSpec request spec covering both versions of the articles endpoint:
# spec/requests/api/v1/articles_spec.rb
require "rails_helper"
RSpec.describe "Api::V1::Articles", type: :request do
let(:user) { create(:user) }
let(:token) { JsonWebToken.encode(user_id: user.id) }
let(:headers) { { "Authorization" => "Bearer #{token}" } }
describe "GET /api/v1/articles" do
it "returns articles without author details" do
create(:article, title: "Building Rails APIs")
get "/api/v1/articles", headers: headers
expect(response).to have_http_status(:ok)
json = JSON.parse(response.body)
expect(json.first["title"]).to eq("Building Rails APIs")
expect(json.first).not_to have_key("author")
end
end
describe "authentication" do
it "rejects requests without a valid token" do
get "/api/v1/articles"
expect(response).to have_http_status(:unauthorized)
end
end
end
# spec/requests/api/v2/articles_spec.rb
require "rails_helper"
RSpec.describe "Api::V2::Articles", type: :request do
let(:user) { create(:user) }
let(:token) { JsonWebToken.encode(user_id: user.id) }
let(:headers) { { "Authorization" => "Bearer #{token}" } }
describe "GET /api/v2/articles" do
it "returns articles including embedded author details" do
author = create(:author, name: "Jane Doe")
create(:article, title: "Building Rails APIs", author: author)
get "/api/v2/articles", headers: headers
expect(response).to have_http_status(:ok)
json = JSON.parse(response.body)
expect(json.first["author"]["name"]).to eq("Jane Doe")
end
end
end
Notice that the same underlying authentication behavior is tested at both versions, since it's shared logic, while the response shape assertions differ between v1 and v2 to reflect their distinct contracts. This kind of parallel test structure makes it immediately obvious in your test suite when a version's contract has accidentally changed, which is exactly the kind of regression versioning is meant to prevent.
Documentation becomes significantly more important once multiple API versions exist, since developers integrating with your API need to know not just what endpoints exist, but which version they're looking at and what's different between versions.
OpenAPI and Swagger are the most common standards for documenting REST APIs, and both support documenting multiple versions, either through separate specification files per version or through versioned paths within a single spec.
Version-specific documentation should clearly separate v1 and v2 sections, ideally with a visible indicator of which version is current and which is deprecated.
Examples matter enormously here. Showing an actual request and response for each version, side by side when relevant, helps developers understand exactly what changed without having to infer it from a changelog entry.
Migration guides tie directly back into your deprecation process, giving developers a concrete, step-by-step path from an old version to a new one.
Good documentation turns versioning from a purely internal engineering practice into something that actually helps your API's consumers migrate confidently and on their own schedule.
A few mistakes show up repeatedly in real Rails codebases, and it's worth calling them out directly.
Creating a new version for every change leads to version sprawl. If you bump to v3 for every minor tweak, you end up maintaining far more parallel codebases than necessary. Reserve new versions for genuinely breaking changes.
Duplicating all application logic between versions turns your API into multiple separate applications that happen to share a database. Share services, models, and business logic wherever the underlying behavior hasn't actually changed.
Mixing versioned and unversioned controllers creates confusion about which endpoints are subject to versioning guarantees and which aren't. Once you commit to versioning, apply it consistently across your public API surface.
Removing old versions without warning breaks exactly the clients versioning was supposed to protect. Always follow a deprecation process with clear timelines before removing a version entirely.
Not documenting breaking changes leaves API consumers to discover them the hard way, usually through a production incident on their end.
Forgetting mobile clients is an easy mistake for teams that primarily think in terms of their own frontend. Mobile apps often have the longest tail of outdated versions still in active use, since users update on their own schedule.
Versioning database models unnecessarily adds significant complexity for little benefit. Keep your database shared and let your presentation layer handle version differences instead.
Making API versions too tightly coupled to each other, such as having v2 controllers directly call v1 controller methods, creates fragile dependencies where a change in one version accidentally breaks another. Share logic through models, services, and shared concerns instead of coupling controllers directly to each other.
Pulling everything together, here are practical recommendations for most Rails API projects:
/api/v1/ and /api/v2/ over more complex negotiation schemes, unless you have a specific reason to need them.To bring everything together, here's what a complete versioned blog API looks like in practice.
Routes:
Rails.application.routes.draw do
namespace :api do
namespace :v1 do
resources :articles, only: [:index, :show]
end
namespace :v2 do
resources :articles, only: [:index, :show]
end
end
end
Shared base controller handling authentication for both versions, as shown earlier in the authentication section.
Version 1 controller, returning a simple, minimal article payload:
module Api
module V1
class ArticlesController < Api::BaseController
def index
render json: Article.published.as_json(only: [:id, :title, :body])
end
end
end
end
Version 2 controller, introducing an embedded author object as a deliberate, planned breaking change from v1's shape:
module Api
module V2
class ArticlesController < Api::BaseController
def index
articles = Article.published.includes(:author)
render json: articles.as_json(
only: [:id, :title, :body],
include: { author: { only: [:id, :name] } }
)
end
end
end
end
Shared model and business logic, unaffected by versioning:
class Article < ApplicationRecord
belongs_to :author
scope :published, -> { where(published: true) }
end
Shared authentication, using the JWT pattern from the base controller, available identically to both versions.
Request specs, verifying each version's contract independently, exactly as shown in the testing section above.
Documentation, describing v1 as the stable, currently supported legacy version and v2 as the current recommended version, along with a migration note explaining that v2 adds an embedded author object that v1 does not include.
With this setup, existing clients calling /api/v1/articles continue receiving the exact response shape they've always received. New clients, or existing clients that are ready to migrate, can start calling /api/v2/articles to get the richer response with embedded author data. Both versions share the same underlying Article model, the same authentication system, and the same publishing logic. The only thing that genuinely differs between them is the shape of the JSON response, which is exactly where version-specific differences belong.
What is API versioning in Rails? API versioning in Rails is the practice of maintaining multiple, distinct contracts for your API at the same time, typically through namespaced routes and controllers, so that changes to one version don't affect clients still using another version.
What is the best way to version a Rails API? For most Rails APIs, URL-based versioning using route namespaces, such as /api/v1/ and /api/v2/, offers the best balance of simplicity, discoverability, and compatibility with Rails' existing routing conventions.
Should I use /api/v1 in the URL? Yes, for most projects. It's the most widely used convention in the Rails community, it's easy for other developers to understand immediately, and it integrates cleanly with Rails namespacing without any additional libraries or middleware.
Should Rails API versions have separate controllers? Yes. Separate, namespaced controllers per version, such as Api::V1::ArticlesController and Api::V2::ArticlesController, let each version evolve independently while still inheriting shared behavior like authentication from a common base controller.
Do I need separate models for each API version? No. In almost all cases, your database models should remain shared across versions. Version differences belong in your presentation layer, meaning serializers, presenters, or controller-level formatting, not in your data layer.
When should I create a new API version? Create a new version when you need to make a breaking change, meaning a change that would cause an existing, correctly-implemented client to fail or misbehave. Non-breaking, additive changes generally don't require a new version.
How do I deprecate an old API version? Announce the deprecation well in advance, document the recommended migration path, set a clear sunset date, monitor how much traffic the old version still receives, and communicate directly with known consumers before removing it entirely.
Can I use JWT authentication with versioned APIs? Yes, and in most cases you should share the same JWT authentication logic across every API version through a common base controller. Versioning and authentication solve different problems and don't need to be coupled together.
How many API versions should I maintain? As few as practically possible. Most healthy Rails APIs maintain one current version and, at most, one previous version during an active migration window. Maintaining many versions simultaneously significantly increases maintenance overhead.
API versioning exists to protect the people and systems that depend on your API from being broken by changes they had no control over. Whether it's a mobile app that can't be force-updated, a frontend maintained by a separate team, or a third-party integration you don't have visibility into, versioning gives every one of those consumers a stable contract they can rely on, along with a reasonable path to migrate when you do need to make breaking changes.
For most Rails APIs, a simple URL-based namespace strategy, using /api/v1/ and /api/v2/ style routes backed by shared models and business logic, offers the most practical balance of simplicity, discoverability, and maintainability. It requires no extra libraries, maps cleanly onto Rails' existing routing and controller conventions, and is immediately understandable to any developer who opens your codebase.
The real work of versioning isn't the routing setup, which takes only a few lines of code. It's establishing clear policies before you need them: what counts as a breaking change, how you communicate deprecations, how long you support old versions, and how you keep shared logic from becoming duplicated across every version you create. Set those policies early, and versioning becomes a manageable, routine part of your API's evolution rather than a scramble every time you need to make a change.