Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Keeping .NET as Your API: Next.js/Nuxt as a Typed Frontend for ASP.NET Core

For .NET engineers who know: ASP.NET Core Web APIs, Swagger/Swashbuckle, Entity Framework Core, and JWT authentication You’ll learn: How to connect a Next.js or Nuxt frontend to an existing ASP.NET Core API with full end-to-end type safety — and when this architecture is the right call Time: 25-30 min read


The .NET Way (What You Already Know)

You have a working ASP.NET Core Web API. It has battle-tested controllers, complex EF Core queries tuned over months, middleware that handles multi-tenant auth, and business logic that would take a year to replicate. Your team knows it cold. The Swagger UI is the de facto contract with every consumer.

This is not a greenfield situation. This is most production systems.

The instinct when you start learning the modern JS stack is to assume you need to throw away the backend and rebuild in NestJS or tRPC. That instinct is wrong in a significant number of real-world cases. Understanding when to keep your .NET backend — and how to wire it properly to a TypeScript frontend — is one of the most practically valuable skills in this curriculum.

Here is a mature ASP.NET Core endpoint that will serve as the running example throughout this article:

// ProductsController.cs
[ApiController]
[Route("api/[controller]")]
[Authorize]
public class ProductsController : ControllerBase
{
    private readonly AppDbContext _db;

    public ProductsController(AppDbContext db) => _db = db;

    [HttpGet]
    [ProducesResponseType(typeof(PagedResult<ProductDto>), 200)]
    public async Task<IActionResult> GetProducts(
        [FromQuery] ProductFilterDto filter,
        CancellationToken ct)
    {
        var query = _db.Products
            .Where(p => !p.IsDeleted)
            .Where(p => filter.CategoryId == null || p.CategoryId == filter.CategoryId)
            .Select(p => new ProductDto
            {
                Id = p.Id,
                Name = p.Name,
                Price = p.Price,
                StockCount = p.StockCount,
                CreatedAt = p.CreatedAt,
                Category = new CategoryDto { Id = p.Category.Id, Name = p.Category.Name }
            });

        var total = await query.CountAsync(ct);
        var items = await query
            .OrderBy(p => p.Name)
            .Skip((filter.Page - 1) * filter.PageSize)
            .Take(filter.PageSize)
            .ToListAsync(ct);

        return Ok(new PagedResult<ProductDto>
        {
            Items = items,
            Total = total,
            Page = filter.Page,
            PageSize = filter.PageSize
        });
    }

    [HttpPost("{id}/purchase")]
    [ProducesResponseType(typeof(PurchaseResultDto), 200)]
    [ProducesResponseType(typeof(ProblemDetails), 400)]
    public async Task<IActionResult> Purchase(
        int id,
        [FromBody] PurchaseRequestDto request,
        CancellationToken ct)
    {
        // Complex business logic, inventory checks, payment processing
        // — code that took months to get right
    }
}

That [ProducesResponseType] decoration is the bridge to the TypeScript world. Every annotated endpoint feeds Swashbuckle, which generates an OpenAPI specification, which a code generator transforms into TypeScript types. This is the chain you are about to build.


The Architecture

graph TD
    Browser["Browser"]

    subgraph FE["Next.js / Nuxt (Render / Vercel / Azure SWA)"]
        SC["Server Components / Pages (RSC)"]
        CC["Client Components (TanStack Query)"]
        SC --- CC
    end

    subgraph API["ASP.NET Core Web API (Azure App Service / Render)"]
        Controllers["Controllers (REST/gRPC)"]
        Middleware["Middleware Auth/Tenant"]
        BGServices["Background Services"]
        EFCore["EF Core + Migrations"]
        DomainSvc["Domain Services"]
        Controllers --> EFCore
        Controllers --- Middleware
        Controllers --- BGServices
        Controllers --- DomainSvc
    end

    DB["SQL Server / PostgreSQL"]

    Browser -->|HTTPS| FE
    FE -->|"HTTPS + JWT / Cookie\nGenerated TS types + Zod validation"| API
    EFCore --> DB

The key observation: Next.js and Nuxt sit in front, handling rendering, routing, and auth session management. The ASP.NET Core API handles data, business logic, and everything it already does well. The contract between them is the OpenAPI specification.


