All writing
  • rest
  • api design
  • naming
  • architecture

How should I name REST resources, routes and associations?

Plurals for collections, a plural plus an id for one, and associations as plurals after the single. Naming conventions written so any stack can follow them

Name a collection with a plural noun (/articles). Name one member with that plural plus an identifier (/articles/42). Name the things that belong to it with another plural after the single (/articles/42/comments). Never put a verb in the path, because the HTTP method is the verb. Everything else in this article is the detail behind those four sentences.

I am Ash, and I use these rules on every API I build. They come from Rails, which has had them baked in since 2007, but nothing here needs Rails. If your software has URLs, tables, classes or events, the same grammar applies, and the people who call your API will guess your routes correctly on the first try. That is the whole point of a convention.

TL;DR: the rules on one screen

You want Write Example
All of them (a collection) a plural noun GET /articles
One of them (a member) the plural plus an identifier GET /articles/42
Change some fields of one (partial update) the same member path with PATCH PATCH /articles/42
Replace one entirely (full update) the same member path with PUT PUT /articles/42
The things that belong to one another plural after the single GET /articles/42/comments
The one thing that belongs to one (has one) a singular after the single GET /articles/42/cover_image
The caller’s own thing, with no id a singular at the top level GET /profile
An action that is not create, read, update or delete a noun for what the action produces POST /articles/42/publication
Narrowing, sorting, paging the query string GET /articles?status=draft&sort=-created_at

The path /articles/42/comments/7 split into four parts. Articles is a collection, 42 is one member, comments is its collection, 7 is one member. Below it a note says the path names a thing and the HTTP method says what to do with it.

And the same rules as a list you can paste into a pull request template:

  1. Plurals for collections. /articles, /people, /order_items.
  2. Plural plus identifier for a single one. /articles/42. Never /article/42.
  3. Associations are plurals after the single. /articles/42/comments. The parent is always the single one, so the path always reads parent, id, children.
  4. A has-one association is singular. /articles/42/cover_image. There is only one, so there is no collection and no id.
  5. Nest one level deep at most. After that, give the child its own top level path: /comments/7, not /authors/9/articles/42/comments/7.
  6. Paths are nouns. The method carries the verb: GET reads, POST creates, PATCH changes, DELETE removes.
  7. If you need a verb, find the noun. “Publish” creates a publication. “Like” creates a like. “Log in” creates a session.
  8. Filters, sorts and pages go in the query string. ?status=draft, never /draft-articles.
  9. Lowercase snake_case, no trailing slash, no file extension. /blog_posts, not /Blog-Posts/. Snake case only, never kebab case.
  10. Foreign keys are the parent’s singular name plus _id. article_id on comments.
  11. Models and classes are singular, tables and collections are plural. Article is stored in articles.
  12. Say it once. The same noun, with the same spelling logic, in the path, the class, the table, the JSON and the event name.

Why this matters more now: conventions make AI coding agents like Claude Code and Codex implement things consistently. An agent that knows /articles/42/comments is the comments of article 42 does not have to guess, so it needs less hand holding, less back and forth and fewer rewrites. Give it the rules once and every route it adds looks like the ones you wrote.

If you stop reading here, you already know enough to name most routes well. The rest of the article explains why each rule exists, shows the edge cases (irregular plurals, many-to-many, singletons) and lists the ways teams break the rules and what it costs them.

The words I use

Naming arguments often turn out to be vocabulary arguments, so here is the short glossary I use. Each term has one meaning in this article.

Term Meaning Example
Resource A noun your software exposes. Something with an identity that you can name, fetch and change. an article, an order, a user
Collection All the members of a resource, as a group. /articles
Member One item in a collection, picked by its identifier. /articles/42
Identifier The value that picks one member. A number, a UUID or a slug. 42, 7f3a..., my-first-post
Association A relationship from one resource to another. an article has many comments
Parent and child In an association, the owner and the owned. The child holds the foreign key. article (parent), comment (child)
Singleton A resource with exactly one instance in its context, so no id is needed. the current user’s profile
Action Something that changes state but is not plain create, read, update or delete. publish, archive, approve

