Part 3.5 — Shaping API Contracts for the Frontend (React Integration)
Communication between frontend and backend is a contract.
When that contract is clear, predictable, and typed end-to-end, everything else becomes easier: feature work, refactoring, testing, and debugging.
This post ties together the pieces from Parts 2 and 3.
You’ll build shared API contracts, ensure consistent REST output, and integrate your NestJS backend with your React frontend — using shared TypeScript types and predictable error responses.
This is the point where your full stack starts behaving like one cohesive system.
1. What Makes a Good API Contract?
A strong API contract has:
Consistent response structures
Consistent error shapes
Typed DTOs
Predictable status codes
Stable field names
Clear pagination rules
Frontend-friendly formats
Your backend already has validation, filters, and interceptors — now we shape the final API interface for React.
2. Use Shared Types Across the Full Stack
Your shared types live in:
packages/types/
post.ts
user.ts
dto/
create-user.ts
update-user.ts
create-post.ts
update-post.tsExample: Shared Post type
export interface Post {
id: number;
title: string;
body: string;
createdAt: string;
authorId: number;
}Import on both sides:
Frontend:
import type { Post } from '@types/post';Backend:
import type { Post } from '@types/post';This reduces backend/frontend mismatches to virtually zero.
3. Shape REST Responses Consistently
You have two options:
Option A — Raw JSON objects
Return Prisma results directly.
Pros:
simple
minimal overhead
no extra transformations
Cons:
Prisma fields leak into API
Changing model fields might break frontend
Option B — Response DTOs (recommended)
Convert database results to API objects.
Example:
export class PostResponseDto {
id!: number;
title!: string;
body!: string;
createdAt!: string;
}Service:
return posts.map(post => ({
id: post.id,
title: post.title,
body: post.body,
createdAt: post.createdAt.toISOString(),
}));This gives you freedom to change database structures later.
4. Unify Error Shapes for React
You now have a global exception filter that outputs:
{
"statusCode": 400,
"message": ["Invalid email"],
"timestamp": "2025-11-18T10:00:00Z"
}This predictable shape lets React handle errors cleanly:
try {
await createUser(dto);
} catch (err) {
const e = err as ApiError;
if (Array.isArray(e.details?.message)) {
setErrors(e.details.message);
}
}Good contracts reduce UI spaghetti.
5. Add Pagination Contracts (Essential)
Any list the frontend loads should support pagination.
Define a shared type:
export interface Paginated<T> {
data: T[];
total: number;
limit: number;
offset: number;
}Backend controller:
@Get()
findAll(
@Query('limit', new ParseIntPipe()) limit: number,
@Query('offset', new ParseIntPipe()) offset: number,
) {
return this.service.findAllPaginated(limit, offset);
}Service:
async findAllPaginated(limit: number, offset: number) {
const [data, total] = await this.prisma.$transaction([
this.prisma.post.findMany({ take: limit, skip: offset }),
this.prisma.post.count(),
]);
return { data, total, limit, offset };
}Frontend usage:
const { data, total, limit, offset } = await getPosts({ limit: 10, offset: 0 });This contract works for every resource.
6. Standardize Timestamps (Avoid Confusion)
Return ISO strings:
No timezone ambiguity
JS can parse directly
Works well with React date libs
From Prisma:
post.createdAt.toISOString()Never return Date objects as raw JS Date instances — they serialize inconsistently.
7. Build a Dedicated API Module in React
Your frontend should not call http() directly everywhere.
Instead, use an API module:
src/api/
users.ts
posts.ts
comments.ts
auth.tsExample:
export async function getPosts() {
return http<Paginated<Post>>('/api/posts?limit=10&offset=0');
}This keeps your UI clean and focused.
8. Integrate the Backend Environment URL
React cannot rely on relative paths in production.
Use:
VITE_API_URL=https://api.example.comThen:
const base = import.meta.env.VITE_API_URL;
export function getPosts() {
return http(`${base}/posts`);
}This makes deployment flexible.
9. Add CORS Configuration in NestJS
In main.ts:
app.enableCors({
origin: ['http://localhost:5173'],
credentials: true,
});This ensures your React app can talk to the backend.
10. Example: End-to-End Flow (React → REST → DB)
Front-end:
const user = await createUser({ name, email });HTTP module:
return http<User>(`${base}/users`, {
method: 'POST',
body: JSON.stringify(dto),
});NestJS Controller:
@Post()
create(@Body() dto: CreateUserDto) {
return this.service.create(dto);
}Service:
return this.prisma.user.create({ data: dto });PostgreSQL:
Stores row → Prisma returns it → Controller returns JSON → React receives typed User.
All predictable. All typed.
11. Summary
You now have:
Shared types for full-stack correctness
Predictable API response formats
Unified error shapes
Pagination contracts
Timestamps that behave consistently
CORS-enabled communication
Clean React integration patterns
In Part 3.6, you’ll strengthen this backend foundation with environment management, Docker-based local DB, Prisma migrations, and workflow scripts.
Related
Part 2.5 — Consuming REST APIs with Shared Types & Error Handling Strategies
Real-world frontends need safety: typed API responses, predictable errors, and consistent client logic. This post teaches you the patterns professionals use to integrate React with REST cleanly.
Part 2.4 — UX State: Loading, Error, and Empty States in React
A React app becomes truly usable when it handles all states gracefully: loading, error, empty, and success. In this post, you’ll learn the UX patterns professionals rely on.
Part 2.3 — Handling Forms, Validation & Submission to REST Endpoints
Forms connect users to data. In this post, you’ll learn how to validate input, manage UX states, and submit cleanly typed data to real REST endpoints.
Comments