A type-safe REST API is one where your frontend and backend agree on the shape of every request and response, and the compiler tells you the moment they stop agreeing. In my full-stack work with Next.js and Node.js, the single setup change that has saved me the most debugging time is sharing Zod schemas between both sides of the stack. In this post I'll walk through exactly how I structure it, the code I actually use, and the mistakes I've made along the way.

This isn't a tRPC or GraphQL pitch. Plenty of clients want plain REST because they have mobile apps, third-party integrations or other teams consuming the same endpoints. You can keep REST and still get most of the type safety people move to tRPC for.

Why a Type-Safe REST API Matters in Client Work

I've been writing software for over eight years, starting in Android and Kotlin before moving into React, Next.js, Node.js and Laravel. Since I started taking Upwork seriously in September 2024, most of my projects have been full-stack builds where I own both ends. My very first Upwork project was a MERN stack platform for a Jiu Jitsu business, and that kind of project is a perfect example of where contracts drift: one person, moving fast, renaming fields on the backend and forgetting a component three folders away still reads the old name.

The classic failure looks like this:

  • The backend renames startTime to startsAt.
  • The frontend still reads booking.startTime, gets undefined, and renders 'Invalid Date'.
  • Nobody notices until the client opens the staging link on a call.

When you freelance, that last step matters more than the bug itself. US clients judge you on whether things just work when they click around. A type-safe REST API turns that rename into a red squiggly line in your editor instead of an awkward screen share.

The core idea

Define each request and response shape once, as a Zod schema. Use it to validate at runtime on the server, infer TypeScript types for the client, and optionally parse responses on the client too. One source of truth, three jobs.

Setting Up a Shared Contracts Package

I keep the schemas in a small workspace package, usually called contracts. Both the Node.js API and the Next.js app import from it. With npm, pnpm or yarn workspaces, the layout is simple:

  • apps/api for the Express or Fastify backend
  • apps/web for the Next.js frontend
  • packages/contracts for the shared Zod schemas and inferred types

If the client already has separate repositories, I publish the contracts package privately or, for smaller projects, copy it with a script during CI. A monorepo is nicer, but it isn't a requirement.

Here's a realistic contracts file for a class booking feature:

// packages/contracts/src/booking.ts
import { z } from 'zod';

export const CreateBookingSchema = z.object({
  classId: z.string().uuid(),
  memberId: z.string().uuid(),
  startsAt: z.coerce.date(),
  notes: z.string().trim().max(500).optional(),
});

export const BookingSchema = CreateBookingSchema.extend({
  id: z.string().uuid(),
  status: z.enum(['confirmed', 'waitlisted', 'cancelled']),
  createdAt: z.coerce.date(),
});

export const BookingListSchema = z.object({
  items: z.array(BookingSchema),
  nextCursor: z.string().nullable(),
});

export type CreateBookingInput = z.infer<typeof CreateBookingSchema>;
export type Booking = z.infer<typeof BookingSchema>;
export type BookingList = z.infer<typeof BookingListSchema>;

A few deliberate choices here:

  • z.coerce.date() matters because JSON has no date type. The server sends an ISO string, and coercion turns it back into a Date on the client without manual parsing.
  • Enums for status mean the frontend gets an exhaustive union. If I add 'no_show' later, every switch that doesn't handle it can be flagged.
  • Response schemas extend request schemas, so a field added to creation automatically shows up in the response type.

Validating Requests in the Node.js Backend

On the Node.js backend, the schema does runtime work. TypeScript types disappear at compile time, so they can't protect you from a malformed request body. Zod can. I use a tiny middleware for this:

// apps/api/src/middleware/validate.ts
import type { Request, Response, NextFunction } from 'express';
import type { ZodTypeAny } from 'zod';

export const validateBody =
  (schema: ZodTypeAny) =>
  (req: Request, res: Response, next: NextFunction) => {
    const result = schema.safeParse(req.body);

    if (!result.success) {
      return res.status(422).json({
        error: 'VALIDATION_FAILED',
        fields: result.error.flatten().fieldErrors,
      });
    }

    req.body = result.data; // trimmed, coerced, unknown keys stripped
    next();
  };

And the route itself:

// apps/api/src/routes/bookings.ts
import { Router } from 'express';
import {
  CreateBookingSchema,
  BookingSchema,
  type CreateBookingInput,
} from '@acme/contracts';
import { validateBody } from '../middleware/validate';
import { bookingService } from '../services/booking';

export const router = Router();

router.post('/bookings', validateBody(CreateBookingSchema), async (req, res, next) => {
  try {
    const input: CreateBookingInput = req.body;
    const record = await bookingService.create(input);

    // Parse the outgoing response too: strips DB-only fields
    res.status(201).json(BookingSchema.parse(record));
  } catch (err) {
    next(err);
  }
});