A resource is not the same as a database table. It is a concept your callers care about. Often it maps to one table, sometimes to a few, and sometimes to none (a search or a session may live in memory). Name the concept the caller thinks in, not the table behind it.

Rule 1 and 2: collections and members

Take the noun, make it plural, and you have the collection. Add an identifier and you have one member.

Two panels. The collection panel shows GET on /articles to list and POST on /articles to add one. The member panel shows GET, PATCH and DELETE on /articles/42 to read, change and remove one.

Those five rows are the whole core of REST naming. Rails calls the five actions index, create, show, update and destroy. In Rails you will also see new and edit, which return HTML forms. An API that only speaks JSON does not need them, so you can leave them out (resources :articles, except: [:new, :edit]).

Intent Method and path Rails action Typical success status
List GET /articles index 200
Create POST /articles create 201
Read one GET /articles/42 show 200
Change some fields PATCH /articles/42 update 200
Replace the whole thing PUT /articles/42 update 200
Remove DELETE /articles/42 destroy 204

PATCH sends only the fields that change. PUT sends the full replacement. Rails routes both to update, and PATCH is the one you should reach for by default.

Why plural, even for one item

This is the rule people question most. “I am fetching one article, so shouldn’t it be /article/42?” Three reasons say no.

  1. The path is an address, not a sentence. /articles/42 reads as “in the articles, the one numbered 42”, like a locker number in a hall of lockers. The collection is the place and the id is the slot.
  2. One name per resource. If the collection is plural and the member is singular, you have two spellings to remember, two to document and two to get wrong. With the plural rule there is one: articles. The member just adds a segment.
  3. The shapes stay parallel. /articles and /articles/42 share a prefix. Routers, access rules, rate limits and logs can all match on the shared prefix.

Plural is a convention, not a law of nature. Some excellent APIs break it. The cost is not that they stop working, it is that every caller has to look the rule up instead of guessing it. Pick plural and keep it everywhere.

What the identifier looks like

The identifier is whatever uniquely picks a member. Three common choices:

Identifier Example Good for Watch out for
Sequential number /articles/42 simple internal tools easy to enumerate, leaks how many records you have
UUID /articles/7f3a9c1e-... public APIs, merge-safe, hard to guess long, hard to read aloud
Slug /articles/how-to-name-routes pages people read, SEO must be unique, may change, can collide with action words

Two practical rules about identifiers:

  • Treat them as opaque. A caller should copy the id from a response and paste it back, never build it or parse it. That lets you change the format later.
  • Do not let a slug collide with a reserved word. If /articles/new shows a form, no article can have the slug new. Rails reserves new and edit for this reason. Block them at validation.

In a nested path, name the parameters so they cannot be confused. Rails calls the parent :article_id and the child :id. In documentation and OpenAPI files I write both in full: /articles/{article_id}/comments/{comment_id}.

Rule 3 and 4: associations in paths

An association is a relationship between two resources. The path for it follows one pattern: parent collection, parent id, then the child collection.

GET  /articles/42/comments      the comments of article 42
POST /articles/42/comments      add a comment to article 42

The word after the single is plural when the parent can have many of them (has_many), and singular when it can have only one (has_one).

Relationship Path Why
An article has many comments /articles/42/comments many children, so a collection
An article has one cover image /articles/42/cover_image one child, so no collection and no id
A user has one profile /users/9/profile same
An order has many items /orders/12/items many children, so a collection

A has-one child is the only place a singular noun appears after a single. There is exactly one cover image, so asking for “image number 3 of the cover” makes no sense and the id disappears.

The same idea in the database and in code

The path is a view of the association. The association itself is named in the model and stored as a foreign key. Here the three line up.

Three cards for Author, Article and Comment, each with its table name and columns. Arrows show that an author has many articles and an article has many comments, and the reverse arrows show belongs to. Below, the paths /authors/9/articles and /articles/42/comments are shown, then article.comments as the plural form and article.author as the singular form.

The naming rules for the association itself:

  • Name the association for what is on the far side. From an article, the comments are article.comments (plural, many) and the author is article.author (singular, one).
  • The foreign key lives on the child and is the parent’s singular name plus _id. A comment belongs to an article, so comments.article_id.
  • The child’s path segment is the child’s collection name. comments in the table, comments in the path, comments in the JSON key.