Why Keep .NET? A Practical Decision Framework

Before building, understand when this architecture is the right call versus when you should consolidate to a full TypeScript stack.

Keep .NET when:

  • You have existing EF Core queries with years of performance tuning (complex joins, compiled queries, raw SQL fallbacks). Rewriting these in Prisma or Drizzle is expensive and the behavior may not be identical.
  • Your API serves multiple consumers — a mobile app, partner integrations, internal tooling — and is not exclusively the BFF for one frontend.
  • You run CPU-intensive workloads. The CLR’s multi-threading model and JIT compiler genuinely outperform Node.js for compute-heavy tasks. Node.js is single-threaded and non-blocking I/O is its strength, not parallel CPU computation.
  • You have enterprise integrations: Active Directory, MSMQ, COM interop, SOAP services, or Windows-specific APIs. Node.js does not have mature equivalents for these.
  • Your team’s .NET expertise is deep. Rewriting in a new language/runtime while simultaneously shipping features is a reliable way to introduce bugs.
  • You have compliance requirements (SOC 2, HIPAA) already satisfied by your .NET infrastructure.
  • You are using gRPC for high-performance inter-service communication. gRPC-Web lets a TypeScript frontend consume gRPC directly, with Protobuf providing the type source of truth for both C# and TypeScript.

Move toward a TypeScript API when:

  • You are building a new product with a small team and want one language end-to-end.
  • Your API exists solely as a BFF for one Next.js/Nuxt application with no other consumers.
  • You want tRPC’s zero-boilerplate type sharing (tRPC requires both ends to be TypeScript — it cannot work with a .NET backend).
  • Your team’s .NET skills are shallow and your TypeScript skills are stronger.

This article is for the former scenario. You have a good .NET API. Now you need a modern frontend for it.


The New Stack Way

Step 1: Prepare the .NET API for Frontend Consumption

Before touching the frontend, make sure the .NET side is properly configured for a separate client origin.

CORS — get this right from the start:

// Program.cs
var allowedOrigins = builder.Configuration
    .GetSection("Cors:AllowedOrigins")
    .Get<string[]>() ?? [];

builder.Services.AddCors(options =>
{
    options.AddPolicy("Frontend", policy =>
    {
        policy
            .WithOrigins(allowedOrigins)   // Never use AllowAnyOrigin with credentials
            .AllowAnyHeader()
            .AllowAnyMethod()
            .AllowCredentials();           // Required if you send cookies
    });
});

// Must come before UseAuthorization
app.UseCors("Frontend");

In appsettings.Development.json:

{
  "Cors": {
    "AllowedOrigins": ["http://localhost:3000"]
  }
}

In production (environment variable):

Cors__AllowedOrigins__0=https://yourapp.vercel.app
Cors__AllowedOrigins__1=https://yourapp.com

Swagger/OpenAPI — make the spec machine-readable:

builder.Services.AddSwaggerGen(c =>
{
    c.SwaggerDoc("v1", new OpenApiInfo { Title = "Products API", Version = "v1" });

    // Include XML comments for richer type documentation
    var xmlFile = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml";
    var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile);
    c.IncludeXmlComments(xmlPath);

    // JWT bearer auth in Swagger UI (for manual testing)
    c.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
    {
        Type = SecuritySchemeType.Http,
        Scheme = "bearer",
        BearerFormat = "JWT"
    });
    c.AddSecurityRequirement(new OpenApiSecurityRequirement
    {
        {
            new OpenApiSecurityScheme
            {
                Reference = new OpenApiReference
                {
                    Type = ReferenceType.SecurityScheme,
                    Id = "Bearer"
                }
            },
            []
        }
    });

    // Serialize enums as strings — critical for TypeScript interop (covered in Gotchas)
    c.UseInlineDefinitionsForEnums();
});

// Expose the spec at /swagger/v1/swagger.json
app.UseSwagger();

Add to your .csproj:

<PropertyGroup>
  <GenerateDocumentationFile>true</GenerateDocumentationFile>
  <NoWarn>$(NoWarn);1591</NoWarn>
