This project demonstrates a clean architecture where Zod schemas are the single source of truth for both runtime validation and API documentation. There is zero duplication between validation logic and OpenAPI specifications.
Shared Package (Zod Schemas with OpenAPI metadata)
↓
┌───────┴───────┐
↓ ↓
Backend Frontend
(validation) (types)
↓
OpenAPI Spec
(documentation)
In the shared/ package, we define schemas once with both validation rules and OpenAPI metadata:
// shared/src/schemas/user.schema.ts
import { z } from 'zod';
import { extendZodWithOpenApi } from '@asteasolutions/zod-to-openapi';
extendZodWithOpenApi(z);
export const UserSchema = z.object({
id: z.string().uuid().openapi({
description: 'Unique user identifier',
example: '123e4567-e89b-12d3-a456-426614174000'
}),
name: z.string().min(1).openapi({
description: 'User full name',
example: 'John Doe'
}),
email: z.string().email().openapi({
description: 'User email address',
example: 'john.doe@example.com'
}),
createdAt: z.string().datetime().openapi({
description: 'ISO 8601 timestamp',
example: '2024-01-15T10:30:00Z'
})
}).openapi('User', { description: 'A user in the system' });// src/routes/api/users/+server.ts
import { CreateUserSchema } from '@zod-schema-example/shared';
export const POST: RequestHandler = async ({ request }) => {
const body = await request.json();
// Validates using the exact same schema
const validatedData = CreateUserSchema.parse(body);
// ...
};// src/routes/+page.svelte
import type { User, CreateUser } from '@zod-schema-example/shared';
let users = $state<User[]>([]);
let formData = $state<CreateUser>({ name: '', email: '' });// scripts/generate-openapi.ts
import { UserSchema, CreateUserSchema, UpdateUserSchema } from '@zod-schema-example/shared';
// Just register them - no duplication!
registry.register('User', UserSchema);
registry.register('CreateUser', CreateUserSchema);
registry.register('UpdateUser', UpdateUserSchema);- Validation rules defined once in Zod schemas
- OpenAPI metadata co-located with validation
- Types automatically inferred from schemas
- No separate type definitions
- No manual OpenAPI schema writing
- No drift between validation and documentation
- TypeScript catches type errors
- Changes to schemas propagate automatically
- Refactoring is safe and easy
- All API inputs are validated
- Schema validation catches bad data
- Type-safe at both compile and runtime
To add a new entity (e.g., Post):
Step 1: Define the schema in shared/src/schemas/post.schema.ts:
import { z } from 'zod';
import { extendZodWithOpenApi } from '@asteasolutions/zod-to-openapi';
extendZodWithOpenApi(z);
export const PostSchema = z.object({
id: z.string().uuid().openapi({ description: 'Post ID' }),
title: z.string().min(1).openapi({ description: 'Post title' }),
content: z.string().openapi({ description: 'Post content' }),
authorId: z.string().uuid().openapi({ description: 'Author ID' })
}).openapi('Post');
export type Post = z.infer<typeof PostSchema>;Step 2: Export it:
// shared/src/schemas/index.ts
export * from './user.schema';
export * from './post.schema';Step 3: Build the shared package:
npm run build:sharedStep 4: Use it everywhere:
// Backend
import { PostSchema } from '@zod-schema-example/shared';
const post = PostSchema.parse(data);
// Frontend
import type { Post } from '@zod-schema-example/shared';
const posts: Post[] = [];
// OpenAPI
registry.register('Post', PostSchema);1. Define types in TypeScript
2. Manually write validation logic
3. Separately define OpenAPI schemas
4. Keep all three in sync (hope for the best!)
1. Define Zod schema with OpenAPI metadata
2. Done! Everything else is derived automatically
shared/
├── src/
│ ├── schemas/ # Zod schemas with OpenAPI metadata
│ │ ├── user.schema.ts
│ │ └── index.ts
│ └── index.ts
├── dist/ # Compiled output
│ ├── *.js # JavaScript for runtime
│ ├── *.d.ts # TypeScript declarations
│ └── *.map # Source maps
├── package.json
└── tsconfig.json
- shared package:
zod+@asteasolutions/zod-to-openapi - main app: imports from
@zod-schema-example/shared
npm run build:shared- Compiles shared package (TypeScript → JavaScript + .d.ts)npm run generate:openapi- Generates OpenAPI spec from schemasnpm run build- Builds the SvelteKit app
- Zod provides runtime validation - catches bad data at runtime
- TypeScript infers types from Zod - compile-time safety
- OpenAPI metadata is additive - doesn't change validation behavior
- Package compilation preserves metadata - OpenAPI info survives the build process
- Single import point - all consumers get the same schemas
- The shared package depends on
@asteasolutions/zod-to-openapi, making it slightly heavier - OpenAPI metadata is verbose but explicit
- Schema changes require rebuilding the shared package
This architecture achieves the goal of a true single source of truth where:
- Validation logic lives in one place (Zod schemas)
- Documentation lives in the same place (OpenAPI metadata)
- Types are derived automatically (TypeScript inference)
- The validation used at runtime is exactly what's documented in OpenAPI
No duplication. No drift. One source of truth.