Here’s the thing about API endpoints: you end up writing the same boilerplate over and over. Pagination metadata. Object type annotations. Wrapping collections in data arrays. Every endpoint looks identical except for the one line that matters—the actual query.
I got tired of it. So I built a convention system with Grape that handles all of it automatically. Now my endpoint code looks like this:
# thing.rb
get do
pagy(Author.all)
end
And the response looks like this:
{
"data": [
{
"id": 1,
"name": "joshmn",
"object": "author"
}
],
"has_more": true,
"object": "list"
}
No manual JSON construction. No remembering to add has_more. No repetitive blueprint rendering calls. The framework handles it.
Why Grape (and why not just Rails controllers)
Before we get into the implementation—yes, you can do all of this with plain Rails controllers. You can also write your own HTTP server in C. The question is whether it’s worth your time.
Grape gives you things out of the box that Rails controllers don’t:
- Typed parameters with automatic validation. Define
params { requires :id, type: Integer }and Grape rejects bad input before your code runs. In Rails, you’re writingparams[:id].to_iand hoping for the best; - Built-in API versioning. Path-based, header-based, whatever you want. Rails has none of this;
- Documentation generation. Grape integrates with grape-swagger to generate OpenAPI specs from your parameter definitions. Your docs stay in sync with your code because they are your code;
- Custom formatters that let you intercept every response and transform it before it goes out the door
Is Grape slower than raw Rails? Yes—maybe 10-20% on micro-benchmarks. Does that matter for your API that spends 95% of its time waiting on the database? Almost certainly not.
If you’re building something where that overhead matters, you probably shouldn’t be using Ruby at all.
The setup
New app, basic dependencies:
rails new stripeish
cd stripeish
bundle add blueprinter grape pagy
rails db:create
rails g model Author name
rails db:migrate
I use Blueprinter for serialization. You don’t have to—any serializer works—but I do. Pagy handles pagination.
The serialization layer
First, an application-wide blueprint that adds the object field automatically:
# app/blueprints/application_blueprint.rb
class ApplicationBlueprint < Blueprinter::Base
def self.object_field
field :object do |object|
object.model_name.singular
end
end
end
Then a blueprint for each model:
# app/blueprints/author_blueprint.rb
class AuthorBlueprint < ApplicationBlueprint
object_field
field :id
field :name
end
Calling object_field in each blueprint is intentional. You’ll want some resources that don’t include the object type—nested associations, for example. Making it opt-in keeps you in control.
The API structure
The main API class:
# app/api/api.rb
module API
class API < Grape::API
version 'v1', using: :path
prefix :api
format :json
mount ::V1::Authors
end
end
Mount it in your routes:
# config/routes.rb
Rails.application.routes.draw do
mount ::API::API => '/'
end
A basic resource:
# app/api/v1/authors.rb
module V1
class Authors < Grape::API
namespace :authors do
get do
Author.all
end
end
end
end
Hit /api/v1/authors and you’ll get… a raw array. Not what we want. Time for the interesting part.
The custom formatter
Grape’s formatter is where the magic happens. It intercepts every response and transforms it before sending. We’ll use it to:
- Automatically serialize ActiveRecord objects using their corresponding blueprints
- Inject pagination metadata when present
- Wrap collections in the Stripe-style
data/has_more/objectstructure
# app/api/custom_json_formatter.rb
class CustomJSONFormatter
DATA_OBJECTS = [
ActiveRecord::AssociationRelation,
ActiveRecord::Relation,
ActiveRecord::Base
].freeze
EXCLUDED_OBJECTS = [String, Hash].freeze
class << self
def call(resource, env)
return resource.to_json if excluded?(resource)
return resource.to_json unless serializable?(resource)
blueprint = infer_blueprint(resource)
options = extract_options(env)
if collection?(resource)
build_list_response(blueprint, resource, options, env)
else
blueprint.render(resource)
end
end
private
def excluded?(resource)
EXCLUDED_OBJECTS.any? { |klass| resource.is_a?(klass) }
end
def serializable?(resource)
DATA_OBJECTS.any? { |klass| resource.is_a?(klass) }
end
def collection?(resource)
resource.is_a?(ActiveRecord::Relation) ||
resource.is_a?(ActiveRecord::AssociationRelation)
end
def infer_blueprint(resource)
model_class = collection?(resource) ? resource.klass : resource.class
"#{model_class.name}Blueprint".constantize
end
def extract_options(env)
options = {}
if (pagy = env['api.pagy'])
options[:has_more] = pagy.next.present?
end
options
end
def build_list_response(blueprint, resource, options, env)
data = blueprint.render_as_hash(resource)
response = {
object: 'list',
data: data
}
if (pagy = env['api.pagy'])
response[:has_more] = pagy.next.present?
end
response.to_json
end
end
end
Wire it into your API:
# app/api/api.rb
module API
class API < Grape::API
version 'v1', using: :path
prefix :api
format :json
formatter :json, CustomJSONFormatter
mount ::V1::Authors
end
end
Now returning Author.all from an endpoint automatically serializes it using AuthorBlueprint, wraps it in a list structure, and includes the object type. No extra code required.
Pagination that doesn’t pollute your endpoints
Returning a tuple from every endpoint—[pagy_object, records]—is gross. The endpoint should return what it’s querying, not bookkeeping objects.
Instead, we’ll store pagination state in the Rack environment and let the formatter retrieve it:
# app/api/api.rb (inside the class)
helpers do
include Pagy::Backend
def pagy(collection)
page = params[:page] || 1
per_page = params[:per_page] || 30
pagy_obj, items = super(collection, items: per_page, page: page)
env['api.pagy'] = pagy_obj
items
end
end
The env hash is the Rack environment—available throughout the request lifecycle. We stash the Pagy object there, and the formatter picks it up later.
Now calling pagy(Author.all) paginates the collection and ensures the response includes has_more. The endpoint code doesn’t change.
Reusable pagination parameters
Define them once in a Grape DSL extension:
# config/initializers/grape_pagination.rb
module Grape
module DSL
module Parameters
def pagination_params
optional :page, type: Integer, desc: 'Page number (defaults to 1)'
optional :per_page, type: Integer, desc: 'Results per page (defaults to 30, max 100)'
end
end
end
end
Use them in any endpoint:
module V1
class Authors < Grape::API
namespace :authors do
params do
pagination_params
end
get do
pagy(Author.all)
end
end
end
end
Grape generates parameter documentation automatically. Your API docs now include pagination params with descriptions.
The complete picture
Here’s everything together. The endpoint:
module V1
class Authors < Grape::API
namespace :authors do
params do
pagination_params
end
get do
pagy(Author.all)
end
route_param :id do
get do
Author.find(params[:id])
end
end
end
end
end
GET /api/v1/authors?per_page=2&page=1 returns:
{
"object": "list",
"data": [
{ "id": 1, "name": "Alice", "object": "author" },
{ "id": 2, "name": "Bob", "object": "author" }
],
"has_more": true
}
GET /api/v1/authors/1 returns:
{
"id": 1,
"name": "Alice",
"object": "author"
}
No serialization calls. No response building. No pagination metadata juggling. The convention handles it.
When this breaks down
This approach has tradeoffs. Be aware of them:
Non-standard responses. Sometimes you need to return something that doesn’t fit the convention—aggregated data, custom structures, responses from external services. The formatter checks for Hash and String and passes them through unchanged, but you’ll need to build those responses manually.
Nested associations. If you want Author to include their Books, you need to handle that in the blueprint. The formatter won’t automatically serialize nested relations—you’ll define that relationship in AuthorBlueprint using Blueprinter’s association DSL.
Performance at scale. Inferring the blueprint class via constantize on every request adds overhead. It’s negligible for typical loads, but if you’re serving thousands of requests per second, you might want to cache the lookup or make the blueprint explicit.
Debugging opacity. When something goes wrong in the formatter, the stack trace isn’t always obvious. Add logging liberally while developing.
What you get
A system where adding a new resource means:
- Create the model
- Create a blueprint with
object_fieldand the fields you want - Create a resource class with the query
Everything else—serialization, pagination, response structure, documentation—is handled. Your endpoint code stays focused on the actual business logic.
That’s the whole point of conventions. You make decisions once, encode them in infrastructure, and stop thinking about them. The formatter is ~50 lines of code. It saves you from writing the same 10 lines in every endpoint, forever.
The full code is straightforward to extend. Add total_count to list responses. Add rate limiting headers. Add request timing. Whatever you need—you add it once, and every endpoint gets it.
That’s the leverage.