Typed API Contracts from NestJS to Next.js with OpenAPI: How We Keep Frontend Types Honest
Table of Contents
About the Author

Ahmed Mahmoud
Author & Developer
Software engineer passionate about web development and user experience design.
Founder of Devya · eng-ahmed.com ↗The most reliable way we have found to keep a Next.js frontend in sync with a NestJS REST API is to treat the OpenAPI spec as the contract. The @nestjs/swagger CLI plugin generates the spec at build time, openapi-typescript turns it into TypeScript types, and openapi-fetch makes every API call type-checked. A CI step that regenerates both files and fails on any uncommitted diff turns an unannounced contract change into a failed build instead of a production bug.
For a long time our Next.js apps talked to NestJS backends through hand-copied types or a shared types package in the monorepo. Both drifted, and a renamed field compiled fine on the frontend while rendering undefined in production. These are our engineering notes from replacing that with generated types, including the places where generated types still lie.
Key takeaways
OpenAPI is a language-neutral description of an HTTP API, and NestJS can emit it from the controllers and DTOs a team already has, so the contract costs almost no extra code.
The @nestjs/swagger CLI plugin only runs inside nest build. A spec generated through tsx or esbuild silently loses DTO properties.
openapi-typescript generates types only, and openapi-fetch is a thin typed wrapper over fetch, so the runtime cost on the frontend is close to zero.
Generated types are only as honest as the spec. Response-wrapping interceptors, generic DTOs, and fields hidden with @Exclude() are where the NestJS spec and the wire usually disagree.
The CI drift check is the part that prevents bugs: regenerate the spec and the types on every pull request, then fail on any uncommitted diff.
Why do shared TypeScript types between NestJS and Next.js drift?
Shared TypeScript types drift because they describe what the backend code intends, not what goes over the wire. A NestJS DTO with a createdAt field typed as Date arrives in the browser as an ISO 8601 string, because JSON has no date type.
A global interceptor that wraps every response in a data envelope changes the shape without touching the DTO. And a shared types package only helps TypeScript clients in the same repository; a React Native app in another repository gets nothing.
An OpenAPI spec is a JSON or YAML document that lists every path, parameter, request body, and response schema of an HTTP API. Generating frontend types from the spec moves the source of truth one step closer to the wire and gives non-TypeScript clients the same contract.
How do we get an accurate OpenAPI spec out of NestJS?
An accurate NestJS spec starts with the @nestjs/swagger CLI plugin, a TypeScript compiler transformer that adds @ApiProperty() metadata to every property in files ending in .dto.ts or .entity.ts, plus response metadata based on each controller method's return type. We enable it in nest-cli.json under compilerOptions.plugins. Without the plugin, every DTO property needs a hand-written @ApiProperty(), and the ones a developer forgets do not exist in the spec.
We export the spec from a small script, src/export-openapi.ts, that compiles with the rest of the app. It calls NestFactory.create(AppModule) with logging off, builds a config with DocumentBuilder, passes both to SwaggerModule.createDocument(), writes openapi.json, and closes the app. We run it with nest build followed by node dist/export-openapi.js.
Three details mattered. The Swagger plugin is a TypeScript transformer, so it runs in nest build but not under tsx or esbuild, which strip types without running transformers; our first spec came out of tsx with empty DTO schemas.
NestFactory.create() instantiates providers, so an async provider that opens a database connection will try to connect. In CI we point the export step at the same throwaway Postgres the tests use.
We commit openapi.json, so a reviewer sees every contract change as a diff next to the code that caused it.
How do we generate types and a typed fetch client for Next.js?
openapi-typescript converts an OpenAPI 3.x document into one .d.ts file that exports a paths type, and openapi-fetch uses that type to check the URL, path parameters, query, body, and response of every call. Neither tool emits runtime code per endpoint, which is why our team prefers them over generators that produce a client class per controller.
In the Next.js App Router we create the API client per request. Our server-only getApi() helper reads the session cookie through cookies() from next/headers and returns createClient with the paths type, the API_URL base URL, and a bearer Authorization header. A module-level client with an auth middleware is shared by every request the server handles, which is exactly how one user's token ends up on another user's call.
A page then calls api.GET('/orders/{id}') with the id under params.path and receives data and error. A typo in the path, a missing path parameter, or a field the DTO no longer has is now a compile error. After the types are regenerated, renaming a field in a NestJS DTO breaks next build wherever the frontend reads it.
Where do generated OpenAPI types still lie?
Generated types are only as honest as the spec, and the NestJS spec is built from decorators and class metadata, not from the bytes the API actually returns. These are the gaps we hit.
Response-wrapping interceptors: a global TransformInterceptor that returns data and meta fields changes every response, but the spec still describes the bare DTO. We either drop the wrapper or describe it with a wrapper class per response.
Generic DTOs: TypeScript generics disappear at runtime, so a generic Paginated return type documents nothing useful. A class factory fixes it: a Paginated(item) function returns a class whose items array carries @ApiProperty({ type: [item] }), and OrderPageDto extends Paginated(OrderDto).
Serialized-away fields: ClassSerializerInterceptor strips properties marked @Exclude() from responses, but the plugin still documents them unless they also carry @ApiHideProperty(). The frontend type then promises a passwordHash that never arrives.
Undeclared error responses: the NestJS ValidationPipe throws a 400 whether or not the spec mentions it. Unless the controller declares @ApiBadRequestResponse with an error DTO, openapi-fetch has no useful type for error.
Interfaces and type aliases: interfaces have no runtime representation, so properties typed with them come out vague. We make every nested response shape a class.
None of this validates responses at runtime, because openapi-fetch trusts the server. Our NestJS e2e tests validate a handful of critical responses against the committed openapi.json, which is what caught the interceptor wrapper.
How do we stop the API contract drifting in CI?
A contract drift check regenerates the OpenAPI spec and the frontend types on every pull request and fails if either differs from what is committed. In a pnpm monorepo it is four steps: build the API, run the export script, run openapi-typescript in the web package, then run git diff --exit-code on openapi.json and schema.d.ts.
The diff check catches forgotten regeneration; it does not judge whether a change is safe. When an API has clients that do not deploy with it, such as a mobile app already in the stores, we also run oasdiff breaking with --fail-on ERR against the spec from the main branch. oasdiff is an OpenAPI diff tool that fails the build when a change removes a response field or makes an optional request field required.
When should a team use OpenAPI codegen instead of tRPC or ts-rest?
OpenAPI codegen is the right choice when the NestJS API already exists as REST controllers or serves clients that are not written in TypeScript. tRPC and ts-rest are better when one team controls both ends and wants the contract written in TypeScript first.
Shared types package: hand-written TypeScript types are the source of truth; it fits existing NestJS controllers but serves no non-TypeScript clients, and it describes intent rather than the wire.
tRPC: router types are the source of truth; it replaces controllers instead of fitting them, needs an extra adapter for non-TypeScript clients, and couples client and server to TypeScript.
ts-rest: a contract object in TypeScript, often with Zod, is the source of truth; it fits NestJS partly through @ts-rest/nest handlers and reaches other clients through its OpenAPI generator.
OpenAPI codegen with openapi-typescript and openapi-fetch: a spec generated from NestJS decorators is the source of truth; it fits existing controllers and serves any client, and its main catch is that the spec can disagree with runtime behavior.
Most of the backends we build at Devya already expose REST controllers and Swagger UI, and many also serve a React Native app, so generating from OpenAPI means no rewrite and one contract for every client.
FAQ
Q: Does openapi-fetch add much to a Next.js bundle?
A: Very little. openapi-fetch is a small wrapper around the native fetch, and the openapi-typescript output is a .d.ts file that compiles away completely.
Q: Should the generated openapi.json and schema.d.ts be committed?
A: Yes. Committing both files makes API contract changes visible in code review and gives the CI drift check something to compare against.
Q: Why do NestJS Date fields come out as string in the generated types?
A: JSON has no date type, so NestJS serializes a Date as an ISO 8601 string and the spec marks it as a string with the date-time format. openapi-typescript keeps it a string, which is correct; parse it where a date is needed.
Q: Can a React Native app use the same generated client?
A: Yes. openapi-fetch only depends on the global fetch, which React Native provides, so a mobile app can import the same paths type from a shared package or a copy of schema.d.ts.
Q: Does Swagger UI need to be enabled in production for this to work?
A: No. The spec is written by a build-time script, and the typed client only needs the committed openapi.json. Exposing Swagger UI with SwaggerModule.setup() is a separate, optional decision.
Further Reading
Full-Stack Engineering
Why Node Processes Keep Growing: Field Notes on Heap Snapshots, Module-Scope Caches, and Leaks That Only Appear When Instances Are Reused
A Node service that stays healthy for an hour after every deploy and then climbs is leaking above the request, not inside it. These are our field notes on separating a real leak from garbage-collection sawtooth, capturing heap snapshots with --heapsnapshot-signal, reading the Retainers panel back to a module-scope Map, and why Fluid Compute instance reuse means serverless code leaks like a long-lived server.
Full-Stack Engineering
Why a CDN Cache Never Hits: Our Field Notes on Cache-Control, s-maxage, and stale-while-revalidate
A public JSON endpoint reached the origin on every request while the CDN configuration looked correct. These are our field notes on reading x-vercel-cache and Age instead of dashboards, the difference between max-age, s-maxage and stale-while-revalidate, when CDN-Cache-Control beats a single Cache-Control header, and the five response conditions that silently disable edge caching.