Resource Modeling & URI Design
The hardest part of API design that nobody teaches
In a nutshell
Resource modeling is about structuring your API around things (users, orders, projects) rather than actions (createUser, deleteOrder). Instead of inventing a unique endpoint for every operation, you use a predictable pattern: the URL identifies the thing, and the HTTP method (GET, POST, PUT, DELETE) says what you want to do with it. This makes your API consistent enough that developers can guess how new endpoints work without reading the docs.
The situation
You're building an API for a project management tool. You need endpoints to create projects, add team members, assign tasks, and send notification emails. Your first instinct is to create endpoints like /api/createProject, /api/addTeamMember, /api/sendEmail.
Three months later, you have 47 endpoints, each with a unique verb-noun combination. Nobody can guess what any endpoint does without reading the docs. The mobile team asks "how do I update a team member?" and you realize you never built that — you only built addTeamMember and removeTeamMember.
This is what happens when you model APIs around actions instead of resources.
Resources are nouns, not verbs
A resource is a thing — a noun that your API lets consumers interact with. The HTTP method provides the verb.
| Action-based (avoid) | Resource-based (prefer) |
|---|---|
POST /api/createProject | POST /api/projects |
GET /api/getProject?id=123 | GET /api/projects/123 |
POST /api/updateProject | PUT /api/projects/123 |
POST /api/deleteProject | DELETE /api/projects/123 |
POST /api/addTeamMember | POST /api/projects/123/members |
POST /api/send-email | POST /api/emails |
The resource-based approach gives you a predictable, consistent pattern. Once a consumer understands POST /resource creates and GET /resource/{id} reads, they can guess 80% of your API without documentation.
The /send-email test
If your endpoint starts with a verb — /send-email, /createUser, /processPayment — you're building an RPC-style API disguised as REST. Reframe it: POST /emails, POST /users, POST /payments. The HTTP method is your verb.
Naming conventions that stick
Always use plural nouns
# Consistent — always plural
GET /api/projects # list all projects
POST /api/projects # create a project
GET /api/projects/123 # get one project
PUT /api/projects/123 # update a project
# Inconsistent — mixing singular and plural
GET /api/project/123 # one project
GET /api/projects # all projects... wait, is it /project or /projects?Plural nouns eliminate ambiguity. /projects is the collection, /projects/123 is an item in the collection. Simple.
Use kebab-case for multi-word resources
# Yes
GET /api/team-members
GET /api/learning-paths
# No
GET /api/teamMembers
GET /api/team_members
GET /api/TeamMembersURI paths are technically case-sensitive per RFC 3986, but kebab-case is the most readable convention and avoids casing ambiguity entirely.
Nesting: stop at two levels
Resource nesting expresses relationships. But deep nesting creates long, rigid URLs that are painful to use.
# Good: one level of nesting — clear parent-child relationship
GET /api/projects/123/tasks
# Acceptable: two levels when it genuinely makes sense
GET /api/projects/123/tasks/456/comments
# Too deep: what are we even doing here?
GET /api/organizations/1/departments/5/projects/123/tasks/456/comments/789/reactionsThe two-level rule
If you need more than two levels of nesting, promote the deeply nested resource to a top-level resource and use query parameters for filtering. GET /comments?task_id=456 is cleaner than GET /projects/123/tasks/456/comments.
When to nest vs when to flatten
# Nest when the child resource doesn't make sense without the parent
GET /api/projects/123/members # members belong to a project
POST /api/orders/456/line-items # line items belong to an order
# Flatten when the child resource has its own identity
GET /api/tasks?project_id=123 # tasks can exist across projects in the UI
GET /api/comments/789 # a comment can be linked to directlyResource payloads: what goes in, what comes out
Create request — accept the minimum
POST /api/projects
{
"name": "Q4 Launch",
"description": "Website redesign for Q4",
"teamId": "team_abc"
}Don't accept id, createdAt, updatedAt, or status in a create request. The server owns those fields.
Create response — return the full resource
HTTP/1.1 201 Created
Location: /api/projects/proj_789
{
"id": "proj_789",
"name": "Q4 Launch",
"description": "Website redesign for Q4",
"teamId": "team_abc",
"status": "active",
"createdAt": "2026-04-13T10:30:00Z",
"updatedAt": "2026-04-13T10:30:00Z"
}Return the created resource so the consumer doesn't need a follow-up GET. Include the Location header pointing to the new resource.
Collection response — always wrap in an object
GET /api/projects
{
"data": [
{ "id": "proj_789", "name": "Q4 Launch", "status": "active" },
{ "id": "proj_790", "name": "Onboarding Revamp", "status": "draft" }
],
"meta": {
"total": 47,
"page": 1,
"pageSize": 20
}
}Never return a bare JSON array ([{...}, {...}]). Wrapping in an object gives you room to add pagination metadata, links, or other top-level fields without a breaking change.
Bare arrays are a trap
If your list endpoint returns [item, item, item], adding pagination later is a breaking change. Always return {"data": [...]} from day one.
When CRUD doesn't fit: action endpoints
Sometimes a resource-based model is awkward. Triggering a complex workflow, running a calculation, or performing a bulk operation doesn't map cleanly to CRUD.
For these cases, use a sub-resource action:
# Approve an order (state transition, not a simple field update)
POST /api/orders/456/approve
# Bulk archive old projects
POST /api/projects/bulk-archive
# Re-send an invoice email
POST /api/invoices/789/resendThese are the exception, not the rule. If more than 20% of your endpoints are action-based, revisit your resource model.
Quick reference: URI design checklist
- Resources are plural nouns (
/users, not/user) - URIs use kebab-case (
/line-items, not/lineItems) - Nesting stops at two levels max
- IDs use opaque identifiers (
proj_789, not auto-increment3) - Collections return wrapped objects, not bare arrays
- Actions are sub-resources on the parent (
/orders/456/approve) - No verbs in URIs unless it's an action endpoint
Next up: REST design principles — HTTP methods, status codes, and the difference between PUT and PATCH.