# 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

- Author: Ash Gaikwad (https://ashgaikwad.com)
- Published: 2025-08-22
- Topics: rest, api design, naming, architecture
- Canonical: https://ashgaikwad.com/blogs/how-should-i-name-rest-resources-routes-and-associations

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.](/images/blogs/rest-url-anatomy.svg)

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.](/images/blogs/rest-collection-and-member.svg)

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.

```text
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.](/images/blogs/rest-associations-map.svg)

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:

```ruby
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.

```ruby
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.](/images/blogs/rest-shallow-nesting.svg)

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:

```ruby
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:

```text
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.](/images/blogs/rest-verb-to-noun.svg)

- 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.

```text
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:

```ruby
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.

```text
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.

```text
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.](/images/blogs/rest-one-name-every-layer.svg)

| 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:

```text
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:

```text
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:

```text
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:

```ruby
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:

```text
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.](/images/blogs/rest-naming-recipe.svg)

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.
