The Problem That Costs You Hours Every Sprint
You define a CreateUserDto in your NestJS backend. You write a User interface in your Next.js frontend. They start identical. Six weeks later, a backend field is renamed, the API is updated, but the frontend type isn't. The bug makes it to production. Someone spends two hours in the network tab wondering why user.fullName is undefined when the API is clearly returning full_name.
This is the type drift problem, and it's entirely solvable.
A shared types package in a monorepo gives your NestJS DTOs and your Next.js components a single source of truth. When the backend changes a field, TypeScript breaks the frontend at compile time — not at runtime, not in production.
Here's exactly how I set it up.
The Monorepo Structure
/portfolio
/apps
/web → Next.js (App Router)
/admin → Next.js admin panel
/services
/api → NestJS backend
/packages
/types → shared DTOs and interfaces ← this is the focus
The packages/types package is pure TypeScript. No framework dependencies. It exports interfaces and types that both the NestJS backend and the Next.js frontends import.
What Goes in the Types Package
// packages/types/src/experience.types.ts
export interface ExperienceDto {
id: string;
company: string;
role: string;
startDate: string; // ISO date string
endDate: string | null;
summary: string;
highlights: string[];
sortOrder: number;
}
export interface CreateExperienceDto extends Omit<ExperienceDto, 'id'> {}
export interface UpdateExperienceDto extends Partial<CreateExperienceDto> {}
These are plain TypeScript interfaces — no decorators, no class-validator, no NestJS. The interfaces define the contract; the backend and frontend both implement it.
The Backend: Classes That Implement the Contract
NestJS needs classes, not interfaces, for class-validator and Swagger to work. But the class can explicitly implement the shared interface:
// services/api/src/modules/content/presentation/dtos/experience.dto.ts
import type { ExperienceDto } from '@portfolio/types';
import { IsString, IsDateString, IsOptional, IsArray } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class ExperienceResponseDto implements ExperienceDto {
@ApiProperty() id: string;
@ApiProperty() company: string;
@ApiProperty() role: string;
@ApiProperty() startDate: string;
@ApiProperty({ nullable: true }) endDate: string | null;
@ApiProperty() summary: string;
@ApiProperty({ type: [String] }) highlights: string[];
@ApiProperty() sortOrder: number;
static fromEntity(entity: Experience): ExperienceResponseDto {
const dto = new ExperienceResponseDto();
Object.assign(dto, entity);
dto.startDate = entity.startDate.toISOString();
dto.endDate = entity.endDate?.toISOString() ?? null;
return dto;
}
}
The implements ExperienceDto is doing the work. If you rename company to companyName in the shared types package, this class gets a compile error immediately. The contract is enforced by the compiler, not by convention.
The Frontend: Import and Trust
// apps/web/src/app/page.tsx
import type { ExperienceDto } from '@portfolio/types';
async function getExperiences(): Promise<ExperienceDto[]> {
const res = await fetch(`${process.env.API_URL}/content/experiences`);
return res.json() as Promise<ExperienceDto[]>;
}
export default async function Home() {
const experiences = await getExperiences();
// TypeScript knows the shape — `experience.company` is string, not any
return experiences.map(experience => (
<ExperienceCard key={experience.id} experience={experience} />
));
}
The frontend doesn't need its own Experience interface. It imports the shared one. Both ends of the wire agree on the shape.
The admin form is the same story:
// apps/admin/src/app/dashboard/experience/page.tsx
import { useForm } from 'react-hook-form';
import type { ExperienceDto } from '@portfolio/types';
const { register, handleSubmit } = useForm<Omit<ExperienceDto, 'id'>>();
Package Config
// packages/types/package.json
{
"name": "@portfolio/types",
"version": "0.0.1",
"main": "./src/index.ts",
"exports": {
".": "./src/index.ts"
}
}
// apps/web/package.json
{
"dependencies": {
"@portfolio/types": "workspace:*"
}
}
With pnpm workspaces, workspace:* resolves to the local package. No publishing, no versioning ceremony. Changes to types are immediately available to all consumers because they're reading the TypeScript source directly.
The tsconfig Setup
// packages/types/tsconfig.json
{
"compilerOptions": {
"strict": true,
"declaration": true,
"moduleResolution": "bundler",
"module": "ESNext",
"target": "ES2022"
},
"include": ["src"]
}
Each consuming package references it:
// apps/web/tsconfig.json
{
"references": [{ "path": "../../packages/types" }]
}
Turborepo handles the build order, so packages/types is always compiled before the apps that depend on it.
What This Catches
The real value shows up when you change something. Say you add a location field to experience entries:
- –Add
location?: stringtoExperienceDtoinpackages/types - –The backend class gets a compile error (it implements the interface but doesn't have
location) - –You add the field to the Prisma schema, write a migration, update the DTO class
- –Any frontend component that tries to access
experience.locationnow has proper type inference — autocomplete works, it's flagged asstring | undefined
Without the shared package, step 2 is silent. The backend ships the field, the frontend either doesn't know it exists or has an any-typed access.
The Caveat: Runtime Trust
The shared interface describes what the API should return. TypeScript doesn't enforce this at runtime — the as Promise<ExperienceDto[]> cast in the fetch call is a claim, not a guarantee.
For internal APIs (where you control both ends), this is fine in practice. The type contract is enforced at compile time on the backend, and the backend owns the shape. If you're consuming third-party APIs, parse the response with Zod or a similar runtime validator and derive the TypeScript type from the schema.
The Outcome
On this portfolio project, I have one types package imported by two Next.js apps and one NestJS service. When I rename a field or add a new DTO, the compiler tells me everywhere that breaks — before I push, before CI runs, before the browser is involved.
That's the promise: make type drift a compile error instead of a runtime surprise. In a full-stack TypeScript monorepo, there's no reason not to.