NestSync vs OpenAPI: The NestJS SDK Generator Trade-Off Your Monorepo Will Face
AST-first codegen wins when TypeScript on disk is your contract boundary — the moment Legal or Mobile needs OpenAPI, you're back to picking whose lie is authoritative.

When I first wired a NestJS monorepo, I assumed the grown-up path was @nestjs/swagger, export the JSON, run OpenAPI Generator, commit the client. Decorators in, types out, standup over. After a few sprints of DTO refactors and a partner team asking for "the spec," I stopped treating Swagger as documentation and started treating it as a second codebase nobody owned.
The mistake to avoid isn't "pick the wrong npm package." It's picking a codegen pipeline without naming who owns the contract when backend, spec, and client disagree — and both pipelines lie if nobody owns regeneration.
NestSync targets internal TypeScript drift. The README pitch is narrower than the marketing energy.
The OpenAPI Treadmill — When Spec Becomes a Second Job
Nest's official story is clean. @nestjs/swagger and SwaggerModule turn decorators into an OpenAPI document you can serve over HTTP or dump to JSON/YAML for downstream tooling.
In practice, the spec becomes another artifact with its own lifecycle. Someone adds a field to a DTO. CI still passes. The frontend compiles against last week's generated client. Production validates the new shape. Three systems agree until they don't.
I've watched teams treat "compiles end-to-end" as proof the contract matches production. It doesn't.
@ApiProperty({ required: false }) on a field never wired into class-validator makes Swagger look honest while runtime rejects the payload. Serializers strip fields the spec promised. Versioning prefixes live in bootstrap code the generator never saw.
OpenAPI exists because organizations need a language-agnostic contract between services, codegen pipelines, and partners who will never clone your monorepo. That portability is real. It's also why "export JSON and run the generator" keeps breaking teams. The generator inherits the spec; the spec inherits whatever you last exported — and whether anyone reran the job this sprint.
Where SwaggerModule Fits — Doc vs Deployable Contract
There's a difference between "we have Swagger UI in staging" and "this YAML is what Legal signed." Nest's docs describe document generation, not organizational ownership. SwaggerModule.createDocument() builds the tree on demand; SwaggerDocumentOptions let you filter modules, attach extra models, and tune operationIdFactory behavior — all useful when the OpenAPI tree is the product.
When the product is your TypeScript frontend in the same repo, you're maintaining two representations of the same routes: decorators on controllers and whatever the last codegen run emitted. The treadmill isn't slow computers. It's slow humans forgetting which artifact is authoritative.
What NestSync Is Actually Optimizing For — Source as Contract
NestSync flips the default. Instead of booting Nest to reflect metadata into OpenAPI, it crawls your source with static analysis — ts-morph over the TypeScript compiler API — and treats controllers and DTO imports as the contract boundary.
The pipeline reads like a tiny compiler:
- Analyzer — walks the NestJS AST, resolves decorators, follows DTO imports recursively.
- IR — serializes what it found into framework-agnostic JSON.
- Emitter — hands that IR to a transport plugin and writes TypeScript strings to disk.
Install the CLI, point it at src, get a generated file:
npx nestsync --input ./apps/backend/src \
--output ./apps/frontend/src/api/sdk.gen.ts \
--client=fetchWatch mode is the ergonomics win for monorepo work:
npx nestsync -i ./src -o ../client/sdk.gen.ts --client=react-query --watchNo running server. No intermediate YAML checked into git unless you want one. The trade is explicit: you optimized for source fidelity inside a TypeScript monorepo, not for handing a zip file to five client languages.
I ran it against a apps/backend/src tree with a handful of feature modules — not the README's 50-module fixture, but enough to feel the shape. First run wrote sdk.gen.ts; --watch picked up a DTO edit before I'd finished the commit message.
Less "did we run the export script." More "the compiler saw what you typed."
Fetch, Axios, React Query — Who Owns the Edge
The --client flag isn't cosmetic. fetch gives you thin typed wrappers — you own retries, caching, and auth injection. axios centralizes interceptors. react-query pushes ownership into hooks and cache keys.
Pick wrong and you've solved type drift while creating a new hidden contract: whoever configures the transport owns runtime behavior the types never mention.
Two Pipelines, Two Lies — Spec-as-Contract vs Source-as-Contract
Most tutorials teach spec-as-contract: turn on Swagger, export JSON, run codegen. NestSync pitches source-as-contract: read controllers and DTOs from disk, emit a typed client, skip the YAML middle. Same goal — keep frontend calls honest — different artifact you're forced to regenerate when someone edits a DTO.
This is the contrast someone should've pasted into the README before my third "types are fine" production bug.
Scenario: You rename a field on CreateInvoiceDto from customerId to clientId and tighten validation.
OpenAPI path: DTO compiles. You forget to regenerate the Swagger document or rerun OpenAPI Generator. Frontend still sends customerId. TypeScript might still compile if the old client wasn't regenerated either. Runtime returns 400. Standup topic: "did someone deploy without running codegen?"
NestSync path: You save the DTO. Watch mode rewrites sdk.gen.ts. Frontend fails at compile time on the old property name — if you're importing the generated surface and actually rebuilding the app. Backend ships Friday; frontend CI skipped regen. Same silent 400. Compile-time safety only helps when compile runs.
Removing swagger files doesn't remove contract drift.
You swapped OpenAPI as source of truth for TypeScript source as source of truth — still a generated surface that must stay aligned with runtime validation, global prefixes, versioning, and serializers decorators never captured.
Neither pipeline pages you when the client goes stale.
The alert is a 400 in production, not a metric.
You might be tempted to treat spec-first as the universal winner.
It isn't.
OpenAPI Generator's typescript-nestjs target is marked EXPERIMENTAL — a CLIENT generator whose nestVersion and packaging knobs exist because output must track Nest HTTP client evolution across majors. Not a blessed corporate default.
And yet spec-first is often the right corporate default when OAS is contract-of-record, Legal wants a portable artifact, or mobile and partner teams consume the same YAML. NestSync's monorepo story is narrower than "eliminate API contract drift" implies on the tin.
AST-first trades away the portable API zip file.
OpenAPI wins politics whenever someone outside the monorepo needed a spec anyway.
When Spec-First Still Earns Its Keep
Polyglot consumers. Partner API reviews. API marketplaces. Governance workflows where "what's on disk" is not admissible evidence. In those orgs, @nestjs/swagger isn't overhead — it's the lingua franca you were going to produce regardless.
Static analysis hits its own walls: dynamic routes, barrel re-exports the analyzer didn't follow, third-party decorators Swagger never mapped, anything that isn't classic HTTP controllers. Controller-driven generators like nest-sdk-generator and nest-client-generator show the pattern predates NestSync — different maturity, same gap between what source analysis sees and what runtime does.
Speed Benchmarks Are Workload-Shaped — What the README Table Actually Proves
NestSync's README publishes a table: Swagger/OpenAPI flow ~9.17s vs NestSync ~1.73s on a large fixture, labeled ~5.3× faster. Treat that as [publisher-claimed], not physics. The comparison bundles boot + JSON export + generation against direct static analysis without a running server.
The honest win is pipeline shape — CI that skips boot, watch mode that fires on save — not proof that OpenAPI must be slower in every repo. Your module count, cold-start time, and whether you already cache the spec dominate.
Speed benchmarks are workload-shaped.
Cite them when deciding CI ergonomics, not when declaring a winner for all time.
Pick Your Contract Owner — A Four-Slot Rubric
When you're choosing pipelines, map your org to four questions — not a toolchain beauty contest:
- Slot 1 — Consumers — all TypeScript in one monorepo, or polyglot / partner clients who need OAS?
- Slot 2 — Governance — is YAML the signed artifact, or is
git diffon controllers enough?
Four tidy slots — org charts rarely cooperate. Still beats arguing toolchain logos in Slack.
- Slot 3 — CI shape — can you boot Nest on every build, or is source-only analysis the bottleneck you actually feel on every PR?
- Slot 4 — Runtime seams — global prefix, auth, multi-tenant base URLs: who owns the config layer after codegen?
Worked example for a typical internal SaaS monorepo: Nest API + Next.js app, no external SDK consumers, Legal not in the loop — Slots 1 and 3 favor NestSync; Slot 4 still needs setNestSyncConfig either way. Add a Kotlin mobile app and a partner portal that imports OpenAPI — Slot 1 flips; you're maintaining @nestjs/swagger exports even if the frontend stays on AST codegen.
The Config Seam — Operator Notes That Decide Whether This Ages Well
The demo path is easy.
The production path is setNestSyncConfig.
Generated clients don't know your global prefix, tenant base URL, or how you attach Bearer tokens. That configuration seam is the real public API surface after codegen:
import { setNestSyncConfig } from './sdk.gen';
setNestSyncConfig({
baseUrl: process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:3000',
getAuthToken: async () => localStorage.getItem('access_token'),
});Miss this once and you have perfect types calling the wrong host. Auth plumbing becomes the new undocumented contract — same class of problem as forgetting to regenerate OpenAPI, different hiding place.
Global prefix is the classic punch-through. Controllers live under api/v2; the generated client calls /invoices because the analyzer read route decorators, not app.setGlobalPrefix. Swagger hides the same bug behind manually curated servers URLs in the spec.
Roll back a bad deploy and you're still wrong until someone regenerates and redeploys the client — neither toolchain reads your deployment env for you.
CI: generate on build vs commit. Generating in CI guarantees freshness but adds Node + source access on every pipeline. Committing sdk.gen.ts makes DTO-driven diffs reviewable but creates churn — watch mode plus eager commits fatigue reviewers with noise that isn't logic changes.
If reviewers treat generated files as opaque green, generate in CI and fail the build on drift — diff against committed output, same as you'd gate an OpenAPI export. If reviewers actually read generated diffs, committed output plus watch mode can be faster to reason about in code review. Pick one; mixing "commit generated files" with "skip CI regen" is how stale clients ship.
Non-HTTP surfaces don't fit. gRPC, GraphQL code-first, message queues — if it isn't expressed as Nest HTTP controllers the analyzer understands, NestSync isn't your bridge. Spec-first codegen won't save you either unless you maintain a separate schema. Draw the boundary before either pipeline becomes team religion.
Does NestSync remove the need for @nestjs/swagger? Not if anyone outside your monorepo consumes the API. Swagger remains the portable boundary; NestSync is the internal TypeScript shortcut when booting for codegen is the pain you feel daily.
OpenAPI is the contract you can email. Source on disk is the contract you can compile against — until Legal or Mobile demands YAML, and you're back to choosing whose drift shows up in standup.