The URL is the first thing a developer sees when connecting to your API. A well-designed path reads like a sentence: GET /orders/42/items — "give me the items of order #42". A poorly designed one forces you to open the docs at every step. In this article we'll look at how NestJS is arranged under the hood and which rules will help make an API predictable.
How NestJS builds a URL from code
In NestJS each controller is responsible for its own path prefix. The base address of a resource is set in @Controller, and specific endpoints — in the method decorators.
@Controller('orders') // /orders
export class OrdersController {
@Get() // GET /orders
findAll() {}
@Get(':id') // GET /orders/:id
findOne(@Param('id') id: string) {}
}
So that you don't write /api/v1 in every controller by hand, configure it once at application startup.
Global prefix and versioning
In main.ts two calls give a unified format for all routes:
import { VersioningType } from '@nestjs/common';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.setGlobalPrefix('api');
app.enableVersioning({
type: VersioningType.URI,
defaultVersion: '1',
});
await app.listen(3000);
}
After this the controller @Controller('orders') automatically responds at the address /api/v1/orders.
Service paths — health, ready — should be outside this chain, otherwise the load balancer would have to be configured with the version in mind:
@Controller('health')
export class HealthController {
@Get()
check() { return { status: 'ok' }; }
}
// → GET /health (no /api, no version)
To keep a controller from receiving the global prefix, specify { host } or override the version with the @Version(NO_VERSION) decorator.
How to name paths
Lowercase letters and hyphens
The standard for URLs is kebab-case: everything lowercase, separated by hyphens. No camelCase, no snake_case.
@Controller('order-items') // right
@Controller('OrderItems') // wrong — uppercase
@Controller('order_items') // wrong — underscore
@Controller('orderItems') // wrong — camelCase
No verbs and extensions
The path names a noun — a resource, not an action. The action is expressed by the HTTP method.
@Get(':id') // right: /orders/:id
@Get('getAll') // wrong: a verb in the path
@Get('list.json') // wrong: a file extension
No trailing slash
NestJS doesn't redirect from orders/ to orders. If you declare a path with a slash — it becomes a separate route, and without the slash it returns a 404.
@Get(':id') // right: /orders/:id
@Get(':id/') // wrong: trailing slash
Collections and single resources
A collection is plural, a single resource is singular. The rule is simple: if a single path returns a list — use the plural.
@Controller('orders') // collection — /orders, /orders/:id
@Controller('products') // collection — /products, /products/:id
@Controller('order') // wrong for a collection
If a resource is always one per user — singular:
@Controller('profile') // one profile of the current user
export class ProfileController {
@Get() // GET /api/v1/profile
get() {}
}
Take the resource name from the problem domain: orders, not purchases; customers, not users. When the word from the business matches the name in the code, the API is clear without extra explanations.
HTTP methods
Each method has a clear purpose. The choice of method is part of the contract that the client sees.
@Controller('orders')
export class OrdersController {
@Get() // GET /api/v1/orders → 200
findAll() {}
@Get(':id') // GET /api/v1/orders/:id → 200
findOne(@Param('id') id: string) {}
@Post() // POST /api/v1/orders → 201
create(@Body() dto: CreateOrderDto) {}
@Put(':id') // PUT /api/v1/orders/:id → 200
replace(@Param('id') id: string, @Body() dto: ReplaceOrderDto) {}
@Patch(':id') // PATCH /api/v1/orders/:id → 200
update(@Param('id') id: string, @Body() dto: UpdateOrderDto) {}
@Delete(':id')
@HttpCode(204) // DELETE → 204 No Content
remove(@Param('id') id: string) {}
}
What to pay attention to:
- POST on creation returns
201 Created— NestJS does this automatically. - DELETE by default in NestJS returns
200, but the correct response is204 No Content. You need to explicitly add@HttpCode(204). - GET must not change data. If you need a domain command (for example, "cancel an order"), use POST with the action name in the path:
@Get(':id/cancel') // wrong: GET changes state
cancel() {}
@Post(':id/cancel') // right: a command through POST
@HttpCode(200)
cancel() {}
Resource nesting
When one resource logically belongs to another, this can be expressed in the path:
@Controller('orders')
export class OrdersController {
@Get(':orderId/items') // GET /api/v1/orders/:orderId/items
getItems(@Param('orderId') orderId: string) {}
@Get(':orderId/items/:itemId') // GET /api/v1/orders/:orderId/items/:itemId
getItem(
@Param('orderId') orderId: string,
@Param('itemId') itemId: string,
) {}
}
Parameters are named uniquely: orderId and itemId, not both id — this is a requirement of documentation tools like Swagger.
The maximum depth is two levels of nesting. Three levels and deeper make the URL unreadable and hard to cache. Instead, use a flat resource with filtering:
// wrong: three levels
@Get(':userId/orders/:orderId/items/:itemId')
// right: a flat resource with a query parameter
@Get() // GET /api/v1/items?orderId=...
findAll(@Query('orderId') orderId: string) {}
The resource identifier is passed in the path, not in the request body:
@Put(':id') // right
replace(@Param('id') id: string) {}
@Put() // wrong: id in body
replace(@Body() dto: { id: string }) {}
In short
- Paths —
kebab-case, lowercase, no verbs, no trailing slash. setGlobalPrefix('api')+VersioningType.URIgive/api/v1/...for all controllers automatically.- Service paths (
/health,/ready) — outside the global prefix. - Collections — plural (
/orders), a single resource — singular (/profile). - The resource name comes from the problem domain, not technical synonyms.
- GET doesn't change data; domain commands go through POST.
- DELETE returns
204 No Content— you need@HttpCode(204). - At most two levels of nesting; deeper — a flat resource with filtering.
- Path parameters are named uniquely (
orderId,itemId).
Further reading
- Versioning — how
enableVersioningis arranged and the transition between versions. - Alias and Action endpoints —
me,latest, domain commands in NestJS. - Query parameters — DTO classes,
@Query, pagination. - OpenAPI and antipatterns —
operationId, parameter naming.