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 |
And the same rules as a list you can paste into a pull request template:
- Plurals for collections.
/articles,/people,/order_items. - Plural plus identifier for a single one.
/articles/42. Never/article/42. - Associations are plurals after the single.
/articles/42/comments. The parent is always the single one, so the path always reads parent, id, children. - A has-one association is singular.
/articles/42/cover_image. There is only one, so there is no collection and no id. - 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. - Paths are nouns. The method carries the verb:
GETreads,POSTcreates,PATCHchanges,DELETEremoves. - If you need a verb, find the noun. “Publish” creates a
publication. “Like” creates alike. “Log in” creates asession. - Filters, sorts and pages go in the query string.
?status=draft, never/draft-articles. - Lowercase snake_case, no trailing slash, no file extension.
/blog_posts, not/Blog-Posts/. Snake case only, never kebab case. - Foreign keys are the parent’s singular name plus
_id.article_idoncomments. - Models and classes are singular, tables and collections are plural.
Articleis stored inarticles. - 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.
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.
- The path is an address, not a sentence.
/articles/42reads 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. - 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. - The shapes stay parallel.
/articlesand/articles/42share 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/newshows a form, no article can have the slugnew. Rails reservesnewandeditfor 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.
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 isarticle.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, socomments.article_id. - The child’s path segment is the child’s collection name.
commentsin the table,commentsin the path,commentsin 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.
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 (
indexandcreate). - Use the child’s own top level path once it has an id: the member routes (
show,updateanddestroy).
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.
- Publishing an article creates a publication.
POST /articles/42/publicationpublishes it,DELETE /articles/42/publicationunpublishes it, andGET /articles/42/publicationtells 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/likesadds one,DELETE /likes/17removes it, andGET /articles/42/likeslists them. - Following creates a follow.
POST /users/9/follows, or from the other side,GET /users/9/followersandGET /users/9/following. - Logging in creates a session.
POST /sessionlogs in,DELETE /sessionlogs 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
pageandper_page, every endpoint does. Do not uselimithere andsizethere. - 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-articlesand/articles/draftslook neat, then you need/draft-articles-by-authorand 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.
| 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:
- Prefer a different word. Instead of
data, name what it is:measurements,readings,records. Instead ofnews, useannouncementsornews_items. A better noun solves the plural problem and the clarity problem together. - If you must keep an irregular word, teach your framework. In Rails, add an inflection rule so the plural and singular round trip correctly.
- Never hand-write both spellings in the same codebase. If
personispeoplein the path, it ispeoplein 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
/Articlesand/articlescan 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_postsgives/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/articlesare different addresses to a cache and a crawler. Pick the version without the slash and redirect the other. - No file extension. Use the
Acceptheader to ask for a format. If you need a URL that shows its format, use.jsonas 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,managerandserviceas 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
_atfor timestamps (created_at,published_at),_onfor dates and_countfor counters (comments_count). - Prefer a timestamp to a boolean when something happened at a moment:
published_atcarries both the fact and the time, whereis_publishedcarries 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.
- What is the thing? Write it as a plural noun. This gives the collection.
- One or many? For one, add the identifier.
- 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.
- 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. - 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
POSTon 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>_idand 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:
- Write the rules into the agent instructions. Paste the twelve-rule list from the top of this article into
CLAUDE.md,AGENTS.mdor your equivalent. Twelve short lines remove a whole class of questions the agent would otherwise ask or guess. - Name the resource, not the route. Say “add a
commentsresource 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). - 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}/confirmreads 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
statusor acharge, 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.