</PropertyGroup>

Enum serialization — configure globally:

builder.Services.AddControllers()
    .AddJsonOptions(options =>
    {
        // Serialize enums as strings ("Active") not integers (1)
        options.JsonSerializerOptions.Converters.Add(
            new JsonStringEnumConverter());

        // Serialize property names as camelCase
        options.JsonSerializerOptions.PropertyNamingPolicy =
            JsonNamingPolicy.CamelCase;
    });

This is not optional. TypeScript unions and enums both expect string values. An API returning 1 where the TypeScript type expects "Active" is a silent runtime failure — the field will not match any union arm and you will get undefined behavior.


Step 2: Generate TypeScript Types from OpenAPI

This is the heart of the type-safety story. You do not write TypeScript interfaces by hand. You generate them from the same Swashbuckle spec that documents your API.

The recommended tool is openapi-typescript. It is fast, produces clean output, and has no runtime dependency — it runs at build/CI time only.

Install:

npm install -D openapi-typescript

Configure in package.json:

{
  "scripts": {
    "generate-api": "openapi-typescript http://localhost:5000/swagger/v1/swagger.json -o src/lib/api-types.gen.ts",
    "generate-api:prod": "openapi-typescript $API_URL/swagger/v1/swagger.json -o src/lib/api-types.gen.ts"
  }
}

What the generated output looks like:

Given the ProductDto from the controller:

public class ProductDto
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public decimal Price { get; set; }
    public int StockCount { get; set; }
    public DateTime CreatedAt { get; set; }
    public CategoryDto Category { get; set; } = new();
}

The generator produces:

// src/lib/api-types.gen.ts — DO NOT EDIT BY HAND
export interface components {
  schemas: {
    ProductDto: {
      id: number;
      name: string;
      price: number;
      stockCount: number;
      createdAt: string;        // <-- DateTime becomes string (ISO 8601)
      category: components["schemas"]["CategoryDto"];
    };
    CategoryDto: {
      id: number;
      name: string;
    };
    PagedResultProductDto: {
      items: components["schemas"]["ProductDto"][];
      total: number;
      page: number;
      pageSize: number;
    };
  };
}

// Convenience type aliases
export type ProductDto = components["schemas"]["ProductDto"];
export type CategoryDto = components["schemas"]["CategoryDto"];
export type PagedResultProductDto = components["schemas"]["PagedResultProductDto"];

Note createdAt: string. This is correct and expected. The JSON wire format carries ISO 8601 strings. You must parse them into Date objects explicitly. This is covered in detail in the Gotchas section.

For heavier use cases — orval:

If you need typed fetch functions, TanStack Query hooks, or mock service worker handlers generated automatically, use orval instead of openapi-typescript. It wraps the type generation and produces ready-to-use hooks:

npm install -D orval

orval.config.ts:

import { defineConfig } from "orval";

export default defineConfig({
  productsApi: {
    input: {
      target: "http://localhost:5000/swagger/v1/swagger.json",
    },
    output: {
      target: "src/lib/api-client.gen.ts",
      client: "react-query",        // generates TanStack Query hooks
      httpClient: "fetch",
      override: {
        mutator: {
          path: "src/lib/custom-fetch.ts",
          name: "customFetch",      // your authenticated fetch wrapper
        },
      },
    },
  },
});

This generates hooks like:

// Generated — do not edit
export const useGetProducts = (
  params: GetProductsParams,
  options?: UseQueryOptions<PagedResultProductDto>
) => {
  return useQuery({
    queryKey: ["products", params],
    queryFn: () => customFetch<PagedResultProductDto>(`/api/products`, { params }),
    ...options,
  });
};

Step 3: Zod Validation at the API Boundary

Generated types tell TypeScript what to expect. Zod tells you at runtime whether the actual response matches. These are complementary, not redundant.

The type generator trusts the OpenAPI spec. If your .NET controller returns a field the spec does not declare, TypeScript will not know about it and will not complain. If your .NET API has a bug and returns null for a field declared non-nullable, TypeScript’s type-level guarantees are violated silently.

Zod closes this gap:

// src/lib/schemas/product.schema.ts
import { z } from "zod";

