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.
This 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 …
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.
This 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.
A “classic” REST endpoint setup
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 schema-driven solution
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:
- One schema, one truth. Types are inferred automatically from Zod.
- No middleware. Validation is built in.
- No type casting. Inputs and outputs are strongly typed.
- No
try/catch
. Errors are handled gracefully by the framework.
The result: faster iteration, fewer bugs, and self-documenting code.
Frameworks embracing schema-driven APIs
This shift isn’t limited to tRPC — it’s a broader industry trend. Here’s how three other frameworks implement similar principles.
Hono: Web standards meet type safety
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.
Fastify: Schema-driven performance
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.
NestJS: Declarative via decorators
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 payoff: faster, safer, and self-documenting
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. |
Conclusion
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 understands everything users do in your web and mobile apps.
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.