In Rails, the declarations are short and the names do the rest:

class Author < ApplicationRecord
  has_many :articles
end

class Article < ApplicationRecord
  belongs_to :author
  has_many   :comments
  has_one    :cover_image
end

class Comment < ApplicationRecord
  belongs_to :article
end

Other stacks spell it differently, but the three names are the same. In SQL you would write comments.article_id REFERENCES articles(id). In an ORM like Django it is article = ForeignKey(Article), which stores article_id. In a GraphQL schema you would expose Article.comments: [Comment!]! and Comment.article: Article!. Plural when many, singular when one, _id for the stored reference.

Name the role, not just the type

Sometimes the far side is a User but the relationship means something more specific. Name the relationship for the role, and keep the type separate.

class Article < ApplicationRecord
  belongs_to :author, class_name: "User"      # articles.author_id
  belongs_to :editor, class_name: "User", optional: true
end

Now the column is author_id and editor_id, the code reads article.author, and a reader never wonders which of two user_id columns they are looking at. The path follows the role too: /authors/9/articles can mean “articles whose author is user 9”. If the role is not a resource of its own, use a filter instead: /articles?author_id=9.

Path or filter?

Both /authors/9/articles and /articles?author_id=9 are valid. Choose using this test.

Question If yes If no
Does the child only exist inside the parent (it is created under it and meaningless without it)? nest it: /articles/42/comments use a filter
Is the parent part of the child’s identity or permissions? nest it use a filter
Might you also want the child across many parents? use a filter, or offer both nest it

Comments belong to one article, so nest them. Articles belong to an author but are also browsed across all authors, so /articles is the main collection and /authors/9/articles is a convenient view onto it.

Rule 5: nest one level, then go shallow

Nesting is useful once and harmful after that. It is easy to follow /authors/9/articles/42/comments/7/likes/3 in your head when you design it, and painful when you have to use, document or change it.

Two panels. On the left, a path nested four levels deep marked as wrong, with the note that reaching one like needs four ids. On the right, the shallow pattern marked as right: nest to list or create under a parent, such as GET and POST on /articles/42/comments, then use the child’s own path, such as GET, PATCH and DELETE on /comments/7.

The rule of thumb in the Rails guides is that resources should never be nested more than one level deep. The shape that follows from it is called shallow nesting:

  • Use the nested path when you need the parent to find or create children: the collection routes (index and create).
  • Use the child’s own top level path once it has an id: the member routes (show, update and destroy).

The id of a comment is already unique, and it already knows which article it belongs to. Making callers repeat the article id adds nothing and creates new ways to be wrong. What if they send /articles/41/comments/7 when comment 7 belongs to article 42? You would need to handle a mismatch that should not be possible to express.

Rails gives you this with one option:

resources :articles do
  resources :comments, only: %i[index create], shallow: true
end

resources :comments, only: %i[show update destroy]

Laravel does the same with ->shallow() on a nested resource, and in Express or any other router you simply declare the two shapes yourself.

Real APIs do this

You can see this pattern in the big public APIs. GitHub lists an issue’s comments under the parent, then edits a single comment by its own id:

GET   /repos/{owner}/{repo}/issues/{issue_number}/comments
PATCH /repos/{owner}/{repo}/issues/comments/{comment_id}

The first path needs the parent to find the children. The second does not need the issue number, only the comment id. That is shallow nesting, reached by a team that learned it the hard way.

Rule 6 and 7: paths are nouns, verbs are methods

The HTTP method already says what to do. If the path says it too, you say it twice, and you invent a second vocabulary that nobody can guess (/getArticles, /fetchArticles, /articles/list, /listAllArticles).

Do not write Write
GET /getArticles GET /articles
POST /createArticle POST /articles
POST /updateArticle/42 PATCH /articles/42
POST /deleteArticle/42 DELETE /articles/42
GET /articles/42/getComments GET /articles/42/comments
GET /articles/all GET /articles

Actions that are not create, read, update or delete

Real software has actions: publish, approve, archive, cancel, follow, log in. You do not need a new kind of route for them. Ask one question: what exists after this succeeds? That is your noun.

