Designing Nested Resources in REST APIs: A Practical Guide
Clear patterns and best practices for designing nested resources in REST APIs—when to use them, how deep to nest, and how to keep URLs stable.
Image used for representation purposes only.
Overview
Designing nested resources in a REST API looks straightforward—just put a child under its parent in the URL. In practice, it shapes discoverability, performance, security, and long‑term evolvability. This article distills practical guidance for when and how to use nested resources, how deep to nest, how to model many‑to‑many relations, and how to keep URLs stable while your product grows.
What is a nested resource?
A nested resource is a collection or entity addressed in the context of a parent resource.
Examples:
- Collection under a parent:
/projects/{projectId}/tasks - Single child under a parent:
/projects/{projectId}/tasks/{taskId}
The nesting expresses a containment or scoping relationship: tasks belong to projects, comments belong to posts, etc.
When to use nesting (and when not to)
Use nesting when:
- The child has no meaningful existence outside the parent (strict containment). Example:
/carts/{cartId}/items. - The child is always accessed in the context of a single parent (natural scope, authorization boundary). Example:
/organizations/{orgId}/members. - The relationship cardinality is one‑to‑many (1→N) and each child has exactly one parent.
Prefer flat top‑level endpoints with filters when:
- The child can be accessed independently or belongs to multiple parents (N↔N). Example:
/tasks?projectId=...and canonical/tasks/{taskId}. - You need cross‑cutting queries, search, reporting, or pagination across parents. Example:
/events?type=error&since=.... - Deep nesting harms URL stability or developer ergonomics.
Rule of thumb: use at most one level of nesting for canonical write operations. Provide flat, top‑level canonical URLs for independent resources.
URL design patterns
Good patterns:
# Canonical collections
/projects
/tasks
# Scoped collection (1 level deep)
/projects/{projectId}/tasks
# Canonical identity for the child (top-level)
/tasks/{taskId}
Avoid:
# Deep nesting (brittle, long, hard to cache)
/companies/{companyId}/departments/{deptId}/teams/{teamId}/projects/{projectId}/tasks/{taskId}
# Overloaded paths that confuse identity
/projects/{projectId}/tasks/{taskId}/assignees/{userId}/comments/{commentId}
Why avoid deep nesting?
- Identity ambiguity: is the canonical location of
taskIdunder a project, under a user, or both? - Operational cost: proxies and caches perform better with shorter, stable paths.
- Authorization complexity: checking multiple ancestors for every request is fragile.
Depth and canonical identity
- Canonical identity means a resource has exactly one primary URL (stable over time), e.g.,
/tasks/{taskId}. - Nested routes are convenience or scope routes, not the canonical identity. You can return 201 Created from a nested POST with a Location header pointing to the canonical top‑level URL.
Example creation flow:
POST /projects/{projectId}/tasks
Content-Type: application/json
{ "title": "Set up CI" }
201 Created
Location: /tasks/87421
{ "id": "87421", "projectId": "acme-42", "title": "Set up CI", "links": {"self": "/tasks/87421", "project": "/projects/acme-42"} }
HTTP methods and behaviors on nested resources
- GET
/projects/{id}/tasks— list tasks scoped to the project. - POST
/projects/{id}/tasks— create a task within that project. Return 201 and Location of/tasks/{taskId}. - GET
/projects/{id}/tasks/{taskId}— allowed for convenience; it should resolve only iftask.projectId == {id}; otherwise 404. - PATCH/PUT
/tasks/{taskId}— update via canonical URL. If re‑parenting is allowed, validate and return 409 Conflict if illegal. - DELETE
/tasks/{taskId}— delete via canonical URL. Optionally support delete via nested route if parent matches.
Status codes to consider:
- 404 Not Found if the parent does not exist or the child is not under that parent.
- 409 Conflict when association constraints (e.g., single parent) would be violated.
- 422 Unprocessable Entity for semantic validation errors (invalid state transitions, etc.).
Modeling relationships
One‑to‑many (strict containment)
- Canonical child URL: top‑level
/children/{id}. - Scoped collection:
/parents/{id}/children. - Child contains
parentId. Authorization checks parent membership.
Many‑to‑many (associations)
Use a join/association resource to keep identities stable:
# Canonical resources
/posts/{postId}
/tags/{tagId}
# Association collection (scoped)
/posts/{postId}/tags # view linked tags
/tags/{tagId}/posts # view linked posts
# Association resource (explicit)
/post-tags # collection of link objects
/post-tags/{linkId} # canonical identity for a link
Payloads:
// POST /post-tags
{ "postId": "p123", "tagId": "t9" }
This avoids forcing a “child” to pick a single canonical parent when it belongs to many.
Polymorphic relationships
If a child can belong to different parent types (e.g., comments on posts or photos), prefer canonical top‑level identity plus typed association fields:
{ "id": "c7", "subject": { "type": "post", "id": "p42" }, "body": "Nice!" }
Expose convenience listings:
/posts/{postId}/comments/photos/{photoId}/comments- Canonical:
/comments/{commentId}
Query, pagination, and sorting for nested collections
Nested listings should accept the same controls as top‑level collections:
- Pagination:
page[size],page[after](cursor) orlimit/offset. - Sorting:
sort=-createdAt,title. - Filtering:
state=open,assignee=....
Example:
GET /projects/{id}/tasks?limit=50&after=eyJpZCI6Ijg3NDIwIn0&sort=-createdAt&state=open
Return pagination metadata and HATEOAS links:
{
"data": [ {"id": "87421", "title": "Set up CI"} ],
"page": { "next": "/projects/acme-42/tasks?after=..." },
"links": {
"self": "/projects/acme-42/tasks?limit=50",
"project": "/projects/acme-42"
}
}
Consistency rules and naming
- Use consistent, plural nouns for collections:
/users,/orders. - Prefer snake_case or kebab-case consistently in URLs; use camelCase or snake_case in JSON—pick one convention API‑wide.
- IDs are opaque strings; don’t encode business meaning in them.
- Never change segment order to reflect workflow; URLs represent identity, not steps.
Idempotency and safety across nested routes
- GET is safe and idempotent. Repeating a GET on a nested route must not change state.
- DELETE
/parents/{id}/children/{childId}is idempotent; deleting an already‑deleted child returns 404 (if canonical child is gone) or 204 (if deletion was acknowledged earlier)—pick one behavior and document it. - POST is not idempotent; for retry safety, support idempotency keys via
Idempotency-Keyheader for create operations.
Concurrency, caching, and conditional requests
- Use ETags on resource representations and require
If-Matchon updates to avoid lost updates. - For nested listings, cache at the collection level with validators (
ETag/Last-Modified). - Keep canonical child URLs short to maximize cache effectiveness.
Example conditional update:
PATCH /tasks/87421
If-Match: "e2a9fd"
Content-Type: application/json
{ "title": "Set up CI/CD" }
Error handling in nested contexts
Provide precise errors that reflect ancestry and association constraints.
Examples:
// 404 when parent not found
{ "error": "not_found", "message": "Project 'acme-42' does not exist." }
// 404 when child not under parent
{ "error": "not_found", "message": "Task '87421' is not under project 'acme-42'." }
// 409 when re-parenting would violate constraints
{ "error": "conflict", "message": "Task '87421' cannot be moved to project 'beta-7'." }
Security and authorization boundaries
- Scope tokens to parent contexts: a token for
org-7should authorize/organizations/org-7/...but not others. - Always validate parent ownership before operating on nested children.
- Avoid leaking child existence across parents. Return 404 for mismatched ancestry even if the child exists elsewhere.
- Propagate rate limits per principal and optionally per parent to prevent hot‑spot abuse.
Deletion, cascading, and orphans
Decide and document lifecycle rules:
- Cascade delete: deleting a parent deletes all children. Dangerous for audit; pair with soft‑delete or tombstones.
- Restrict delete: block parent deletion if children exist; return 409 with guidance.
- Reassign children: allow moving children to a different parent before parent deletion.
State the policy explicitly in error messages and docs.
Versioning and evolution
Nesting is part of your public surface; changing it breaks clients. To evolve safely:
- Introduce new top‑level canonical endpoints while keeping old nested routes for a deprecation window.
- Use semantic versioning in media types or URL (e.g.,
/v2) sparingly; prefer additive changes. - Announce deprecations with
DeprecationandSunsetheaders and link to docs viaLink: rel="deprecation".
Documentation with OpenAPI (example)
paths:
/projects/{projectId}/tasks:
get:
summary: List tasks in a project
parameters:
- in: path
name: projectId
required: true
schema: { type: string }
- in: query
name: limit
schema: { type: integer, minimum: 1, maximum: 100 }
responses:
'200':
description: OK
post:
summary: Create a task in a project
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [title]
properties: { title: { type: string } }
responses:
'201':
description: Created
headers:
Location:
description: Canonical URL of the new task
schema: { type: string }
/tasks/{taskId}:
get: { summary: Get task }
patch: { summary: Update task }
delete: { summary: Delete task }
Migration patterns
- Start nested for strong containment (e.g.,
/carts/{id}/items). When items later need search/reporting, add canonical/items/{itemId}; keep nested routes as alternate views. - If deep nesting exists, introduce short canonical URLs and 301/308 redirect reads from old deep URLs. For writes, prefer 201 with Location to canonical.
Testing nested resource behavior
- Unit‑test ancestry checks: parent existence, membership, and mismatches.
- Contract‑test status codes for edge cases (conflicts, orphans, re‑parenting).
- Load‑test nested listings to validate pagination and cache.
Anti‑patterns to avoid
- Encoding filters into paths:
/projects/active/tasks/todayinstead of query params. - Using names as identifiers in paths when they are mutable:
/projects/{slug}unless slugs are immutable. - Varying the same resource identity by multiple parents: both
/users/{u}/tasks/{t}and/projects/{p}/tasks/{t}as canonical. - Returning 200 OK with an error body for authorization failures; use 403/404 appropriately.
Practical checklist
Before adding or deepening nesting, ask:
- Does the child have exactly one parent? If not, avoid nesting as canonical.
- Will clients need to list/search children across parents? If yes, add a top‑level collection.
- Is the parent a natural authorization boundary? If yes, keep a scoped route for listing/creating.
- Can you cap nesting at one level? If not, reconsider the model.
- Have you defined canonical URLs, pagination, errors, and ETag/If‑Match semantics?
Example end‑to‑end flow
- Create a task under a project:
POST /projects/acme-42/tasks
{ "title": "Publish roadmap" }
201 Created
Location: /tasks/9012
- Retrieve via canonical URL:
GET /tasks/9012
200 OK
{ "id": "9012", "projectId": "acme-42", "title": "Publish roadmap" }
- List tasks for the project with pagination:
GET /projects/acme-42/tasks?limit=25&after=...&state=open
- Update with optimistic concurrency:
PATCH /tasks/9012
If-Match: "w-3"
{ "state": "in_progress" }
- Delete via either route (if parent matches):
DELETE /tasks/9012
# or
DELETE /projects/acme-42/tasks/9012
Conclusion
Use nested resources to express scope, not identity. Keep canonical URLs short and unique, cap nesting at one level, and model many‑to‑many relations with explicit association resources. Apply consistent pagination, filtering, and concurrency controls. With these patterns, your API remains intuitive today and resilient to tomorrow’s requirements.
Related Posts
Header-Based API Versioning: A Practical Guide
A practical guide to header-based API versioning: patterns, negotiation, caching, examples, and rollout strategies for evolving contracts without breaking clients.
GraphQL to REST Migration: A Practical, Low‑Risk Guide
Step-by-step guide to migrate APIs from GraphQL to REST: design mapping, caching, auth, rollout, and pitfalls to avoid.
Designing a Robust REST API Response Envelope: Patterns, Pitfalls, and Practical Examples
A practical guide to the REST API response envelope pattern: design, errors, pagination, performance tips, and when to use or skip it.