![]() |
VOOZH | about |
If youโve been a backend or full-stack developer for any length of time, you know the ritual. A new feature requires a new API endpoint, and the boilerplate ceremony begins: define the route, write the controller, validate input, handle errors, and update the docs.
๐ ImageThis process isnโt just tedious โ itโs fragile. Every extra definition or cast is a chance for a silent bug: mismatched types, stale documentation, or forgotten validation. Developers have accepted this as the cost of reliability.
But in 2025, itโs time to challenge that assumption. Building APIs manually is an anti-pattern. The modern ecosystem offers something better โ a schema-driven paradigm that replaces repetitive setup with declarative contracts.
This article deconstructs the old way, introduces the schema-driven model, and shows why writing REST APIs from scratch no longer makes sense.
The Replay is a weekly newsletter for dev and engineering leaders.
Delivered once a week, it's your curated guide to the most important conversations around frontend dev, emerging AI tools, and the state of modern software.
Letโs illustrate the problem by building a simple POST /users endpoint the โclassicโ way, using Express and yup.
import * as yup from 'yup';
// Definition 1: TypeScript interface
interface CreateUserRequest {
username: string;
email: string;
age: number;
}
// Definition 2: Validation schema
const createUserSchema = yup.object({
username: yup.string().min(3).required(),
email: yup.string().email().required(),
age: yup.number().positive().integer().required(),
});
Immediately, weโve defined the same structure twice โ violating DRY and creating sync issues.
Now, the endpoint itself:
import express, { Request, Response, NextFunction } from 'express';
import * as yup from 'yup';
const app = express();
app.use(express.json());
const validate = (schema: yup.AnyObjectSchema) =>
async (req: Request, res: Response, next: NextFunction) => {
try {
await schema.validate(req.body);
next();
} catch (err) {
res.status(400).json({ type: 'validation_error', message: err.message });
}
};
app.post('/users', validate(createUserSchema), (req, res) => {
const userData = req.body as CreateUserRequest;
try {
const newUser = { id: Date.now(), ...userData };
res.status(201).json(newUser);
} catch {
res.status(500).json({ message: 'Internal server error' });
}
});
Weโve repeated the same ceremony: duplicate schemas, manual validation middleware, explicit type casting, and try/catch clutter.
To make things worse, weโd still need to manually update our OpenAPI docs โ a third source of truth bound to drift.
The alternative is a declarative model: define your contract once and let your framework handle routing, validation, and documentation.
Letโs rebuild the same endpoint using tRPC with Zod as our single source of truth.
import { initTRPC } from '@trpc/server';
import { z } from 'zod';
const t = initTRPC.create();
const createUserSchema = z.object({
username: z.string().min(3),
email: z.string().email(),
age: z.number().positive().int(),
});
export const appRouter = t.router({
createUser: t.procedure
.input(createUserSchema)
.mutation(({ input }) => {
const newUser = { id: Date.now(), ...input };
return newUser;
}),
});
export type AppRouter = typeof appRouter;
Hereโs what changed:
try/catch. Errors are handled gracefully by the framework.The result: faster iteration, fewer bugs, and self-documenting code.
This shift isnโt limited to tRPC โ itโs a broader industry trend. Hereโs how three other frameworks implement similar principles.
import { Hono } from 'hono';
import { z } from 'zod';
import { zValidator } from '@hono/zod-validator';
const app = new Hono();
const createUserSchema = z.object({
username: z.string().min(3),
email: z.string().email(),
age: z.number().positive().int(),
});
app.post('/users', zValidator('json', createUserSchema), (c) => {
const userData = c.req.valid('json');
const newUser = { id: Date.now(), ...userData };
return c.json(newUser, 201);
});
Hono modernizes Express-style syntax with built-in validation middleware โ minimal setup, full type safety.
import Fastify from 'fastify';
import { z } from 'zod';
import { zodToJsonSchema } from 'zod-to-json-schema';
const fastify = Fastify();
const createUserSchema = z.object({
username: z.string().min(3),
email: z.string().email(),
age: z.number().positive().int(),
});
type CreateUserRequest = z.infer;
fastify.post<{ Body: CreateUserRequest }>('/users', {
schema: { body: zodToJsonSchema(createUserSchema) },
}, async (request, reply) => {
const newUser = { id: Date.now(), ...request.body };
reply.code(201).send(newUser);
});
Fastify uses schemas for both validation and performance optimization, turning type safety into runtime efficiency.
import { Controller, Post, Body } from '@nestjs/common';
import { IsString, IsEmail, IsInt, Min, MinLength } from 'class-validator';
export class CreateUserDto {
@IsString()
@MinLength(3)
username: string;
@IsEmail()
email: string;
@IsInt()
@Min(1)
age: number;
}
@Controller('users')
export class UsersController {
@Post()
create(@Body() userData: CreateUserDto) {
return { id: Date.now(), ...userData };
}
}
NestJS integrates validation and typing through class decorators โ no manual wiring needed.
The schema-driven paradigm offers measurable improvements across the board:
| Aspect | Classic REST (Express + yup) | Schema-Driven (tRPC + Zod) |
|---|---|---|
| Development velocity | Slow and verbose: multiple schemas, middleware, and manual error handling. | Rapid and concise: one schema defines the entire contract; plumbing handled by framework. |
| Safety and reliability | Brittle: manual type casting and sync issues between layers. | End-to-end typesafe: schema shared across server and client with compile-time validation. |
| Documentation | Manual and stale: separate OpenAPI spec that drifts over time. | Automatic and current: tools like trpc-openapi generate live documentation from code. |
Building APIs manually is a relic of the past. The schema-driven approach replaces repetitive glue code with declarative contracts, letting frameworks handle the boilerplate.
Itโs not about writing less code โ itโs about writing better code. A single schema becomes your validation layer, type system, and documentation. Your APIs are faster to build, safer to evolve, and easier to maintain.
The message is simple: stop writing REST APIs from scratch. The frameworks of 2025 already know how to do it for you.
LogRocket lets you replay user sessions, eliminating guesswork by showing exactly what users experienced. It captures console logs, errors, network requests, and pixel-perfect DOM recordings โ compatible with all frameworks, and with plugins to log additional context from Redux, Vuex, and @ngrx/store.
With Galileo AI, you can instantly identify and explain user struggles with automated monitoring of your entire product experience.
Modernize how you understand your web and mobile apps โ start monitoring for free.
Build dynamic LLM routing in Next.js with OpenRouter, TanStack AI, task classification, model fallbacks, and cost-aware routing.
TSRX adds first-class control flow, conditional hooks, and scoped styles to React via a TypeScript compiler extension โ no new framework required.
Learn how to build a full React Native auth system using Better Auth and Expo โ with email/password login, Google OAuth, session persistence, and protected routes.
Compare the top AI development tools and models of June 2026. View updated rankings, feature breakdowns, and find the best fit for you.
Hey there, want to help make our blog better?
Join LogRocketโs Content Advisory Board. Youโll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up now