This article is part of the Nx Polyglot Monorepos series:
- Exploring Polyglot Monorepos with Nx, TanStack and Rust
- Full-Stack Type Safety Across Languages with @nx/dotnet
When the backend is C# and the frontend is TypeScript, the same data gets described twice. If you change a property on your C# DTO, nothing tells you that the TypeScript interface is wrong now. You'll find out at runtime.
A monorepo can help close that gap. In this article we're going to dive into how you can expose your .NET API in a machine-readable format such that an Nx generator can read it and turn that description into TypeScript types. And in particular how to automate it by fully integrating it into the task graph.
Every change described below landed in a single PR against nx-examples, the repo behind the Nx tutorials, so you can read the whole setup as one diff.
Our example setup
To keep things simple, we use the example of a products API. It sits in an existing workspace, next to the frontend that already renders products:
apps/
├── products/ # storefront app
├── cart/ # cart app
└── products-api/ # the .NET API
├── Program.cs
└── ProductsApi.csproj
libs/
└── shared/
└── product/
├── types/ # the Product type, generated from the API
├── data/ # product data, typed by the library above
├── state/
└── ui/Those are folder paths. In this article you'll see me refer to the Nx project names instead: ProductsApi for the .NET project, shared-product-types and shared-product-data for the two libraries we keep coming back to.
The API itself is one endpoint over a single record:
app.MapGet("/products", () => Products.All);
public record Product(
[property: Description("Stable identifier.")] string Id,
[property: Description("Display name.")] string Name,
[property: Description("Price in cents.")] int Price,
[property: Description("Optional path to a product image.")] string? Image = null);Also, for the sake of this example, that record is exactly in the shape the storefront renders it, so it is directly consumed by the frontend. The [Description] attributes travel with it, through the OpenAPI document and into JSDoc on the generated TypeScript.
Adding .NET capabilities to your monorepo
To teach your Nx monorepo how to run .NET projects, we install the Nx .NET plugin.
nx add @nx/dotnetThe plugin reads your .csproj, .fsproj and .vbproj files and turns each project into a graph node with targets attached. Project references become graph edges, so nx build on a WebAPI project correctly builds the class libraries it depends on first.
The rest of the pipeline is yours to describe, as ordinary targets.
Using OpenAPI to describe our API surface
The webapi template ships Microsoft.AspNetCore.OpenApi, which can write your OpenAPI document during the build. Add the package that does the writing:
dotnet add apps/products-api package Microsoft.Extensions.ApiDescription.ServerThat leaves one new reference in the project file:
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.11" />
+ <PackageReference Include="Microsoft.Extensions.ApiDescription.Server" Version="10.0.11">
+ <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
+ <PrivateAssets>all</PrivateAssets>
+ </PackageReference>
</ItemGroup>Now the build writes the document, named after the project:
❯ nx build ProductsApi
> nx run ProductsApi:build
> dotnet build --no-restore --no-dependencies
ProductsApi -> apps/products-api/bin/Debug/net9.0/ProductsApi.dll
GenerateOpenApiDocuments:
Generating document named 'v1'.
Writing document named 'v1' to 'apps/products-api/obj/ProductsApi.json'.
Build succeeded.The plugin already infers obj as a build output, so the document is cached and restored with everything else.
Generating the TypeScript client for the API
On the client side we can use the @hey-api/openapi-ts CLI to consume the machine-readable API spec and turn it into TypeScript types. The library is a TypeScript package already, so this goes where any other build step for it would go, as a script:
{
"scripts": {
"codegen": "openapi-ts -i ../../../../apps/products-api/obj/ProductsApi.json -o src/generated -p @hey-api/typescript"
},
"devDependencies": {
"@hey-api/openapi-ts": "0.99.0"
}
}Nx infers a codegen target from that script. Scripts run with the library as their working directory, so the paths are relative to the library rather than to the workspace root. The three flags:
-iis the document the .NET build just wrote.-ois where the generated code goes, which theoutputsentry below has to match.-ppicks what to generate. Here it is types only, which is all the frontend needs. Ask for a client plugin instead, or reach for a different generator entirely, and the same target could give you a full API client.
For now you can run this by hand, but it won't automatically run as part of the build. This is because nothing tells Nx how the two targets relate. That's next.
Wiring the OpenAPI codegen into the Nx task graph
This is what our ideal task pipeline should look like.
Only the middle of that pipeline is hand-written. ProductsApi:build comes from the plugin, and the last edge exists because shared-product-data depends on shared-product-types like any other package.
The rest describes how that target relates to everything else, and lives in the nx block of the same file:
{
"nx": {
"implicitDependencies": ["ProductsApi"],
"targets": {
"codegen": {
"dependsOn": ["^build"],
"inputs": [
{ "dependentTasksOutputFiles": "**/obj/ProductsApi.json" },
"{projectRoot}/package.json",
"sharedGlobals"
],
"outputs": ["{projectRoot}/src/generated"],
"cache": true
}
}
}
}A couple of things worth calling out here:
- Adding
^buildtells the codegen target to make sure its dependencies have built first. implicitDependenciesis how we manually draw an edge in the project graph, relating our types package with the .NET project. Nothing in the repository says this library depends on the .NET project, because the generated code is gitignored and there is no import for Nx to read, so you say it outright.dependentTasksOutputFileshashes the outputs of the tasks you depend on, rather than the source files Nx would hash by default. Listinginputsreplaces the defaults rather than adding to them, which is whysharedGlobalsneeds to be added here as well. The library's ownpackage.jsonis in there because it pins the generator, and a new generator can produce different output from the same document.
As a result, if we now change the C# API contract, the document changes, which changes build's outputs, which changes codegen's hash. If however you edit a comment in Program.cs, the rebuild produces an identical document and the types come back from cache.
Combining the inputs above with the outputs configuration lets us safely enable caching, which makes the target instant when a developer is only working on frontend changes.
Keeping the frontend in sync automatically
The frontend needs almost no Nx configuration. Give the client library a package name, have the app depend on it the way it depends on anything else, and the graph edge follows from the import:
{
"devDependencies": {
"@nx-example/shared-product-types": "workspace:*"
}
}Additionally we need to make sure that anything that compiles the generated sources has them on disk first and also includes them as part of their cache hash. These three target defaults enable that:
{
"targetDefaults": {
"build": { "dependsOn": ["...", "^codegen"] },
"serve": { "dependsOn": ["...", "^codegen"] },
"typecheck": {
"dependsOn": ["...", "codegen"],
"inputs": [
"...",
{ "dependentTasksOutputFiles": "**/*.ts", "transitive": true }
]
}
}
}The ^codegen dependency reaches through the libraries in between whether or not they have a codegen target of their own. On typecheck, transitive does the same for the input, reaching past direct dependencies to codegen two hops away.
Run nx run-many -t typecheck now and it builds the API, writes the document, generates the types, and typechecks everything that consumes them, in that order, skipping whatever it has cached.
If you now rename Name to Title on the C# record, and you run nx run-many -t typecheck you'll see that the libraries that consume the type stop compiling:
src/lib/shared-product-data.ts(6,5): error TS2353: Object literal may only
specify known properties, and 'name' does not exist in type 'Product'.
Failed tasks:
- shared-product-data:typecheckWe basically made .NET API changes typesafe end to end from backend to the frontend that consumes it.
Wrapping up
We've seen the entire chain now of integrating a .NET backend with the frontend in a typesafe way, in a single monorepo:
@nx/dotnetinfers the build graph from your project filesdotnet buildwrites the OpenAPI document intoobj, which the plugin already caches- a command target turns that document into TypeScript types
dependentTasksOutputFileskeeps those types in sync with the document- the frontend imports them
It is not really about OpenAPI either. A build produces a schema, a generator turns that schema into code, and the task graph keeps the two ordered and cached. Point that at protobuf, a GraphQL SDL, or a database schema and the shape does not change. The front end and the backend still share a type, whichever language each of them is written in.
You may not have to wire it yourself
Everything above is hand-written because @nx/dotnet does not infer a codegen target. If your spec producer is on a path the community has already covered, somebody may have done this work for you already. Three plugins in this space, all MIT:
They differ in which generator they wrap and how much of the workspace they expect to own, so read their docs before picking one.
Full example
The complete setup lives in nx-examples: a .NET API under apps/products-api, the generated types in libs/shared/product/types, and the Angular apps consuming the Product type it produces.
Clone it, run yarn install, then nx run-many -t typecheck. Rename a property on the C# record, run nx codegen shared-product-types, and typecheck again. The apps stop compiling, because the type they import is the one the API defines.