export const CategorySchema = z.object({
  id: z.number().int().positive(),
  name: z.string().min(1),
});

export const ProductSchema = z.object({
  id: z.number().int().positive(),
  name: z.string().min(1),
  price: z.number().nonnegative(),
  stockCount: z.number().int().nonnegative(),
  // Parse ISO 8601 string -> Date object at the boundary
  createdAt: z.string().datetime().transform((val) => new Date(val)),
  category: CategorySchema,
});

export const PagedResultProductSchema = z.object({
  items: z.array(ProductSchema),
  total: z.number().int().nonnegative(),
  page: z.number().int().positive(),
  pageSize: z.number().int().positive(),
});

// Infer TS types FROM the Zod schema — single source of truth
export type Product = z.infer<typeof ProductSchema>;
export type PagedResultProduct = z.infer<typeof PagedResultProductSchema>;

Integrate into your fetch layer:

// src/lib/api-client.ts
import { PagedResultProductSchema, type PagedResultProduct } from "./schemas/product.schema";

async function fetchProducts(params: GetProductsParams): Promise<PagedResultProduct> {
  const url = new URL(`${process.env.NEXT_PUBLIC_API_URL}/api/products`);
  Object.entries(params).forEach(([k, v]) => {
    if (v != null) url.searchParams.set(k, String(v));
  });

  const res = await fetch(url.toString(), {
    headers: { Authorization: `Bearer ${await getToken()}` },
    next: { tags: ["products"] }, // Next.js cache tag for on-demand revalidation
  });

  if (!res.ok) {
    throw new ApiError(res.status, await res.json());
  }

  const raw = await res.json();

  // safeParse gives you the error without throwing — parse gives you throw-on-failure
  const result = PagedResultProductSchema.safeParse(raw);

  if (!result.success) {
    // Log to your observability platform — this is a contract violation
    console.error("API contract violation:", result.error.format());
    // Re-throw or return a degraded result — your call
    throw new Error(`API response did not match expected schema`);
  }

  return result.data; // Fully typed, date fields are now Date objects
}

A note on parse vs. safeParse: Use safeParse in production and log failures to your observability platform (Sentry, Datadog) rather than throwing blindly. A runtime type mismatch between your .NET API and your frontend schema is important diagnostic information — treat it as such.


Step 4: TanStack Query for Data Fetching

TanStack Query (formerly React Query) is the standard for server state management in the React/Next.js ecosystem. Think of it as a combination of IMemoryCache, IHttpClientFactory, and a Blazor/SignalR reactive binding — all in one library.

// src/hooks/use-products.ts
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { fetchProducts, purchaseProduct } from "@/lib/api-client";
import type { GetProductsParams } from "@/lib/api-types.gen";

export function useProducts(params: GetProductsParams) {
  return useQuery({
    queryKey: ["products", params],   // Cache key — params changes = new fetch
    queryFn: () => fetchProducts(params),
    staleTime: 60 * 1000,             // Consider data fresh for 60 seconds
    gcTime: 5 * 60 * 1000,           // Keep in memory 5 min after last subscriber
  });
}

export function usePurchaseProduct() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: purchaseProduct,
    onSuccess: () => {
      // Invalidate the products cache — next render refetches from .NET API
      queryClient.invalidateQueries({ queryKey: ["products"] });
    },
    onError: (error: ApiError) => {
      // Handle 400 ProblemDetails from .NET
      console.error("Purchase failed:", error.detail);
    },
  });
}

Using in a component:

// src/components/ProductList.tsx
"use client";

import { useProducts } from "@/hooks/use-products";
import { usePurchaseProduct } from "@/hooks/use-products";

export function ProductList({ categoryId }: { categoryId?: number }) {
  const { data, isLoading, error } = useProducts({
    page: 1,
    pageSize: 20,
    categoryId,
  });

  const purchase = usePurchaseProduct();

  if (isLoading) return <ProductSkeleton />;
  if (error) return <ErrorDisplay error={error} />;

  return (
    <ul>
      {data.items.map((product) => (
        <li key={product.id}>
          <span>{product.name}</span>
          <span>${product.price.toFixed(2)}</span>
          {/* product.createdAt is a Date object here — Zod did the transform */}
          <span>Added {product.createdAt.toLocaleDateString()}</span>
          <button
            onClick={() => purchase.mutate({ productId: product.id, quantity: 1 })}
            disabled={purchase.isPending}
          >
            Purchase
          </button>
        </li>
      ))}
    </ul>
  );
}