That last line is the part most tutorials skip. Parsing the response on the server does two useful things. First, Zod objects strip unknown keys by default, so if your ORM returns an internal field you never meant to expose, it never leaves the server. Second, if the service layer returns something that doesn't match the contract, you get a loud error in your logs instead of a silent bug in the UI.

Performance note

Response parsing has a cost on large payloads. For heavy list endpoints I sometimes only parse in development and staging, controlled by an environment flag. Measure before you decide; for typical CRUD responses the overhead is rarely the bottleneck.

I return validation failures as 422 with a consistent fields object. Because every route uses the same middleware, the frontend can map those errors straight onto form inputs with one shared helper.

Building a Typed API Client in Next.js

On the Next.js side, I wrap fetch in a small function that takes a schema and returns parsed, typed data. This works in Server Components, Route Handlers and client components alike.

// apps/web/lib/api.ts
import { z } from 'zod';
import {
  BookingSchema,
  BookingListSchema,
  type CreateBookingInput,
} from '@acme/contracts';

const API_URL = process.env.NEXT_PUBLIC_API_URL;

export class ApiError extends Error {
  constructor(public status: number, public body: unknown) {
    super(`API request failed with ${status}`);
  }
}

async function apiFetch<T>(
  path: string,
  schema: z.ZodType<T, z.ZodTypeDef, unknown>,
  init?: RequestInit
): Promise<T> {
  const res = await fetch(`${API_URL}${path}`, {
    ...init,
    headers: { 'Content-Type': 'application/json', ...init?.headers },
  });

  const json = await res.json().catch(() => null);
  if (!res.ok) throw new ApiError(res.status, json);

  return schema.parse(json);
}

export const createBooking = (input: CreateBookingInput) =>
  apiFetch('/bookings', BookingSchema, {
    method: 'POST',
    body: JSON.stringify(input),
  });

export const listBookings = (cursor?: string) =>
  apiFetch(
    `/bookings${cursor ? `?cursor=${cursor}` : ''}`,
    BookingListSchema,
    { next: { revalidate: 30 } }
  );

Now listBookings() returns a fully typed BookingList, and startsAt is a real Date, not a string pretending to be one. If the backend changes the contract, the frontend either fails to compile or fails loudly at the parse step, both of which beat rendering garbage.

Reusing schemas for forms

The same CreateBookingSchema plugs into React Hook Form through @hookform/resolvers/zod. That means client-side validation messages and server-side validation rules come from the exact same definition. When a client asks to raise the notes limit from 500 to 1,000 characters, I change one number in one file.

Type-Safe REST API Mistakes to Avoid

I didn't get this right the first time. Here are the traps I've fallen into or watched others fall into in client codebases.

1. Trusting inferred types without runtime parsing

Writing const data = (await res.json()) as Booking feels type-safe, but it's a lie you're telling the compiler. The cast proves nothing about what the server actually sent. If you're going to share types, parse at least at the boundary where data enters your system.

2. Breaking older clients with strict parsing

Coming from Android, this one is burned into me. Mobile users don't update instantly, so an old app version may be talking to your new API for months. If the client parses responses strictly and you add a new enum value, older clients can crash on data they don't recognise. For responses consumed by apps you can't redeploy instantly, I use .catch() on enums or make new fields optional, and I treat contract changes with the same care as API versioning.

3. Putting server-only logic in the contracts package

The contracts package gets bundled into the browser. Don't import database clients, secrets or heavy Node-only libraries into it. Keep it pure Zod and TypeScript. I've seen this accidentally balloon a Next.js bundle because someone added a helper that pulled in a server SDK.

4. Using refinements that need the database

Checks like 'this email is not already taken' belong in the service layer, not in a shared schema. If you put an async database refinement in the contract, the frontend can't run it, and you end up with two different versions of the schema.

5. Forgetting documentation for non-TypeScript consumers

Shared Zod schemas are brilliant for your own Next.js app, but a partner's Python service can't import them. Libraries like zod-to-openapi can generate an OpenAPI spec from the same schemas, so external consumers still get accurate docs without you maintaining a second definition by hand.

Type safety across the stack isn't about being clever with TypeScript. It's about removing an entire category of 'it worked on my machine' conversations with clients.

One honest caveat: this setup adds a bit of structure up front. On a two-day prototype I might skip it. On anything a client will maintain or extend, I set it up in the first week, because retrofitting it later is far more painful than starting with it.

Key Takeaways

  • A type-safe REST API comes from defining each contract once, as a Zod schema in a shared package used by both your Next.js frontend and Node.js backend.
  • Validate request bodies on the server and parse responses at the boundary; TypeScript types alone vanish at runtime and can't protect you.
  • Parsing responses on the server strips internal fields and catches contract drift before it reaches the UI.
  • Be careful with strict parsing for mobile or third-party clients you can't redeploy instantly, and keep the contracts package free of server-only code.
  • Generate OpenAPI docs from the same schemas so non-TypeScript consumers stay in sync without a second source of truth.