This article is published in English.
Auto-Generate a Type-Safe Next.js API Client from NestJS Swagger
Learn how to eliminate duplicated API types by using NestJS Swagger and Orval to auto-generate type-safe React Query hooks for Next.js.
Building a full-stack TypeScript application usually begins with a lot of repeated effort.
You define a request type on your NestJS backend. Then you redefine that same shape on your Next.js frontend. You build a controller endpoint, then hand-roll a fetch call to reach it. You tweak an API response, then cross your fingers that you remember every place on the client that depends on it.
This works fine in the early days.
But as the API surface expands, duplicated types and manually written request logic turn into a steady source of bugs and lost time.
A more sustainable approach is to treat the backend's API contract as the single source of truth.
This workflow relies on:
- NestJS
- Swagger
- Orval
- Next.js
- TanStack Query
The core idea is straightforward:
NestJS endpoints + Swagger DTOs
→ OpenAPI document
→ Orval generation
→ TypeScript types, request functions, and React Query hooks
→ Next.js frontend
Rather than manually syncing frontend and backend types by hand, you regenerate the client directly from the API contract whenever it changes.
The full project used as an example is available in this repository: next-modern-stack on GitHub.
The Problem: API Types Drift Apart
Picture yourself adding a "create note" feature to a terminal-styled notepad app.
A typical hand-written frontend implementation might look something like this:
type CreateNoteInput = {
text: string;
folderId: number;
};
type Note = {
id: number;
text: string;
folderId: number;
createdAt: string;
};export async function createNote(input: CreateNoteInput): Promise<Note> {
const response = await fetch("http://localhost:3001/notes", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(input),
}); if (!response.ok) {
throw new Error("Could not create note");
} return response.json();
}
None of this code is inherently wrong.
The issue is that you're now on the hook for maintaining a whole set of things by hand: the shape of the outgoing request, the shape of the incoming response, which URL to call, which HTTP verb to use, how errors are surfaced, how loading state is tracked, how the mutation's status is represented, and how caching and refetching are handled.
Now suppose the backend changes.
Perhaps folderId gets renamed. Perhaps the response gains a new field. Perhaps the route path shifts. Perhaps the API begins returning an entirely different shape.
Your frontend type can drift out of sync without any warning.
The fix is to stop treating the frontend and backend as two independent sources of truth.
Make Swagger the API Contract
Swagger allows your NestJS API to describe its own endpoints, request bodies, and response models.
From that metadata, NestJS can generate a full OpenAPI document.
Here's a DTO for creating a note:
import { ApiProperty, ApiSchema } from "@nestjs/swagger";
@ApiSchema({ name: "CreateNote" })
export class CreateNoteDto {
@ApiProperty({
description: "The text content of the note",
})
text: string; @ApiProperty({
description: "The ID of the folder this note belongs to",
})
folderId: number;
}
This defines exactly what shape the request body must take.
Next, you document the endpoint itself:
import { Body, Controller, Post } from "@nestjs/common";
import { ApiOperation, ApiResponse } from "@nestjs/swagger";
import { CreateNoteDto } from "./create-note.dto";
import { NoteDto } from "./note.dto";
import { NotesService } from "./notes.service";
@Controller("notes")
export class NotesController {
constructor(private readonly notesService: NotesService) {} @Post()
@ApiOperation({
summary: "Create a note",
operationId: "createNote",
})
@ApiResponse({
status: 201,
description: "The note has been successfully created.",
type: NoteDto,
})
create(@Body() createNoteDto: CreateNoteDto) {
return this.notesService.create(
createNoteDto.text,
createNoteDto.folderId,
);
}
}
Two details here matter a lot:
CreateNoteDtodefines the expected request body.NoteDtodefines the shape of a successful response.
The operationId field also plays a key role.
operationId: "createNote";
It assigns the endpoint a stable, human-readable name inside the generated client.
That's what lets the frontend later call a hook named:
useCreateNote();
instead of something vague or auto-derived from the raw route path.
Publish Swagger Documentation from NestJS
With your controllers and DTOs annotated, the next step is wiring up Swagger when the NestJS application boots.
import { NestFactory } from "@nestjs/core";
import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger";
import { AppModule } from "./app.module";
async function bootstrap() {
const app = await NestFactory.create(AppModule); app.enableCors({
origin: "http://localhost:3000",
}); const config = new DocumentBuilder()
.setTitle("Next Modern Stack API")
.setDescription("API documentation for Next Modern Stack")
.setVersion("1.0")
.build(); const document = SwaggerModule.createDocument(app, config); SwaggerModule.setup("api-docs", app, document); await app.listen(process.env.PORT ?? 3001);
}bootstrap();
Once your API is running locally, Swagger exposes two endpoints worth knowing:
http://localhost:3001/api-docs
http://localhost:3001/api-docs-json
The first URL serves the interactive Swagger UI, where you can browse and test endpoints manually.
The second returns the raw OpenAPI document in JSON form, and this is exactly what Orval consumes to build your frontend client.
Generate the Next.js API Client with Orval
Orval's job is to read that OpenAPI document and turn it into TypeScript code your Next.js app can import directly.
In this setup, the Orval config file lives inside the Next.js project itself:
import { defineConfig } from "orval";
export default defineConfig({
api: {
input: "http://localhost:3001/api-docs-json",
output: {
target: "./src/generated/api.ts",
client: "react-query",
httpClient: "fetch",
baseUrl: "http://localhost:3001",
},
},
});
This configuration tells Orval to:
- pull the Swagger JSON from the running NestJS server
- write the generated client to
src/generated/api.ts - produce TanStack Query hooks alongside the raw functions
- rely on the native browser
fetchAPI for requests - point those requests at the local NestJS instance
The Next.js package defines a script to trigger generation:
{
"scripts": {
"generate": "orval --config orval.config.ts"
}
}
At the monorepo root, Turborepo fans that command out across every workspace that needs it:
{
"scripts": {
"generate": "turbo run generate"
}
}
From the repository root, a single command regenerates everything:
bun run generate
Keep in mind that your NestJS server has to be running beforehand, since Orval fetches its schema from:
http://localhost:3001/api-docs-json
What Orval Generates
Running the generator produces a file similar to this one:
apps/web/src/generated/api.ts
Treat this file as build output, not source code — don't hand-edit it.
If something needs to change, update the backend DTOs and Swagger annotations, then rerun generation to regenerate the client.
Given a well-defined API contract, Orval can output:
- TypeScript types for both requests and responses
- fully typed request functions
- TanStack Query hooks for fetching data
- TanStack Query hooks for mutations
- helper functions that expose query keys for cache invalidation
As an example, the folders endpoint is annotated with this operation ID:
@ApiOperation({
summary: "Get all folders",
operationId: "getFolders",
})
Orval turns that into a ready-to-use hook on the frontend:
useGetFolders();
along with a matching query-key helper:
getGetFoldersQueryKey();
Because the operation ID is defined explicitly on the backend, the generated hook and helper names stay consistent and predictable instead of being guessed from the URL path.
Use Generated Hooks in Next.js
With the client generated by Orval, your Next.js frontend no longer needs a hand-written fetch call for each endpoint.
Consider the pattern used in a terminal notepad feature:
import { useQueryClient } from "@tanstack/react-query";
import {
getGetFoldersQueryKey,
useCreateNote,
useGetFolders,
} from "@/generated/api";
export function TerminalContent() {
const queryClient = useQueryClient(); const { data: foldersData } = useGetFolders(); const { mutateAsync: createNote } = useCreateNote(); async function handleCreateNote(text: string, folderId: number) {
await createNote({
data: {
text,
folderId,
},
}); await queryClient.invalidateQueries({
queryKey: getGetFoldersQueryKey(),
});
} return null;
}
The flow works like this:
useGetFolders()retrieves the current folder list.useCreateNote()sends the request to create a note.- Once the mutation resolves successfully,
getGetFoldersQueryKey()points to the cache entry that needs updating,- and TanStack Query automatically refetches the folder data.
As a result, the interface reflects the latest server state without you manually syncing nested pieces of React state. This is one of the strongest benefits of pairing generated hooks with TanStack Query's cache management.
Use Initial Data When the Server Already Has It
In many Next.js setups, some data is already available on the server before a client component even mounts.
For instance, the terminal component might receive its folders as props and feed them into the hook as initial data:
const { data: foldersData } = useGetFolders({
query: {
initialData: {
data: initialFolders,
status: 200,
headers: new Headers(),
},
},
});
Doing this lets the page render instantly using the data you already fetched server-side, while TanStack Query continues to own caching and any later refetching. You keep the advantages of the generated data-fetching layer without discarding work Next.js already did for you.
The Workflow When Your API Changes
Whenever you add or modify an endpoint, follow this sequence:
1. Update the NestJS controller or service
2. Update Swagger DTOs and endpoint metadata
3. Start the API locally
4. Run bun run generate
5. Review the generated API client changes
6. Update frontend usage where needed
7. Run bun run lint:fix
8. Let TypeScript show you any remaining mismatches
As an example, suppose the payload for creating a note changes from this shape:
{
text: string;
folderId: number;
}
to this one, with an added flag:
{
text: string;
folderId: number;
isPinned: boolean;
}
You'd update the backend DTO accordingly:
@ApiSchema({ name: "CreateNote" })
export class CreateNoteDto {
@ApiProperty()
text: string;
@ApiProperty()
folderId: number; @ApiProperty()
isPinned: boolean;
}
Then regenerate the client:
bun run generate
From that point on, calling createNote() on the frontend requires isPinned, and TypeScript will flag every call site that still needs updating. That kind of immediate, compiler-driven feedback is far more reliable than trusting yourself to remember every place a manually maintained type needs adjusting in a separate codebase.
Why This Is Better Than a Shared Types Package
A common pattern in monorepos is to set up a dedicated package, something like:
packages/
└── types/
Both the frontend and backend then import the same TypeScript interfaces from that shared location.
This can work reasonably well in certain situations.
However, it only addresses part of the challenge of keeping an API in sync.
Simply sharing interfaces leaves several things missing:
- endpoints described in documentation
- request functions that carry proper types
- mutation hooks that carry proper types
- consistent cache keys
- centralized endpoint paths
- consistent HTTP method definitions
- a reference other developers can browse
- a contract that additional clients could consume
The combination of Swagger and Orval gives you an API-first pipeline instead.
The backend defines and owns the contract.
The frontend simply consumes code generated from that contract.
The result is a much cleaner separation between the two applications.
Common Mistakes to Avoid
Manually editing generated files
Never hand-edit a file like this one directly:
apps/web/src/generated/api.ts
Any changes you make there will be wiped out the next time the client is regenerated.
Instead, correct the contract on the backend and regenerate the client.
Skipping operationId
If you leave operation IDs undefined, route names in the generated code can end up looking messy or unpredictable.
Assign clear, descriptive IDs instead, such as:
operationId: "getFolders";
operationId: "createNote";
operationId: "updateNote";
Doing so produces much more readable hook names on the frontend.
Forgetting to regenerate after backend changes
The frontend has no way of knowing an endpoint changed until you rerun the generation step.
Treat regeneration as a routine part of your development cycle, not an afterthought.
Writing custom fetch functions beside generated hooks
Default to using the hooks Orval generates for you.
Only reach for a hand-written fetch function when you run into a real limitation the generated client can't handle.
Otherwise you're just reintroducing the same duplicated request logic you were trying to eliminate.
Treating generated types as runtime validation
Generated TypeScript types are useful for catching mistakes while you write code.
They provide no protection against unknown or malformed input arriving at runtime.
For things like form submissions, URL parameters, webhook payloads, or data from third-party services, pair your types with actual runtime validation, using something like Zod.
One API Contract, Less Repetitive Work
The biggest benefit of combining Swagger and Orval isn't just improved type safety.
It's that you stop having to make the same decisions over and over.
Rather than rebuilding a frontend API layer by hand for every single endpoint, you describe the contract once and let the repetitive, predictable parts get generated automatically.
NestJS endpoint
→ Swagger contract
→ Orval generated client
→ TanStack Query hook
→ Next.js UI
The payoff includes:
- fewer duplicated type definitions
- fewer manually written request functions
- a clearer boundary between frontend and backend
- immediate TypeScript errors when the API shape changes
- ready-made query and mutation hooks
- simpler cache invalidation
- API documentation your whole team can reference
This setup also makes it safer to let AI tools contribute to the codebase.
When an AI assistant adds a new backend endpoint, you can point it through a straightforward sequence:
Update the NestJS controller and DTOs
→ document the endpoint with Swagger
→ run bun run generate
→ use the generated hook in Next.js
→ run Biome
That's a far more dependable pattern than asking an AI assistant to invent and maintain scattered, duplicated API code across the project.
Build the Full Workflow
This Swagger-and-Orval pipeline is one piece of a broader modern TypeScript setup that pairs Next.js and NestJS together, using tools such as Bun workspaces, Turborepo, PostgreSQL with Prisma, TanStack Query, nuqs, Biome, and Lefthook, along with AI-assisted workflows built around reusable rules and skills.
You can inspect a complete, working example of this setup here: