Skip to content

Part 3.5 — Shaping API Contracts for the Frontend (React Integration)

Site Console Site Console
4 min read Updated Jan 20, 2026 Web Development 0 comments

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.ts

Example: 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.ts

Example:

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.com

Then:

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

Leave a comment

Sign in to leave a comment.

Comments