A table of five actions. Publish an article becomes POST /articles/42/publication, unpublish becomes DELETE on the same path, like becomes POST /articles/42/likes, log in becomes POST /session, and search becomes GET /articles with a q parameter. The avoided versions are shown beside each, such as POST /publishArticle/42 and GET /searchArticles.

  • Publishing an article creates a publication. POST /articles/42/publication publishes it, DELETE /articles/42/publication unpublishes it, and GET /articles/42/publication tells you when and by whom. The article’s state is now something you can read, not just a flag you can flip.
  • Liking creates a like. POST /articles/42/likes adds one, DELETE /likes/17 removes it, and GET /articles/42/likes lists them.
  • Following creates a follow. POST /users/9/follows, or from the other side, GET /users/9/followers and GET /users/9/following.
  • Logging in creates a session. POST /session logs in, DELETE /session logs out. It is a singleton, because a caller has one current session.
  • Paying creates a payment. POST /orders/12/payments.
  • Searching narrows a collection, so it is a filter. GET /articles?q=ruby. A global search across several resources can be its own noun: GET /search?q=ruby.

This approach has a quiet benefit. Once an action is a noun, it automatically gets the full toolbox: you can list them, read one, give it a timestamp, attach an owner and an audit trail. A publish button cannot do any of that, but a publication can.

When a verb is the honest answer

Sometimes there is no noun worth inventing. Confirming a payment intent, cancelling a subscription at period end, or running a one-off job are commands, not objects. In that case, put the verb last, on a member, with POST, and keep it rare.

POST /payment_intents/pi_123/confirm
POST /payment_intents/pi_123/cancel

That is exactly how Stripe names them. Google’s API design guide uses a colon for the same job (POST /v1/articles/42:publish), so it is visibly different from a child resource. Both approaches work. The rule is the same: it is a last resort, it is always a POST, and it is always on one member, never invented on a collection.

In Rails, that last resort looks like this:

resources :payment_intents, only: [] do
  member do
    post :confirm
    post :cancel
  end
end

If you notice yourself adding a fourth or fifth of these to one resource, stop and look for the noun you are missing.

Rule 8: filters, sorting and paging

Anything that narrows or orders a collection is not a new resource, it is the same collection looked at differently. Put it in the query string, where it can be combined.

GET /articles?status=draft
GET /articles?status=draft&author_id=9
GET /articles?q=ruby&sort=-created_at&page=2&per_page=20

Naming rules for query parameters:

  • Filter by the field name. status=draft, author_id=9. Use the exact name the field has in the response, so callers do not translate.
  • Use one word for each concept across the whole API. If one endpoint pages with page and per_page, every endpoint does. Do not use limit here and size there.
  • Sort with sort. I like a leading minus for descending (sort=-created_at), but whatever you pick, document it once and apply it everywhere.
  • Search with q. It is short, common and widely understood.
  • Do not turn a filter into a path. /draft-articles and /articles/drafts look neat, then you need /draft-articles-by-author and the list never ends. A filter keeps the collection name stable.

Paths identify things, query strings describe how you want to see them. A useful test: if two different URLs can point to the same member (/articles/42 and /articles/42?foo=1), the second one only changed the view.

Singular resources

Sometimes a resource has exactly one instance in its context, and an id would be noise. The current user’s profile, the current session, the shopping cart of the signed-in person, the settings of an account. For these the singular is correct, because there is no collection to be a plural of.

GET    /profile            the signed-in user's profile
PATCH  /profile
POST   /session            log in
DELETE /session            log out
GET    /cart

The same pattern appears under a parent: GET /articles/42/cover_image is a singleton scoped to article 42.

A small Rails quirk is worth knowing, because it trips people up. Even for a singular resource (resource :profile), Rails maps it to a plural controller, ProfilesController. The path is singular because there is one, but the controller name is still plural, by convention, so that every controller in an app follows one pattern. If you use another framework, check its own convention here, and do not let the controller name leak into the URL.

Do not use a singleton to avoid thinking about ids. If a user could have several addresses, they have /addresses and /addresses/3, not /address. A singleton means one, not “I only built one so far”.

One name in every layer

The strongest benefit of this convention is not any single rule. It is that one noun, article, produces the right spelling in every layer without anyone having to decide again.

