Server adapters
NestJS
Register your API routes as Nest controller methods.
On this page
@hulla/api-nestjs registers contract routes as Nest controller methods. They can use your application’s dependency injection and guards. The example adds GET /items/:id to an existing Nest app.
Use Node.js 22 or newer and NestJS 11.1+ or 12.0.1+ with either the Express or Fastify platform adapter. An
existing Nest application already has the Nest, reflect-metadata, and rxjs dependencies that the adapter uses.
Add the @hulla/api packages and the validator used in this example:
bun add @hulla/api @hulla/api-nestjs zodnpm install @hulla/api @hulla/api-nestjs zodpnpm add @hulla/api @hulla/api-nestjs zodyarn add @hulla/api @hulla/api-nestjs zodThe application must also install one Nest platform package: @nestjs/platform-express or
@nestjs/platform-fastify. That choice does not change the contract or handler code below.
Define the shared HTTP shape in src/api/contract.ts:
import { defineContract, response, route } from '@hulla/api'
import { z } from 'zod'
const item = z.object({
id: z.string(),
name: z.string(),
})
export const contract = defineContract({
routes: {
getItem: route.get('/items/:id', {
params: z.object({ id: z.string() }),
responses: {
200: response.json(item),
},
}),
},
})Implement that route in src/api/server.ts:
import { defineServer } from '@hulla/api/server'
import { contract } from './contract'
export const implementation = defineServer(contract).implement({
getItem: ({ params, response }) =>
response(200, { id: params.id, name: 'Notebook' }),
})The contract supplies the method, path, and schemas. The implementation supplies the behavior. See contracts and server implementations for the full authoring APIs.
mount() returns a controller class. Add that class to the same controllers array as a handwritten Nest
controller:
// src/app.module.ts
import { Module } from '@nestjs/common'
import { nestAdapter } from '@hulla/api-nestjs'
import { implementation } from './api/server'
const ApiController = nestAdapter().mount(implementation)
@Module({
controllers: [ApiController],
})
export class AppModule {}Start the application through its ordinary Nest bootstrap:
// src/main.ts
import 'reflect-metadata'
import { NestFactory } from '@nestjs/core'
import { AppModule } from './app.module'
async function bootstrap() {
const app = await NestFactory.create(AppModule)
await app.listen(3000)
}
void bootstrap()Request the contract path:
curl -i http://localhost:3000/items/42HTTP/1.1 200 OK
content-type: application/json; charset=utf-8
{"id":"42","name":"Notebook"}Nest registers the exact contract method and path. A global prefix still applies, so
app.setGlobalPrefix('v1') moves this endpoint to /v1/items/42.
| API | Use it when |
|---|---|
mount(implementation) | The implementation already exists and does not need Nest to construct it |
register(contract, options) | Nest providers are required to construct the implementation |
register() returns a dynamic module. Its factory must implement every route in the contract passed to it:
// src/app.module.ts
import { Injectable, Module } from '@nestjs/common'
import { nestAdapter } from '@hulla/api-nestjs'
import { defineServer } from '@hulla/api/server'
import { contract } from './api/contract'
@Injectable()
class ItemService {
find(id: string) {
return { id, name: 'Notebook' }
}
}
const adapter = nestAdapter()
const ApiModule = adapter.register(contract, {
providers: [ItemService],
inject: [ItemService],
useFactory: (items: ItemService) =>
defineServer(contract).implement({
getItem: ({ params, response }) => response(200, items.find(params.id)),
}),
})
@Module({
imports: [ApiModule],
})
export class AppModule {}imports can make providers from other Nest modules available to the factory, and scope can set the generated
implementation provider’s scope. Async factories and request-scoped injected providers use Nest’s normal lifecycle.
mount()can also mount a server fragment.register()compiles the supplied contract and therefore requires a complete implementation of that same contract.
For a matching request:
- Nest’s router matches the generated controller method. Global prefixes and guards run as usual.
- The adapter reads native path parameters, query values, headers, and the already parsed body.
@hulla/apivalidates the declared input, creates server context, runs middleware and the handler, and serializes the declared response.- Nest interceptors can wrap the result, and the selected Express or Fastify platform writes the response.
This division is why the integration does not replace Nest modules or introduce a second dependency-injection system.
| Responsibility | Owner |
|---|---|
| Routing, global prefixes, guards, interceptors, and exception filters | Nest |
| Providers and request scope | Nest |
| Body parsers, multipart plugins, and body-size limits | Express or Fastify through Nest |
| Contract input validation, server middleware, handlers, and declared responses | @hulla/api |
| Mapping contract routes and responses to the native host | @hulla/api-nestjs |
The adapter reads request.body; it does not recover bytes from the underlying request stream. Configure the matching
Nest/platform parser before mounting routes that accept text, bytes, or form data. Configure Nest raw-body support when
signature verification needs the original bytes.
Use adapter.context(({ request, response }) => ...) when a handler needs native request data. Pass platform-specific
request and response types to nestAdapter<Request, Response>() when the common NestRequest type is not enough.
Prefer injected providers for application services and native context for request-specific host details.
controllerDecorators applies class decorators to the generated controller. routeDecorators receives each route’s
key, method, and path and returns method decorators. Use these options for guards, interceptors, or metadata
that belong in Nest’s request lifecycle.
- Only Nest’s Express and Fastify platform adapters are supported.
QUERYcontract routes fail during controller creation because the supported Nest versions do not route that method consistently.- Unmatched paths use the host’s normal 404 behavior.
- Guard and interceptor failures remain Nest failures. Adapter
onErrorobserves core runtime, serialization, and transport failures and may replace a response only before it is committed. - Streaming responses use Nest’s
StreamableFile. A disconnect closes the source; a producer failure after response commitment closes the transport instead of returning a truncated successful response. HEADkeeps the response headers and suppresses the body.
Continue with server context and middleware for portable request state, or compare host behavior in adapter behavior.