Carhub is a simple demo project allowing users to create and view cars, but the interesting part is the tech stack used.
The project demonstrates end-to-end type safety and auto-generated documentation using NestJS, tRPC, Swagger and Zod. The project's goal is to have top tier DX when working with APIs - both in the front and backend.
- Auto-generated DTOs from Zod schemas
- Compile time error if the Swagger specified DTO and actual DTO are different!
- Custom decorators (
@zQuery,@zParam) for endpoint parameter validation - automatically shows up in swagger too. - Specified errors your app can throw and each will show up in the documentation.
- Common errors are automatically derived in the swagger docs:
- Each endpoint automatically gets
429and500error examples added. - If the endpoint performs any validation,
400 Valiation Erroris automatically added. - Protected endpoints gets the
401 Unauthorizedand403 Forbiddenautomatically added.
- Each endpoint automatically gets
- See code examples below!
- It's faster and more reliable with tRPC.
- Types are procedures are immediately updated in the frontend - no codegen needed.
- Jump between frontend and backend code easily.
- No more "Where is this endpoint used?" - see directly where each tRPC prodecure is used in the frontend.
- Backend errors are automatically converted to tRPC errors in middleware.
- A postgres DB and pgadmin are automatically started when starting to develop with
pnpm dev.
- Node.js 24+
- Docker installed and running
- pnpm (
npm install -g pnpm)
# 1. Clone the repository
git clone https://github.com/williamwinkler/carhub.git
cd carhub
# 2. Install dependencies
pnpm install
# 3. Setup environment files
cp apps/api/.env.example apps/api/.env.local
cp apps/web/.env.example apps/web/.env.local
# 4. Configure your environment files (recommended)
pnpm build:packages
# 5. Start the development database
docker compose up -d
# 6. Run database migrations
pnpm --filter api migrations:run
# (optional) Seed with sample data
pnpm --filter api seed
# Start both the frontend and backend
pnpm dev- 2 Users:
admin/adminandjondoe/jondoe - 10 Manufacturers: Toyota, Honda, Ford, BMW, Mercedes-Benz, etc.
- 50 Car Models: 5 models per manufacturer with slugs
- Full Relationships: Proper foreign keys and cascading
Once running, visit:
- Swagger UI: http://localhost:3001/docs
- OpenAPI Spec: http://localhost:3001/swagger.yml
// 1. Define Zod schema (single source of truth)
export const createCarSchema = z.object({
modelId: z.string().uuid(),
color: z.string().min(1).max(50),
year: z.number().int().gte(1900),
price: z.number().min(0),
});
// 2. Auto-generate DTO for Swagger
export class CreateCarDto extends createZodDto(createCarSchema) {}
// 3. Use in controller with automatic validation
@Post()
@SwaggerInfo({
status: 201,
summary: "Create a car",
successText: "Car was succesfully created",
type: CarDto, // <- Won't compile if a CarDto is not returned!
errors: [Errors.SOME_ERROR]
})
async create(@Body() dto: CreateCarDto) {
// dto is validated and typed automatically!
}@Get(":carId")
async findCars(
@zParam("carId", z.uuid()) carId: UUID,
@zQuery("color", z.string().optional()) color?: string,
@zQuery("minPrice", z.number().min(0).optional()) minPrice?: number,
@zQuery("skip", z.number().int().gte(0).default(0)) skip = 0,
) {
// All parameters validated automatically
// Swagger docs generated automatically
}// Backend tRPC procedure
getById: procedure
.input(z.object({ id: z.uuid() })) // id as UUID required
.query(async ({ input: { id } }) => {
const car = await this.carsService.findById(id);
if (!car) {
// API specific errors are automatically converted to tRPC errors
throw new AppError(Errors.CAR_NOT_FOUND)
}
return this.carsAdapter.getDto(car);
});
// Frontend usage (fully typed!)
const car = await trpc.cars.getById.query({
id: "<UUID>", // β
Typed
});
// Response is automatically typed as well!MIT - Feel free to use this as inspiration for your own projects!