One central concept, Article, connected to eight layers. The URL path is /articles and /articles/42. The route helper is articles_path and article_path. The controller is ArticlesController. The model class is Article. The database table is articles. The foreign key is article_id. The JSON body is an object with an articles list. The event name is article.published.

Layer Spelling for the concept “article” Rule
URL path /articles, /articles/42 plural, lowercase
Route helper (Rails) articles_path, article_path(42) plural for the collection, singular for a member
Controller (Rails) ArticlesController plural, because it serves the collection and its members
Model class Article singular, PascalCase
Database table articles plural, snake_case
Foreign key article_id singular plus _id
JSON, list response { "articles": [ ... ] } plural key for many
JSON, single response { "article": { ... } } singular key for one
Event name article.published singular noun, past tense verb
Permission articles:write plural, because it grants a power over the collection

Not every framework agrees on every row. Laravel names its controller ArticleController (singular). Django prefixes the table with the app name, so it creates blog_article. Rails uses snake_case in paths, and so does this article, while many API style guides prefer hyphens. These differences are real, and they do not matter, as long as each layer is consistent inside itself and the URL, the part that other software sees, follows the shared grammar. The URL is your public contract. Your table name is a private detail.

Irregular and awkward plurals

English makes this harder than it should be. A few cases I have run into:

Singular Plural Note
person people irregular, and Rails knows it
category categories y becomes ies
status statuses not statii
address addresses
series series uncountable, singular and plural are identical
news news uncountable. Rails generates news_index_path for the collection to avoid a clash with the member helper
data data Rails singularizes it to datum, which is probably not what you want

My rules for these:

  1. Prefer a different word. Instead of data, name what it is: measurements, readings, records. Instead of news, use announcements or news_items. A better noun solves the plural problem and the clarity problem together.
  2. If you must keep an irregular word, teach your framework. In Rails, add an inflection rule so the plural and singular round trip correctly.
  3. Never hand-write both spellings in the same codebase. If person is people in the path, it is people in the table, the JSON and the controller. One decision, made once.

Casing and characters in URLs

Layer Convention Example
URL path lowercase snake_case /blog_posts, /cover_image
Query parameter snake_case per_page, author_id
JSON field snake_case created_at
Database table and column snake_case blog_posts, author_id
Class PascalCase BlogPost

Some points of detail on the URL row:

  • Lowercase only. Paths are case sensitive in theory, so /Articles and /articles can be two different resources, and callers will mix them up.
  • Snake case, never kebab case. Join words with underscores: /blog_posts, /payment_intents. Rails does this by default (resources :blog_posts gives /blog_posts), and so do well known APIs such as Stripe. The payoff is one spelling for a word everywhere: the path, the helper (blog_posts_path), the table, the JSON key and the class all differ only in case, and nothing needs translating between hyphens and underscores. That is one less rule for people and for AI coding agents to get wrong. Some style guides prefer hyphens for readable public URLs, and that is a fair choice for a marketing page, but for API resources I use snake_case only.
  • No trailing slash. /articles/ and /articles are different addresses to a cache and a crawler. Pick the version without the slash and redirect the other.
  • No file extension. Use the Accept header to ask for a format. If you need a URL that shows its format, use .json as a suffix and keep it optional.
  • No spaces, no encoded characters and no abbreviations. /articles, not /arts, not /a.
  • Names are domain words. Avoid data, info, item, list, manager and service as a resource name. They carry no meaning. If you cannot name the thing, you may not understand it yet.

Many-to-many, polymorphic and self-referencing

Three association shapes need a few extra naming rules.

Many-to-many

A student takes many courses and a course has many students. A table in the middle holds each pair. Name that table for what the pair means, not for the two names glued together.

Weak name Better name Why
students_courses enrollments it is a real thing: enrolled on a date, with a grade
articles_tags taggings it can carry a position, a tagger, a timestamp
users_projects memberships it can have a role

Once the join is a real noun, it is a resource and gets paths for free:

GET    /courses/5/enrollments        who is enrolled
POST   /courses/5/enrollments        enrol someone
DELETE /enrollments/31               un-enrol
GET    /students/8/courses           the other side, as a convenience

