← Back to the section

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 is 204 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.URI give /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 enableVersioning is 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.