Step 5: Authentication — Forwarding JWT to ASP.NET Core

This is where most engineers hit problems. The auth flow differs significantly depending on whether your Next.js components are Server Components (RSC) or Client Components.

This example uses Clerk as the auth provider, but the pattern applies equally to NextAuth.js or Auth0.

The auth problem: Your ASP.NET Core API expects a Bearer JWT in the Authorization header. In Server Components, you can get this token from the server-side session. In Client Components, the token is in the browser’s memory (or a cookie). You need both patterns.

Server Component (SSR data fetch):

// src/app/products/page.tsx — Server Component
import { auth } from "@clerk/nextjs/server";
import { fetchProductsServer } from "@/lib/api-client.server";

export default async function ProductsPage() {
  const { getToken } = await auth();

  // Get the JWT token on the server — never touches the browser
  const token = await getToken();

  // Fetch directly from .NET API — no round-trip through the client
  const products = await fetchProductsServer(token, {
    page: 1,
    pageSize: 20,
  });

  return <ProductList initialData={products} />;
}
// src/lib/api-client.server.ts — server-only fetch utilities
import "server-only"; // Prevents accidental import in client components

export async function fetchProductsServer(
  token: string | null,
  params: GetProductsParams
): Promise<PagedResultProduct> {
  const res = await fetch(
    `${process.env.API_URL}/api/products?${new URLSearchParams(params as Record<string, string>)}`,
    {
      headers: {
        Authorization: token ? `Bearer ${token}` : "",
        "Content-Type": "application/json",
      },
      next: { revalidate: 60, tags: ["products"] },
    }
  );

  if (!res.ok) throw new Error(`API error: ${res.status}`);
  return PagedResultProductSchema.parse(await res.json());
}

Client Component (interactive mutations):

// src/lib/api-client.ts — browser-side fetch utilities
import { useAuth } from "@clerk/nextjs";

// Hook that returns a typed fetch function with auth attached
export function useApiClient() {
  const { getToken } = useAuth();

  return {
    async fetchProducts(params: GetProductsParams): Promise<PagedResultProduct> {
      const token = await getToken();

      const res = await fetch(
        `${process.env.NEXT_PUBLIC_API_URL}/api/products?${new URLSearchParams(
          params as Record<string, string>
        )}`,
        {
          headers: { Authorization: `Bearer ${token}` },
        }
      );

      return PagedResultProductSchema.parse(await res.json());
    },
  };
}

ASP.NET Core JWT validation (accept Clerk-issued tokens):

// Program.cs
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.Authority = "https://clerk.your-domain.com"; // Clerk JWKS endpoint
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuer = true,
            ValidateAudience = false,  // Clerk does not set aud by default
            ValidateLifetime = true,
        };
    });

Step 6: CI Pipeline for Type Generation

The type generation step must run in CI, not just locally. If a developer changes a DTO in the .NET project and does not regenerate types, the TypeScript build will fail — which is exactly what you want.

# .github/workflows/type-check.yml
name: Type Check

on:
  push:
    branches: [main, develop]
  pull_request:

jobs:
  generate-and-check:
    runs-on: ubuntu-latest

    services:
      dotnet-api:
        image: your-registry/your-api:latest
        ports:
          - 5000:80

    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: "20"
          cache: "npm"

      - name: Install dependencies
        run: npm ci

      - name: Wait for API to be ready
        run: |
          timeout 30 bash -c 'until curl -sf http://localhost:5000/swagger/v1/swagger.json; do sleep 1; done'

      - name: Generate API types
        run: npm run generate-api:ci
        env:
          API_URL: http://localhost:5000

      - name: Check for type drift
        run: |
          git diff --exit-code src/lib/api-types.gen.ts || \
          (echo "API types are out of sync. Run npm run generate-api and commit." && exit 1)

      - name: TypeScript type check
        run: npx tsc --noEmit

      - name: Run tests
        run: npm test

