If you're building fullstack with JavaScript (React on the front, Node.js on the back, TypeScript across both) you've hit this wall before. You define an interface on the backend. You define the same interface on the frontend. They drift apart. Bugs happen. Nobody notices until production.
This post walks through a pipeline where you define your data shape once as a Zod schema on the backend, and every other layer (API docs, frontend types, React Query hooks) is generated from it automatically using Orval. Change the schema, regenerate, and TypeScript tells you exactly what broke. Across the entire stack.
No shared packages to publish. No Swagger YAML to maintain by hand. No "hey can you update the frontend types" Slack messages.
A quick note: we're still in the process of learning and fully adopting this flow ourselves. We haven't battle-tested every edge case, and our team is still building muscle memory around the workflow. But the early results have been promising enough that I wanted to share what we've seen so far. Think of this less as "here's the perfect setup" and more as "here's a path worth exploring if you're dealing with the same pain."
The Flow
Here's how a single type definition moves through each layer. Every arrow is automated.
Backend owns the data shape:
SQL Query → Service Layer → Zod Schema → OpenAPI Spec (JSON)
Frontend consumes the contract:
OpenAPI Spec → Orval codegen → TypeScript interfaces + React Query hooks + typed fetch calls → React Components
The OpenAPI spec is the handshake between the two sides. Generated, not handwritten.
Backend: Define Once
The backend team owns three things: talking to the database, defining the data shape, and producing the API contract.
The Database Query
You write SQL. You get rows with database-native column names.
// comments.query.ts
export const GET_COMMENTS_QUERY = `
SELECT ID, TASK_ID, AUTHOR_ID, BODY_TEXT, CREATED_AT
FROM TASK_COMMENTS
WHERE TASK_ID = :p_task_id
`;
The Service Layer
Maps database naming to JavaScript naming. This is the only manual mapping in the entire pipeline.
// comments.service.ts
private static mapRow(row: Record<string, unknown>) {
return {
commentId: row.ID,
taskId: row.TASK_ID,
authorId: row.AUTHOR_ID,
body: row.BODY_TEXT,
createdAt: row.CREATED_AT,
};
}
BODY_TEXT becomes body. This happens in one place. If the database column changes, this is the only line you update.
The Zod Schema
This is where one definition does three jobs at once.
// comments.schema.ts
export const ZCommentSchema = z.object({
commentId: z.number().int().positive()
.meta({ description: 'Unique comment identifier' }),
taskId: z.number().int().positive(),
authorId: z.number().int().positive(),
body: z.string().max(4000)
.meta({ description: 'Comment text content' }),
createdAt: z.string().datetime(),
});
// TypeScript type derived, not duplicated
export type Comment = z.infer<typeof ZCommentSchema>;
// Response wrapper
export const ZGetCommentsResponseSchema = z.object({
status: z.literal('success'),
data: z.array(ZCommentSchema),
});
What you get from this single definition:
Runtime validation. Bad data is rejected before it leaves your controller.
TypeScript type. z.infer<> gives you the type. No separate interface needed.
API documentation. Those .meta() descriptions flow into the OpenAPI spec and eventually into the frontend dev's IDE tooltip.
The Route Definition
Your Express route wires the Zod schema into the API spec:
// swagger-specification.ts
'/tasks/{taskId}/comments': {
get: {
operationId: 'getComments',
responses: {
'200': {
content: {
'application/json': {
schema: ZGetCommentsResponseSchema,
},
},
},
},
},
},
OpenAPI Spec Generation
A build script serializes everything into an OpenAPI JSON file. You never edit that JSON. It's an artifact.
yarn generate:openapi
This JSON file is the contract. It's the only thing the frontend needs from the backend. No meetings, no Slack threads, no "what does this endpoint return?"
Frontend: Generate Everything
The frontend team owns two things: generating typed hooks from the contract, and building the UI with them.
Orval Configuration
Orval reads the backend's OpenAPI JSON and generates everything the frontend needs. It supports React Query, SWR, Angular, and plain axios/fetch out of the box.
// orval.config.ts
export default defineConfig({
api: {
input: { target: '../../services/api/openapi-specs/api.json' },
output: {
target: 'src/api/gen',
client: 'react-query',
httpClient: 'fetch',
override: {
mutator: {
path: './src/api/fetchInstance.ts',
name: 'fetchInstance',
},
},
},
},
});
The Fetch Instance
Your custom fetch wrapper. This is the only hand-written API code on the frontend.
// fetchInstance.ts
export const fetchInstance = async <T>(
url: string,
init?: RequestInit,
): Promise<T> => {
const response = await fetch(`${BASE_URL}${url}`, {
...init,
credentials: 'include',
});
const data = await response.json();
if (!response.ok) {
throw new ApiError(response.status, response.statusText, data);
}
return data as T;
};
Code Generation
Run yarn generate:specs. Orval produces a file you never touch:
// gen/api.ts — AUTO-GENERATED, DO NOT EDIT
export interface GetComments200DataItem {
/** Unique comment identifier */
commentId: number;
taskId: number;
authorId: number;
/** Comment text content */
body: string;
createdAt: string;
}
export function useGetComments(
taskId: number,
options?: ...
): UseQueryResult<GetComments200, ApiError> {
// fully wired TanStack Query hook
}
You didn't write that interface. You didn't write that hook. They exist because the backend's Zod schema exists. The descriptions from .meta() are now JSDoc comments. Hover in VS Code, there they are.
Your React Component
Just use the hook. Everything is typed.
export const CommentsSection = ({ taskId }: { taskId: number }) => {
const { data, isFetching } = useGetComments(taskId);
return (
<List>
{data?.data.map((comment) => (
<ListItem key={comment.commentId}>
<ListItemText
primary={comment.body}
secondary={`User ${comment.authorId} · ${comment.createdAt}`}
/>
</ListItem>
))}
</List>
);
};
Try typing comment. and your IDE shows exactly what's available. Try comment.bodyText and you get a red squiggly. Not a runtime bug. A compile-time error.
What Changes Feel Like
Backend adds a field: Add editedAt to the Zod schema. The OpenAPI spec updates automatically. Tell the frontend team to regenerate.
Frontend regenerates: Run yarn generate:specs. The type now has editedAt. IDE autocompletes it. No guessing.
Backend renames a field: Change body to content in the Zod schema. Frontend regenerates. TypeScript lights up every component that still says .body. Fix them. Ship.
Backend removes a field: Delete it from the schema. Frontend regenerates. TypeScript shows every line that references something that no longer exists.
The compiler becomes the sync mechanism between teams. Not discipline. Not Slack. The compiler.
Where It Breaks Down
We're still early in adopting this, and we've already found the rough edges.
DB to service mapping is still manual. If someone renames a column in the database and doesn't update the service layer, you get undefined at runtime. Zod doesn't know about your SQL. Integration tests against a real database are the only real safety net here. This is the weakest link in the chain.
The fetch layer trusts the backend. The return data as T cast assumes the backend sent what the spec promises. If it didn't, TypeScript won't catch it. You can add Zod parsing on the response path too, but most teams skip it for performance. We're still debating whether to add it for development builds only.
Stale generated code. If the backend updates a schema and the frontend doesn't regenerate, types are out of sync. We added this to CI:
yarn generate:specs
git diff --exit-code src/api/gen/
Build fails if generated code is out of date. This one we've already implemented, and it's caught things.
Team habits take time. The tooling is the easy part. Getting everyone to trust the generated code, stop writing manual interfaces, and remember to regenerate. That's the real adoption curve. Some team members still instinctively create frontend types by hand. We have to remind ourselves to delete them and use the generated ones.
What You Need
| Tool | Purpose |
|---|---|
| Zod + zod-openapi | Define schemas, convert to OpenAPI |
| Orval | Read OpenAPI, generate React Query hooks |
| TanStack Query | Data fetching and caching |
| TypeScript strict mode | Makes the whole chain enforceable |
If you're already using Zod and TanStack Query, you're halfway there. The gap is just adding zod-openapi and Orval. Maybe half a day of setup.
The Point
After enough years of fullstack JavaScript, the pattern is always the same: the bugs that hurt don't come from complex logic. They come from two sides of the stack disagreeing about the shape of data.
This pipeline draws a clean line. Backend owns the shape. Frontend consumes the shape. An OpenAPI spec is the handshake between them. Generated, not handwritten, never out of date.
We're not going to pretend we've got it all figured out. We're still learning the workflow, still smoothing out the edges, still catching ourselves falling back to old habits. But the direction feels right. The few times the compiler has caught a type mismatch that would've been a runtime bug, those moments make the investment feel worth it.
If you're dealing with the same frontend-backend sync headaches, this is worth a spike. Set it up on one endpoint. See how it feels. That's what we did, and we haven't looked back.
Top comments (0)