Zodios is a type-safe HTTP client with Zod validation at the boundary — makeApi([...endpoints]) defines the contract. Each endpoint has { method, path, alias, parameters: [{ name, type, schema }], response }. Zodios(baseUrl, api) creates the axios-backed client. client.getUsers() returns Promise<User[]> — fully typed from the Zod schema. Path params: path: "/users/:id" with { name: "id", type: "Path", schema: z.string() }. Query params use type: "Query". Request body uses type: "Body". makeErrors([{ status, schema }]) defines typed error responses. @zodios/react wraps the client for React Query: useUsers() hook calls useQuery. ZodiosRouter(api, handler) creates an Express router that validates requests and responses. API definitions can be shared as a package between frontend and backend. Claude Code generates Zodios API contracts, typed clients, React hooks, and Express server routers.
CLAUDE.md for Zodios
## Zodios Stack
- Version: @zodios/core >= 10.9, @zodios/react >= 10.4 (wraps tanstack-query v5)
- Define: const api = makeApi([{ method, path, alias, parameters, response, errors }])
- Client: const client = new Zodios(BASE_URL, api)
- Call: await client.getUsers() — typed from response schema
- Path: parameters: [{ name: "id", type: "Path", schema: z.string().uuid() }]
- Body: parameters: [{ name: "body", type: "Body", schema: CreateUserSchema }]
- Query: parameters: [{ name: "page", type: "Query", schema: z.number().optional() }]
- Errors: errors: makeErrors([{ status: 404, schema: NotFoundSchema }])
- React hooks: const { useGetUsers, useCreateUser } = makeHooks(client)
API Contract
// lib/api/contract.ts — shared Zodios API definition
import { makeApi, makeErrors } from "@zodios/core"
import { z } from "zod"
// ── Shared schemas ─────────────────────────────────────────────────────────
export const UserSchema = z.object({
id: z.string().uuid(),
email: z.string().email(),
name: z.string(),
role: z.enum(["user", "admin"]),
avatarUrl: z.string().url().nullable(),
createdAt: z.coerce.date(),
})
export const CreateUserSchema = z.object({
email: z.string().email(),
name: z.string().min(2).max(100),
password: z.string().min(8),
})
export const UpdateUserSchema = CreateUserSchema.partial().omit({ password: true })
export const PaginatedUsersSchema = z.object({
users: z.array(UserSchema),
total: z.number(),
page: z.number(),
pageSize: z.number(),
totalPages: z.number(),
})
export const PostSchema = z.object({
id: z.string().uuid(),
title: z.string(),
slug: z.string(),
content: z.string(),
excerpt: z.string().nullable(),
authorId: z.string().uuid(),
author: UserSchema.optional(),
publishedAt: z.coerce.date().nullable(),
tags: z.array(z.string()),
createdAt: z.coerce.date(),
})
export const CreatePostSchema = z.object({
title: z.string().min(5).max(200),
content: z.string().min(10),
excerpt: z.string().max(500).optional(),
tags: z.array(z.string()).default([]),
publishNow: z.boolean().default(false),
})
// ── Common errors ──────────────────────────────────────────────────────────
const commonErrors = makeErrors([
{ status: 400, schema: z.object({ message: z.string(), errors: z.record(z.array(z.string())).optional() }) },
{ status: 401, schema: z.object({ message: z.literal("Unauthorized") }) },
{ status: 403, schema: z.object({ message: z.literal("Forbidden") }) },
{ status: 404, schema: z.object({ message: z.string() }) },
{ status: 429, schema: z.object({ message: z.string(), retryAfter: z.number() }) },
{ status: "default", schema: z.object({ message: z.string() }) },
])
// ── API definition ─────────────────────────────────────────────────────────
export const api = makeApi([
// ── Users ────────────────────────────────────────────────────────────────
{
method: "get",
path: "/users",
alias: "getUsers",
description: "List users with pagination",
parameters: [
{ name: "page", type: "Query", schema: z.number().min(1).default(1) },
{ name: "pageSize", type: "Query", schema: z.number().min(1).max(100).default(20) },
{ name: "search", type: "Query", schema: z.string().optional() },
{ name: "role", type: "Query", schema: z.enum(["user", "admin"]).optional() },
],
response: PaginatedUsersSchema,
errors: commonErrors,
},
{
method: "get",
path: "/users/:id",
alias: "getUser",
description: "Get a single user",
parameters: [
{ name: "id", type: "Path", schema: z.string().uuid() },
],
response: UserSchema,
errors: commonErrors,
},
{
method: "post",
path: "/users",
alias: "createUser",
description: "Create a new user",
parameters: [
{ name: "body", type: "Body", schema: CreateUserSchema },
],
response: UserSchema,
status: 201,
errors: commonErrors,
},
{
method: "patch",
path: "/users/:id",
alias: "updateUser",
description: "Update a user",
parameters: [
{ name: "id", type: "Path", schema: z.string().uuid() },
{ name: "body", type: "Body", schema: UpdateUserSchema },
],
response: UserSchema,
errors: commonErrors,
},
{
method: "delete",
path: "/users/:id",
alias: "deleteUser",
description: "Delete a user",
parameters: [
{ name: "id", type: "Path", schema: z.string().uuid() },
],
response: z.void(),
status: 204,
errors: commonErrors,
},
// ── Posts ─────────────────────────────────────────────────────────────────
{
method: "get",
path: "/posts",
alias: "getPosts",
parameters: [
{ name: "page", type: "Query", schema: z.number().default(1) },
{ name: "pageSize", type: "Query", schema: z.number().default(10) },
{ name: "authorId", type: "Query", schema: z.string().uuid().optional() },
{ name: "tag", type: "Query", schema: z.string().optional() },
],
response: z.object({
posts: z.array(PostSchema),
total: z.number(),
}),
errors: commonErrors,
},
{
method: "post",
path: "/posts",
alias: "createPost",
parameters: [
{ name: "body", type: "Body", schema: CreatePostSchema },
],
response: PostSchema,
status: 201,
errors: commonErrors,
},
])
export type User = z.infer<typeof UserSchema>
export type Post = z.infer<typeof PostSchema>
export type CreateUser = z.infer<typeof CreateUserSchema>
export type CreatePost = z.infer<typeof CreatePostSchema>
Client Setup
// lib/api/client.ts — Zodios client instance
import { Zodios } from "@zodios/core"
import { pluginToken } from "@zodios/plugins"
import { api } from "./contract"
const BASE_URL = process.env.NEXT_PUBLIC_API_URL ?? "/api"
export const apiClient = new Zodios(BASE_URL, api)
// Attach auth token from localStorage
apiClient.use(
pluginToken({
getToken: async () => {
if (typeof window === "undefined") return undefined
return localStorage.getItem("access_token") ?? undefined
},
renewToken: async () => {
try {
const res = await fetch("/api/auth/refresh", { method: "POST" })
const { accessToken } = await res.json() as { accessToken: string }
localStorage.setItem("access_token", accessToken)
return accessToken
} catch {
localStorage.removeItem("access_token")
window.location.href = "/sign-in"
return undefined
}
},
}),
)
React Query Hooks
// lib/api/hooks.ts — Zodios React hooks factory
import { makeReactQueryPlugin } from "@zodios/react"
import { apiClient } from "./client"
// Creates typed useGetUsers, useCreateUser, etc. hooks
export const apiHooks = makeReactQueryPlugin(apiClient)
// Usage in components:
// import { apiHooks } from "@/lib/api/hooks"
// const { data, isLoading } = apiHooks.useGetUsers({ queries: { page: 1 } })
// const { mutateAsync } = apiHooks.useCreateUser()
// components/users/UserList.tsx — using Zodios hooks
"use client"
import { apiHooks } from "@/lib/api/hooks"
import { useState } from "react"
import type { CreateUser } from "@/lib/api/contract"
export function UserList() {
const [page, setPage] = useState(1)
const { data, isLoading, error } = apiHooks.useGetUsers({
queries: { page, pageSize: 20 },
})
const { mutateAsync: createUser, isPending } = apiHooks.useCreateUser()
const handleCreate = async (input: CreateUser) => {
await createUser({ body: input })
// React Query auto-invalidates after mutation
}
if (isLoading) return <div>Loading users...</div>
if (error) return <div>Error: {error.message}</div>
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">
Users ({data?.total ?? 0})
</h2>
</div>
<ul className="divide-y rounded-xl border">
{data?.users.map(user => (
<li key={user.id} className="flex items-center gap-3 p-4">
{user.avatarUrl && (
<img src={user.avatarUrl} alt="" className="size-9 rounded-full object-cover" />
)}
<div className="flex-1 min-w-0">
<p className="font-medium text-sm truncate">{user.name}</p>
<p className="text-xs text-muted-foreground truncate">{user.email}</p>
</div>
<span className={`text-xs px-2 py-0.5 rounded-full ${
user.role === "admin" ? "bg-purple-100 text-purple-700" : "bg-gray-100 text-gray-600"
}`}>
{user.role}
</span>
</li>
))}
</ul>
{/* Pagination */}
{data && data.totalPages > 1 && (
<div className="flex items-center justify-center gap-2">
<button
onClick={() => setPage(p => Math.max(1, p - 1))}
disabled={page === 1}
className="px-3 py-1.5 text-sm rounded-lg border disabled:opacity-50"
>
Previous
</button>
<span className="text-sm text-muted-foreground">
Page {page} of {data.totalPages}
</span>
<button
onClick={() => setPage(p => Math.min(data.totalPages, p + 1))}
disabled={page === data.totalPages}
className="px-3 py-1.5 text-sm rounded-lg border disabled:opacity-50"
>
Next
</button>
</div>
)}
</div>
)
}
Express Server with ZodiosRouter
// server/routes/users.ts — Express router with Zodios validation
import { zodiosRouter } from "@zodios/express"
import { api } from "../../lib/api/contract"
import { db } from "../db"
import { users } from "../db/schema"
import { eq, ilike, count, desc } from "drizzle-orm"
import { hashPassword } from "../lib/auth"
export const usersRouter = zodiosRouter(api, { transform: true })
// getUsers — validated query params, typed response
usersRouter.get("/users", async (req, res) => {
const { page = 1, pageSize = 20, search, role } = req.query
const offset = (page - 1) * pageSize
const where = []
if (search) where.push(ilike(users.name, `%${search}%`))
if (role) where.push(eq(users.role, role))
const [result, totalResult] = await Promise.all([
db.query.users.findMany({
where: where.length > 0 ? and(...where) : undefined,
limit: pageSize,
offset,
orderBy: desc(users.createdAt),
}),
db.select({ count: count() }).from(users),
])
const total = totalResult[0]?.count ?? 0
res.json({
users: result,
total,
page,
pageSize,
totalPages: Math.ceil(total / pageSize),
})
})
// getUser — validated path param
usersRouter.get("/users/:id", async (req, res) => {
const user = await db.query.users.findFirst({
where: eq(users.id, req.params.id),
})
if (!user) {
return res.status(404).json({ message: "User not found" })
}
res.json(user)
})
// createUser — validated body
usersRouter.post("/users", async (req, res) => {
const { email, name, password } = req.body
const existing = await db.query.users.findFirst({
where: eq(users.email, email),
})
if (existing) {
return res.status(400).json({ message: "Email already in use" })
}
const hashedPassword = await hashPassword(password)
const [user] = await db.insert(users).values({
email,
name,
hashedPassword,
}).returning()
res.status(201).json(user)
})
For the openapi-ts alternative when an OpenAPI YAML/JSON spec already exists (from Swagger docs, FastAPI, or NestJS) and code generation from the spec is preferred over writing contracts in TypeScript — openapi-ts generates clients from existing specs while Zodios lets you author contracts directly in TypeScript, see the openapi-ts guide. For the tRPC alternative when a full-stack TypeScript monorepo with end-to-end type safety, server-side procedures with middleware, and React Query integration without REST conventions is preferred — tRPC co-locates server and client contracts in a single codebase rather than a separate API definition package, see the tRPC guide. The Claude Skills 360 bundle includes Zodios skill sets covering API contracts, typed clients, and Express routing. Start with the free tier to try type-safe API client generation.