In Rails this is has_many :enrollments plus has_many :courses, through: :enrollments, and the pair of foreign keys on enrollments are student_id and course_id.

Polymorphic (one child, many kinds of parent)

A comment can belong to an article or a photo. The child collection has the same name under each parent, so the path stays predictable:

GET /articles/42/comments
GET /photos/9/comments

The child stores which parent, and which one: commentable_type and commentable_id. The -able suffix is a Rails habit for “anything that can be commented on”. Any stack can copy it: a type column plus an id column, both named for the role.

Self-referencing

A user follows other users. Both sides are users, so name the two directions as roles:

GET /users/9/followers      who follows 9
GET /users/9/following      whom 9 follows

The table in the middle is follows, with follower_id and followed_id. Do not write user_id twice. The whole point of a good name is that nobody has to remember which one is which.

Naming in JSON

The responses are part of the naming too. The rules are the ones you already know:

  • A list response uses a plural key and an array: { "articles": [ ... ] }.
  • A single response uses a singular key and an object: { "article": { ... } }.
  • Foreign keys are the same name as in the table: author_id.
  • An embedded association uses the association name: "comments": [ ... ] for many, "author": { ... } for one.
  • Use _at for timestamps (created_at, published_at), _on for dates and _count for counters (comments_count).
  • Prefer a timestamp to a boolean when something happened at a moment: published_at carries both the fact and the time, where is_published carries only the fact.
  • snake_case for every key, matching the paths, query parameters and columns. No camelCase and no kebab-case.

Versions and namespaces

Put a version at the very start of the path and nothing else before it: /v1/articles. It is a namespace for the whole API, not part of a resource name, so it does not repeat.

Group by audience with a namespace the same way: /admin/articles and /articles can differ in what they show and who may call them, while the noun stays articles. In Rails the namespace becomes a module, so Admin::ArticlesController serves /admin/articles. That is the same rule again: the path and the code follow each other.

Anti-patterns and what they cost

Looks like Problem Use instead
/article/42 singular collection, two spellings per resource /articles/42
/Articles or /blog_Posts mixed case creates duplicate addresses /articles, /blog_posts
/blog-posts kebab case, a second spelling for the same word /blog_posts
/articles/ trailing slash, a second address for the same thing /articles
/articles.json as the only format the format is baked into the identity Accept: application/json
/getArticle?id=42 a verb, and the id as a parameter instead of a path GET /articles/42
/articles/list a verb pretending to be a noun GET /articles
/articles/42/comments/7/likes/3 too deep, four ids to reach a leaf /likes/3
/draft-articles a filter written as a resource /articles?status=draft
/articles/42/publish for one resource, /archiveArticle for another two action styles /articles/42/publication and /articles/42/archive or an archival resource, one style for all
/users/9/user_articles the parent’s name repeated in the child /users/9/articles
/api/v1/api/articles the namespace repeated /v1/articles
/tbl_users leaks a private table name /users
POST /articles/delete an action on a collection that hides a DELETE DELETE /articles/42, or DELETE /articles?ids=1,2,3
comment.user_id and comment.author meaning the same two names for one relationship comments.author_id and comment.author

A complete worked example

Here is a small shop, named with every rule above. Read the column on the left as the sentence each route speaks.

What a person wants Method and path
See all products GET /products
See one product GET /products/17
See a product’s reviews GET /products/17/reviews
Write a review POST /products/17/reviews
Edit or delete a review PATCH /reviews/204, DELETE /reviews/204
See the product’s main photo GET /products/17/main_photo
Browse a category’s products GET /products?category_id=3
Search GET /products?q=lamp
See my cart GET /cart
Add a line to my cart POST /cart/items
Change a quantity PATCH /cart/items/9
Place an order POST /orders
See my orders GET /orders
See one order GET /orders/88
See an order’s lines GET /orders/88/items
Pay for an order POST /orders/88/payments
Cancel an order POST /orders/88/cancellation
Log in and out POST /session, DELETE /session
See my profile GET /profile

In Rails the routes file that produces this is short, because the names do the work:

resources :products do
  resources :reviews, only: %i[index create], shallow: true
  resource  :main_photo, only: :show
end
resources :reviews, only: %i[update destroy]