Alternatively, for faster CI pipelines without running the .NET app, generate types from a committed openapi.json file in the repository and regenerate it as part of the .NET build:

// In your .NET project's test or a dedicated CLI tool
// Generate the spec during CI
app.MapSwagger();
// Use Swashbuckle CLI: dotnet tool install -g Swashbuckle.AspNetCore.Cli
// swashbuckle tofile --output openapi.json http://localhost:5000
# .github/workflows/generate-spec.yml — runs in the .NET CI
- name: Generate OpenAPI spec
  run: |
    dotnet tool install -g Swashbuckle.AspNetCore.Cli
    swashbuckle tofile --output openapi.json ${{ env.APP_STARTUP_ASSEMBLY }}
- name: Commit spec if changed
  run: |
    git diff --exit-code openapi.json || \
    (git add openapi.json && git commit -m "chore: regenerate openapi.json")

Step 7: gRPC-Web (The Alternative for High-Performance Scenarios)

If your .NET API uses gRPC, the Protobuf schema is the single source of truth for both C# and TypeScript types. No OpenAPI step needed.

// products.proto
syntax = "proto3";

package products.v1;

service ProductsService {
  rpc GetProducts (GetProductsRequest) returns (GetProductsResponse);
  rpc Purchase (PurchaseRequest) returns (PurchaseResponse);
}

message Product {
  int32 id = 1;
  string name = 2;
  double price = 3;
  int32 stock_count = 4;
  google.protobuf.Timestamp created_at = 5;
}

message GetProductsResponse {
  repeated Product items = 1;
  int32 total = 2;
}

Generate TypeScript client:

npm install -D @protobuf-ts/plugin
npx protoc --plugin=./node_modules/.bin/protoc-gen-ts \
  --ts_out=src/lib/proto \
  --ts_opt=long_type_string \
  products.proto

The Timestamp type resolves to a proper Date object in the TypeScript client — avoiding the DateTime string gotcha entirely.

gRPC-Web requires a proxy (Envoy or the Grpc.AspNetCore.GrpcWebProtocol middleware) to translate between browser HTTP/1.1 and gRPC’s HTTP/2. In practice, REST + OpenAPI is simpler for standard CRUD scenarios. Reserve gRPC for high-throughput scenarios where the binary protocol and bidirectional streaming justify the setup overhead.


Key Differences

ConcernASP.NET CoreNext.js consuming ASP.NET Core
Type sharingImplicit (same project/solution)Codegen from OpenAPI spec
API contractEnforced by compilerEnforced by type gen + Zod at runtime
Auth flowMiddleware/attributesServer: getToken() from server session; Client: hook
Date/timeDateTime, DateTimeOffsetstring in JSON, Date in TS after Zod parse
Enum valuesint by defaultMust configure JsonStringEnumConverter
CacheIMemoryCache, IDistributedCacheTanStack Query + Next.js fetch cache
Error handlingProblemDetails (RFC 7807)Parse ProblemDetails shape in error handler
Null handlingNullable reference typesundefined for absent fields, null for explicit null

Gotchas for .NET Engineers

Gotcha 1: DateTime Serialization — The Silent Data Corruption Bug

In .NET, DateTime serializes to ISO 8601 by default: "2026-01-15T09:30:00". Without a timezone suffix, the JSON deserializer on the TypeScript side assumes local time. With a suffix (Z or +00:00), it assumes UTC.

The problem is DateTime vs DateTimeOffset:

// This serializes as "2026-01-15T09:30:00" — no timezone info
public DateTime CreatedAt { get; set; }

// This serializes as "2026-01-15T09:30:00+00:00" — explicit UTC offset
public DateTimeOffset CreatedAt { get; set; }

When TypeScript does new Date("2026-01-15T09:30:00"), the result depends on the browser’s local timezone. A user in Tokyo sees a different time than a user in New York. This is a data display bug that is almost impossible to catch in development (everyone on the team is in the same timezone).

Fix:

  1. Use DateTimeOffset everywhere in your .NET DTOs, or configure DateTime to serialize as UTC:
