Designing API URLs for Sub-Resources: Flat, Nested, and the Hybrid Default

Should a comment on a post be at /comments/{id} or /posts/{post_id}/comments/{id}? Both shapes exist in production APIs for good reasons. The hybrid pattern most B2B SaaS converges on is flat canonical addresses plus scoped collection URLs for parent-context listing.

Sub-resource URL design is one of those API decisions that looks like a style question at first and turns out to determine quite a lot about how your API ages. Should a comment on a post live at /comments/{id} or at /posts/{post_id}/comments/{id}? Should listing the comments on a specific post be /comments?post_id={post_id} or /posts/{post_id}/comments? Both shapes exist in production APIs for good reasons, and the choice has compounding consequences.

The flat addressing convention

Flat addressing puts every resource at a top-level path keyed by its own ID. A comment is at /comments/{id}, a post is at /posts/{id}, a user is at /users/{id}. The parent relationship is carried in the resource body (the comment has a post_id field) but not in the URL.

What flat addressing buys is stability under relationship change. If a comment is moved from one post to another (rare, but possible in some domains), the comment's URL does not change. If a comment's parent is deleted, the comment can persist as an orphan without an invalid URL. The URL is bound to the resource's identity, not to its current relationships.

What flat addressing costs is that the relationship structure is not visible in the URL. A customer looking at /comments/abc123 has no way to know it is a comment on a post without fetching the resource and reading its post_id field. The discoverability is lower.

Stripe is the canonical flat-addressing reference. Every Stripe resource has a single top-level URL keyed by ID, regardless of how many relationship layers exist. A line item on an invoice on a customer is at /v1/invoice_items/{id}, not at /v1/customers/{customer_id}/invoices/{invoice_id}/items/{id}.

The nested addressing convention

Nested addressing puts each resource under its parent's URL path. A comment on a post is at /posts/{post_id}/comments/{id}. The parent relationship is carried in the URL itself.

What nested addressing buys is discoverability and implicit scoping. A customer looking at /posts/abc/comments/123 immediately knows it is a comment on post abc. The URL describes the relationship. Authorization checks can use the URL structure: a customer authorized for post abc is implicitly authorized for its comments without an additional check.

What nested addressing costs is brittleness under relationship change. If the comment is moved, its URL changes. If the parent is deleted, the URL becomes invalid. The URL is bound to the relationship structure, not to the resource's identity.

GitHub's REST API is the canonical nested-addressing reference. Issues are at /repos/{owner}/{repo}/issues/{number}, not at /issues/{id}. Pull requests, comments, and reactions follow the same nesting. The URLs are descriptive at the cost of stability across repository renames (which GitHub handles via redirects).

The hybrid pattern most B2B SaaS converges on

The hybrid pattern uses flat addressing for canonical resource URLs and nested URLs for scoped collection listing. A comment is at /comments/{id} for direct access, and the list of comments on a specific post is at /posts/{post_id}/comments for parent-scoped browsing. Both URLs are valid; they just answer different questions.

What the hybrid buys is the stability of flat addressing for canonical references and the discoverability of nested addressing for browsing. A customer who has stored a comment ID can fetch it at /comments/{id} regardless of any relationship changes. A customer who wants to list all comments on a post does not have to know how to construct a /comments?post_id={post_id} query; they go to /posts/{post_id}/comments.

What the hybrid costs is the documentation surface. There are now two URLs that do related things and the documentation has to explain when each is appropriate. There is also implementation work to make both URLs serve consistent responses, including pagination, filtering, and sorting.

Linear's API is a hybrid in this style: issues are at /issues/{id}, and team-scoped issue listing is at /teams/{team_id}/issues. The GraphQL surface makes the nested-as-query-context pattern more natural, but the REST surface follows the same shape.

The two-level maximum

The pattern that emerges across hybrid APIs is a two-level maximum for nesting depth in collection URLs. /posts/{post_id}/comments is fine. /posts/{post_id}/comments/{comment_id}/reactions is too deep and is better served by /reactions?comment_id={comment_id} or by /comments/{comment_id}/reactions.

The reason the two-level maximum holds is that URLs deeper than two levels are unmemorable, hard to construct programmatically, and brittle to schema changes. A three-level URL bakes three relationships into a path, and any change to any of the three (rename, restructure, soft-delete handling) breaks the URL.

The deepest defensible nesting is one parent level for collection access. /posts/{post_id}/comments lists comments scoped to the post. To get a specific comment under that scope, the convention is to use the flat /comments/{id} URL rather than to extend the nesting another level.

What does not work

Mixing flat and nested addressing for the same resource without a clear pattern is the most common mistake. An API where comments are sometimes at /comments/{id} and sometimes at /posts/{post_id}/comments/{id}, with inconsistent semantics, produces customer confusion that compounds with every new resource.

Deep nesting that exceeds two levels is the second most common mistake. /accounts/{a}/teams/{t}/projects/{p}/issues/{i}/comments/{c} is theoretically expressive and practically unusable. Customers cannot remember the order, the URL is fragile to any schema change, and the documentation has to explain the nesting depth.

Nesting under resources that change is the third mistake. If your resource model has a parent that can be reassigned (a user can change organizations, a project can change teams), nesting children under the parent's URL means the children's URLs change too. The fix is to make the nesting reflect immutable relationships only.

What we do at Anethoth

Builds uses flat addressing for projects and messages: a project is at /project/{slug}, a contact-founder form is at /project/{slug}/contact. The two-level URL for the contact form is the only nested URL in the API, and it is justified because the contact target is intrinsically scoped to the project. There is no flat /contact endpoint.

The next subdomain we build (whatever it is) will follow the same hybrid: flat addressing for canonical resources, nested collection URLs for parent-scoped browsing, two-level maximum nesting depth. The pattern is boring, predictable, and ages well.

The deeper observation

URL design is one of the API decisions that customers feel most directly. The URL is the first thing they see, the thing they paste into curl, the thing they store in their database when they reference your resource. Getting it right at design time is much cheaper than restructuring after customers have integrated. The hybrid pattern is not the most expressive option or the most flexible option, but it is the option that produces the fewest support tickets over the long run, which is usually the right thing to optimize.


Read more essays and technical writing at anethoth.com — a notebook on databases, distributed systems, biology, and the engineering that holds the world together.