Claude Code for Zodios: Type-Safe HTTP Client with Zod Validation — Claude Skills 360 Blog
Blog / Backend / Claude Code for Zodios: Type-Safe HTTP Client with Zod Validation
Backend

Claude Code for Zodios: Type-Safe HTTP Client with Zod Validation

Published: May 27, 2027
Read time: 6 min read
By: Claude Skills 360

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.

Keep Reading

Backend

Claude Code for Bun: Fast JavaScript Runtime and Toolkit

Build with Bun and Claude Code — Bun.serve for HTTP servers, Bun.file for fast file I/O, Bun.$ for shell commands, Bun.sql for SQLite and PostgreSQL, Bun.build for bundling, bun:test for testing, Bun.hash for hashing, bun.lock for deterministic installs, bun run for package.json scripts, hot reloading with --hot, bun init for project scaffolding, and compatibility with Node.js modules.

6 min read Jun 13, 2027
Backend

Claude Code for Express.js Advanced: Patterns for Production APIs

Advanced Express.js patterns with Claude Code — typed request handlers with RequestHandler generics, async error handling middleware, Zod validation middleware factory, rate limiting with express-rate-limit and Redis store, helmet security middleware, compression, dependency injection with tsyringe, file upload with multer and S3, pagination utilities, JWT middleware, and structured logging with pino.

6 min read Jun 8, 2027
Backend

Claude Code for KeystoneJS: Node.js CMS and App Framework

Build full-stack apps with KeystoneJS and Claude Code — config with lists, fields.text and fields.relationship for schema definition, access control with isAuthenticated and isAdmin functions, hooks with beforeOperation and afterOperation, GraphQL API auto-generation from schema, AdminUI for content management, session with statelessSessions, Prisma adapter for database, file storage with images and files fields, and custom REST endpoints.

6 min read Jun 7, 2027

Put these ideas into practice

Claude Skills 360 gives you production-ready skills for everything in this article — and 2,350+ more. Start free or go all-in.

Back to Blog

Get 360 skills free