options.JsonSerializerOptions.Converters.Add(
    new JsonConverterDateTimeAsUtc());  // Custom converter or use NodaTime serializers
  1. In your Zod schema, always parse the string and be explicit:
createdAt: z.string().datetime({ offset: true }).transform((val) => new Date(val)),

The offset: true flag requires an explicit offset in the ISO string — it will reject bare "2026-01-15T09:30:00" without Z or +HH:MM, surfacing the bug immediately.

  1. In CI, add a test that checks a known UTC timestamp round-trips correctly through your API.

Gotcha 2: Enum Integer vs. String — TypeScript Union Exhaustion Fails Silently

By default, System.Text.Json serializes enums as their integer values:

public enum OrderStatus { Pending = 0, Processing = 1, Shipped = 2, Delivered = 3 }
// Serializes as: { "status": 1 }

Your OpenAPI-generated TypeScript type will be:

// Without JsonStringEnumConverter
status: number;  // Loses all semantic information

// What you wanted
status: "Pending" | "Processing" | "Shipped" | "Delivered";

With integer enums, TypeScript cannot perform exhaustive switch checks, your UI cannot render human-readable labels without a separate mapping table, and adding a new enum value does not trigger a type error in the frontend.

Fix — apply globally:

builder.Services.AddControllers()
    .AddJsonOptions(options =>
    {
        options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());
    });

This is a breaking change if you have existing consumers that send integer values. Coordinate the change across all API consumers before deploying.

Gotcha 3: null vs. undefined — The Behavioral Gap

ASP.NET Core and System.Text.Json serialize missing optional values as null. TypeScript’s type system distinguishes between null (explicitly absent) and undefined (property does not exist), but JSON has no equivalent of undefined — it can only represent null or a missing key.

This creates a mapping problem:

// From the API, you receive:
{ "description": null }

// TypeScript's OpenAPI-generated type:
description: string | null;

// But in your component, you might write:
if (!product.description) { ... }     // Catches both null and ""
if (product.description == null) { } // Catches null but not undefined
if (product.description === undefined) { } // Never true — JSON always sends null

The practical impact: form state in React typically uses undefined for “user has not entered a value yet” and "" for “user cleared the field”. When you pre-populate a form from API data, null from the API becomes null in your form state, which React controlled inputs treat differently from undefined.

Fix:

In your Zod schema, normalize null to undefined for optional fields if your form layer expects it:

description: z.string().nullable().optional().transform((val) => val ?? undefined),

Or leave it as null and handle it consistently in your form state management. The key is picking one convention and enforcing it at the Zod boundary.

Gotcha 4: ProblemDetails Error Handling

ASP.NET Core returns errors as ProblemDetails (RFC 7807) when you use the built-in validation and [ApiController] attribute. The shape is:

{
  "type": "https://tools.ietf.org/html/rfc7807",
  "title": "One or more validation errors occurred.",
  "status": 400,
  "errors": {
    "Price": ["Price must be greater than 0"],
    "Name": ["Name is required"]
  }
}

TypeScript fetch does not throw on 4xx/5xx responses — it only throws on network failure. You must check res.ok explicitly. The typical .NET instinct is to wrap everything in a try/catch and expect HTTP errors to throw — they do not.

// Wrong — res is "successful" from fetch's perspective even for a 400
const res = await fetch("/api/products", { method: "POST", body: JSON.stringify(data) });
const json = await res.json(); // Contains ProblemDetails, not your ProductDto

// Correct
const res = await fetch("/api/products", { method: "POST", body: JSON.stringify(data) });

if (!res.ok) {
  const problem = await res.json() as ProblemDetails;
  // Map validation errors back to form fields
  throw new ApiError(res.status, problem);
}

const product = ProductSchema.parse(await res.json());

Define a typed ProblemDetails interface:

interface ProblemDetails {
  type?: string;
  title?: string;
  status?: number;
  detail?: string;
  errors?: Record<string, string[]>; // Validation errors
}

Gotcha 5: Next.js Server Component Fetch Caching and Stale Data