resource :cart, only: :show do
  resources :items, controller: "cart_items", only: %i[create update destroy]
end

resources :orders, only: %i[index show create] do
  resources :items, controller: "order_items", only: :index
  resources :payments, only: :create
  resource  :cancellation, only: :create
end

resource :session, only: %i[create destroy]
resource :profile, only: %i[show update]

And this is what Rails turns a few of those declarations into. I generated this output with the Rails 8.1 router (trimmed to a dozen rows), so these are the real names:

Prefix                 Verb    URI Pattern                        Controller#Action
article_comments       GET     /articles/:article_id/comments     comments#index
                       POST    /articles/:article_id/comments     comments#create
article_cover_image    GET     /articles/:article_id/cover_image   cover_images#show
article_publication    POST    /articles/:article_id/publication  publications#create
                       DELETE  /articles/:article_id/publication  publications#destroy
articles               GET     /articles                          articles#index
article                GET     /articles/:id                      articles#show
comment                GET     /comments/:id                      comments#show
profile                GET     /profile                           profiles#show
news_index             GET     /news                              news#index
people                 GET     /people                            people#index
person                 GET     /people/:id                        people#show

Notice the Prefix column. The helper for a nested collection reads article_comments_path(article): singular parent, then plural child, joined with an underscore. The path and the helper are the same sentence in two spellings. That is what a consistent convention buys you.

Beyond HTTP: the same grammar elsewhere

Resources and nouns are not only for URLs. The same grammar works for anything with names:

Place Collection One member Association
REST URL /articles /articles/42 /articles/42/comments
GraphQL articles (query field) article(id: 42) article.comments
Database articles table row with id = 42 comments.article_id
Kubernetes API /api/v1/namespaces/default/pods .../pods/web-1 nested under the namespace
Event stream articles topic article.published comment.created with article_id
Permissions articles:read articles:read on 42 comments:write
File store articles/ folder articles/42.json articles/42/comments/

If you design a CLI, a queue, a storage bucket or an internal service the same way, people who know one will find the others without a manual.

The five questions I ask for every new route

When I am unsure how to name something, I go through five questions in this order. Most routes are decided by the second.

Five numbered cards. 1: name the thing, as a plural noun, like /articles. 2: one or many, add the id for one, like /articles/42. 3: owned by another, nest it once as a plural, like /articles/42/comments. 4: not a noun, turn the verb into one, like /articles/42/publication. 5: narrowing a list, use the query string, like ?status=draft. A bar below says to then check the spelling.

  1. What is the thing? Write it as a plural noun. This gives the collection.
  2. One or many? For one, add the identifier.
  3. Is it owned by another resource? Then put it after the single, as a plural (or a singular if there can only be one). Nest no more than one level.
  4. Is the name a verb? Find the noun that exists once the action succeeds. Use the verb only as a last resort, and then only on a member with POST.
  5. Is it narrowing or ordering a list? That belongs to the query string.

Then check the spelling: lowercase snake_case, no verbs, no trailing slash, no extension.

A review checklist

Paste this into a pull request template for any change that adds routes.

  • Every collection is a plural noun.
  • Every member is the plural plus an identifier.
  • Children appear as plurals after the single (or a singular for has-one).
  • No path is nested more than one level, and member routes of children are shallow.
  • No verbs in paths. Any custom action is a POST on a member and is justified in the pull request.
  • Filters, sorting and paging are query parameters with the same names used elsewhere.
  • Paths are lowercase snake_case, with no hyphens.
  • The foreign key is <parent_singular>_id and role names are used where the type is not enough.
  • The class, table, JSON key and event name derive from the same noun.
  • Irregular plurals were decided once and registered with the framework.

Conventions are how AI coding agents stay consistent

I build with AI coding agents (Claude Code, Codex and others) every day, and naming is where I see the difference first. An agent has no memory of the last route it wrote, only the code and instructions in front of it. Where a convention exists, it follows it. Where there is none, it guesses, and each guess is reasonable and different from the last.