Next.js extends the browser fetch API with caching behavior that has no equivalent in .NET. By default in Next.js 14/15, fetch calls in Server Components are not cached unless you explicitly opt in. This differs from earlier Next.js versions and confuses engineers coming from any background.

// No caching — fetches on every request (default in Next.js 14+)
fetch("/api/products")

// Cache for 60 seconds
fetch("/api/products", { next: { revalidate: 60 } })

// Cache indefinitely, invalidate via tag
fetch("/api/products", { next: { tags: ["products"] } })

// On a form submit, invalidate the tag:
import { revalidateTag } from "next/cache";
revalidateTag("products"); // Next.js re-fetches on next request

If your Server Components show stale data after a mutation, check whether your fetch calls have appropriate revalidate or tags configuration, and whether your mutation handlers call revalidateTag.


Hands-On Exercise

Goal: Connect a Next.js 14 app to a running ASP.NET Core API with full type safety.

Prerequisites:

  • ASP.NET Core 8+ API running locally on port 5000 with Swashbuckle configured
  • Next.js 14 app (create with npx create-next-app@latest --typescript)
  • Node.js 20+

Step 1 — Verify the OpenAPI spec is accessible:

curl http://localhost:5000/swagger/v1/swagger.json | jq '.paths | keys[]'

You should see your endpoint paths listed.

Step 2 — Generate TypeScript types:

npm install -D openapi-typescript
npx openapi-typescript http://localhost:5000/swagger/v1/swagger.json \
  --output src/lib/api-types.gen.ts

Open src/lib/api-types.gen.ts and examine the generated interfaces. Find a DateTime property. Note that it is typed as string. Find any enum properties — note they are string union types (if you configured JsonStringEnumConverter) or number (if you did not).

Step 3 — Write a Zod schema for one endpoint’s response:

Pick the simplest GET endpoint in your API. Write a Zod schema for its response shape, using z.string().datetime() for date fields and .transform((v) => new Date(v)) to hydrate them.

Step 4 — Write a typed fetch function:

Write a function that fetches from your .NET endpoint, runs the Zod parse, and returns the typed result. Call it from a Server Component and render the data.

Step 5 — Add TanStack Query for an interactive mutation:

Install @tanstack/react-query. Wrap your app in QueryClientProvider. Write a useMutation hook for a POST endpoint. Wire it to a button click in a Client Component.

Step 6 — Simulate a contract violation:

Temporarily change one of your .NET DTO properties (rename it or change its type). Run npm run generate-api — observe the generated types change. Note where TypeScript now shows errors. Revert the .NET change and observe that npm run generate-api restores the types.

This exercise demonstrates the feedback loop: the type generator is the early warning system for cross-language contract drift.


Quick Reference

OpenAPI type generation (one-time setup)
  npm install -D openapi-typescript
  npx openapi-typescript <url>/swagger.json -o src/lib/api-types.gen.ts

For hooks generation: use orval instead
  npm install -D orval
  npx orval  (reads orval.config.ts)

Zod date/time pattern
  z.string().datetime({ offset: true }).transform((v) => new Date(v))

Zod nullable-to-optional normalization
  z.string().nullable().optional().transform((v) => v ?? undefined)

Fetch error pattern (fetch does NOT throw on 4xx/5xx)
  if (!res.ok) { const err = await res.json(); throw new ApiError(res.status, err); }

Next.js fetch cache tags
  fetch(url, { next: { tags: ["products"] } })
  revalidateTag("products")  // In Server Action after mutation

Server-only imports (prevent client bundle leaks)
  import "server-only"  // At top of any file with server secrets

CORS — allow credentials requires explicit origin list
  policy.WithOrigins(allowedOrigins).AllowCredentials()
  // Never: AllowAnyOrigin() + AllowCredentials() — will throw

Enum serialization — must add globally to .NET
  options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter())

DateTime — use DateTimeOffset in DTOs, not DateTime
  public DateTimeOffset CreatedAt { get; set; }

ProblemDetails shape (ASP.NET Core validation errors)
  { status: number, title: string, errors: Record<string, string[]> }

gRPC-Web type generation
  npm install -D @protobuf-ts/plugin
  npx protoc --plugin=protoc-gen-ts --ts_out=src/lib/proto *.proto

Further Reading