Without a convention With a convention
One session writes /getArticles, the next /article/list, the next /articles/all Every session writes GET /articles
The foreign key is user_id here, userId there, author somewhere else It is author_id everywhere, because the rule says so
You review each name and explain why it is wrong You review behaviour, because the names are already right
The prompt has to describe every route in detail The prompt says “add comments to articles” and the path writes itself
Callers (and other agents) must read the code to find a route They guess it, and the guess is correct

Three practical ways to use this:

  1. Write the rules into the agent instructions. Paste the twelve-rule list from the top of this article into CLAUDE.md, AGENTS.md or your equivalent. Twelve short lines remove a whole class of questions the agent would otherwise ask or guess.
  2. Name the resource, not the route. Say “add a comments resource under articles”. The agent can derive the path, the controller, the table, the foreign key and the JSON key from that one noun, because the convention connects them all (see the one-name-in-every-layer diagram above).
  3. Let the convention be the review checklist. The checklist near the end of this article works for a human reviewer and for an agent checking its own diff before it hands the work back.

The effect compounds. The more of your system that follows one grammar, the better an agent predicts the next piece from the pieces already there, which means fewer corrections from you and more of your time spent on what the product should do.

When to break the rules

Conventions exist to remove decisions, and a few cases do justify a different choice:

  • You are joining an existing API with its own style. Consistency with the system you extend beats consistency with this article.
  • A verb really is the clearest name. POST /payment_intents/{id}/confirm reads well, and a made-up noun would read worse. Keep it rare and keep the verb last.
  • Deep nesting reflects genuine ownership and permissions. Some systems must always know the full chain (a tenant, then a project, then a resource). Say so in the docs, and keep the chain short.
  • Public and external naming already exists. If the industry calls it a status or a charge, use the industry’s word even if it is not the word you would have picked.

When you break a rule, write down why, next to the route. The next person will either learn from it or fix it.

Frequently asked questions

Should REST resource names be singular or plural?

Plural. Use /articles for the collection and /articles/42 for one member. The collection name is the single spelling of the resource, and the member just adds an identifier. The singular is only for a has-one association or a singleton such as /profile, where there is no collection.

How do I name a nested resource?

Put the parent collection, the parent id, and then the child collection: /articles/42/comments. Use the nested path to list and create children, and use the child’s own path (/comments/7) to read, update and delete one. Do not nest more than one level.

How do I name an action like publish or archive?

Find the noun that exists after the action succeeds and create that: POST /articles/42/publication to publish and DELETE /articles/42/publication to unpublish. If there is truly no noun, use a POST with the verb as the last segment on one member (POST /payment_intents/pi_123/confirm).

Should I use hyphens or underscores in URLs?

Underscores. Use snake_case only, for example /blog_posts and /cover_image, and never kebab case in paths. Rails does this by default, so the path, helper, table and JSON key all share one spelling with no translation. Pick one style and use it everywhere, and let that style be snake_case.

How do I name the foreign key column?

Use the singular name of the parent plus _id: article_id on the comments table. If the relationship has a role (author, editor, owner), name it for the role (author_id) and set the class separately.

What is the difference between a route and a resource?

A resource is the noun, the thing your software exposes. A route is one address and method that acts on it. One resource usually has several routes: GET /articles, POST /articles, GET /articles/42 and so on.

Do naming conventions help AI coding agents?

Yes. Agents like Claude Code and Codex work from the code and instructions in front of them, so a clear convention replaces guesswork with a rule. They produce consistent names across sessions, need less hand holding and make fewer changes you have to review and correct. Put the rules in CLAUDE.md or AGENTS.md and name the resource in your prompt, and the rest follows.

Do these conventions only apply to Rails?

No. They came together in Rails, and the same pattern appears in Laravel’s Route::resource, Spring Data REST, Django REST Framework routers, Kubernetes, GitHub’s API and most of the web. The Rails names are a handy reference because they are so complete, but the grammar of plurals, identifiers and nouns works in any language.

The short version, once more

Plurals for collections. A plural plus an identifier for one. Associations are plurals after the single. Singular only when there can be just one. Nouns in paths, verbs in methods. One level of nesting, then go shallow. Filters in the query string. The same noun in every layer.

Name things this way and your routes become something people can guess instead of something they have to look up. That goes for the AI coding agents on your team too: give them a convention and they implement consistently, with less hand holding and less guessing.