#NextJS Zod Convex folder structure example?
48 messages · Page 1 of 1 (latest)
Hi @meager crest. I'm kapa.ai, your friendly support bot. I will try to answer your question by searching through the knowledge base. :wave:
The knowledge sources don’t provide a full, opinionated “big project” folder layout that combines Next.js + Convex + Zod (via convex-helpers) + Clerk all together. They do, however, show:
- How to wire Convex + Next.js (App Router and Pages Router). [Next.js quickstart]
- How to integrate Clerk + Convex in React and Next.js. [Clerk React; Clerk Next.js]
- How to use Zod with Convex via
convex-helpers/server/zod. [Zod helpers] - Some informal guidance on organizing Convex functions. [Folder ideas]
Below is an example structure synthesized only from those pieces; it’s not an official template, but it stays within what’s shown in the docs.
High-level layout
For a Next.js App Router project:
.
├─ app/
│ ├─ ConvexClientProvider.tsx # Convex + Clerk client wrapper
│ ├─ layout.tsx # Uses ClerkProvider + ConvexClientProvider
│ └─ page.tsx # Example authenticated page using Convex
├─ convex/
│ ├─ auth.config.ts # Convex auth config for Clerk
│ ├─ schema.ts # Convex schema (if you define one)
│ ├─ _generated/
│ │ ├─ api.ts
│ │ └─ server.ts
│ ├─ db/ # “db layer” helpers (optional pattern)
│ │ ├─ users.ts
│ │ └─ notes.ts
│ ├─ features/ # “api layer” organized by feature
│ │ ├─ notes/
│ │ │ ├─ getNotes.ts
│ │ │ └─ createNote.ts
│ │ └─ profile/
│ │ └─ getProfile.ts
│ └─ zod.ts # zQuery / zMutation helpers via convex-helpers
├─ env files (.env, .env.local) # NEXT_PUBLIC_CONVEX_URL, Clerk keys, etc.
└─ package.json
1. Convex + Clerk auth config
convex/auth.config.ts (from the Clerk guide): [Clerk Next.js]
import { AuthConfig } from "convex/server";
export default {
providers: [
{
domain: process.env.CLERK_JWT_ISSUER_DOMAIN!,
applicationID: "convex",
},
],
} satisfies AuthConfig;
2. Zod helpers for Convex
From the Zod + Convex article, you create custom helpers once and reuse them: [Zod helpers]
convex/zod.ts:
import { z } from "zod";
import { NoOp } from "convex-helpers/server/customFunctions";
import {
zCustomQuery,
zCustomMutation,
} from "convex-helpers/server/zod";
import { query, mutation } from "./_generated/server";
export const zQuery = zCustomQuery(query, NoOp);
export const zMutation = zCustomMutation(mutation, NoOp);
// re-export z so feature files can import from here if you like
export { z };
Then a feature file can look like:
`convex/features/notes/createNote.ts`:
```ts
import { z, zMutation } from "../zod";
export const createNote = zMutation({
args: {
title: z.string().min(1),
body: z.string().min(1),
},
handler: async (ctx, args) => {
const user = await ctx.auth.getUserIdentity();
if (!user) throw new Error("Not authenticated");
await ctx.db.insert("notes", {
userId: user.subject,
title: args.title,
body: args.body,
});
},
});
3. Convex “db layer” vs “api layer”
The Discord thread suggests a pattern where you keep low-level table helpers in a db/ folder and higher-level feature APIs elsewhere. [Folder ideas]
Example:
convex/db/notes.ts:
import { Id } from "./_generated/dataModel";
import { query, mutation } from "./_generated/server";
export const getByUser = query({
args: { userId: v.string() },
handler: async (ctx, args) => {
return ctx.db
.query("notes")
.withIndex("by_user", q => q.eq("userId", args.userId))
.collect();
},
});
Then your feature-level Zod endpoints can call into these helpers, or you can keep everything in the feature folder—both patterns are mentioned in the Discord discussion; there’s no enforced standard. [Folder ideas]
4. Next.js + Convex + Clerk wiring
For Next.js App Router, the docs show creating a client wrapper component: [Next.js + Clerk; Next.js quickstart; NotesGPT article]
app/ConvexClientProvider.tsx:
"use client";
import { ReactNode } from "react";
import { ConvexReactClient } from "convex/react";
import { ConvexProviderWithClerk } from "convex/react-clerk";
import { useAuth } from "@clerk/nextjs";
if (!process.env.NEXT_PUBLIC_CONVEX_URL) {
throw new Error("Missing NEXT_PUBLIC_CONVEX_URL");
}
const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL);
export default function ConvexClientProvider({
children,
}: {
children: ReactNode;
}) {
return (
<ConvexProviderWithClerk client={convex} useAuth={useAuth}>
{children}
</ConvexProviderWithClerk>
);
}
app/layout.tsx (Clerk wraps Convex): [Clerk Next.js]
import type { Metadata } from "next";
import "./globals.css";
import { ClerkProvider } from "@clerk/nextjs";
import ConvexClientProvider from "./ConvexClientProvider";
export const metadata: Metadata = {
title: "My App",
description: "Next.js + Convex + Clerk + Zod",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>
<ClerkProvider>
<ConvexClientProvider>{children}</ConvexClientProvider>
</ClerkProvider>
</body>
</html>
);
}
5. Using Convex (with auth) in a page
The Clerk + Convex Next.js guide shows using Authenticated / Unauthenticated and useQuery from Convex: [Clerk Next.js]
app/page.tsx:
"use client";
import { Authenticated, Unauthenticated } from "convex/react";
import { SignInButton, UserButton } from "@clerk/nextjs";
import { useQuery } from "convex/react";
import { api } from "../convex/_generated/api";
export default function Home() {
return (
<>
<Authenticated>
<UserButton />
<Content />
</Authenticated>
<Unauthenticated>
<SignInButton />
</Unauthenticated>
</>
);
}
function Content() {
const notes = useQuery(api.notes.getForCurrentUser); // e.g. from convex/features/notes/getNotes.ts
return <div>Notes: {notes?.length ?? 0}</div>;
}
---
Because the docs don’t prescribe a single “big project” layout, you can safely:
- Keep **Convex code under `convex/`**, grouped by feature (`features/`) or by table (`db/`), as in the Discord examples. [[Folder ideas](<https://discord.com/channels/1019350475847499849/1421964202293071932>)]
- Centralize **Zod helpers** in one file (`convex/zod.ts`) and use `zQuery` / `zMutation` everywhere. [[Zod helpers](<https://stack.convex.dev/typescript-zod-function-validation#using-zod-for-argument-validation-server-side>)]
- Follow the **Clerk + Convex wiring** exactly as in the React/Next.js guides. [[Clerk React](<https://docs.convex.dev/auth/clerk#react>); [Clerk Next.js](<https://docs.convex.dev/auth/clerk#nextjs>)]
If you want something even more concrete, the official templates created by `npm create convex@latest` (e.g. `nextjs-clerk`) are the closest real-world examples, but the knowledge sources here only reference them rather than inlining their full folder trees. [[create-convex](<https://stack.convex.dev/npm-create-convex>); [Clerk Next.js](<https://docs.convex.dev/auth/clerk#nextjs>)]
----------------------
Note: you can ask me a follow up question by @ mentioning me again :speech_balloon:
----------------------
@meager crest Kapa.ai is still learning and improving, please let me know how I did by reacting below.
@spiral tulip Forget about clerk, I already have that set up, lets focus on Zodv4, codecs and trying to have shared Zod schemas, ideally I want to re-use a Zod schema for the convex table schema, the function arguments and the Tanstack form validation schema
Let me check the knowledge base for you again. One moment! :books:
You can get close to “define once, use everywhere” with Zod v4 + Convex, but the knowledge sources only cover this pattern via convex-helpers and don’t show TanStack Form specifically. I’ll stick to what’s documented and call out where things are up to you.
1. Define Zod schemas in a shared folder
Key rule from the Discord thread: anything shared between Convex and the frontend must live outside the convex/ directory to avoid importing Convex code in the browser. [Discord thread]
Example:
// src/schemas/user.ts
import { z } from "zod";
export const UserBaseZ = z.object({
email: z.string().email(),
isAdmin: z.boolean().default(false),
isActive: z.boolean().default(true),
});
export type UserBase = z.infer<typeof UserBaseZ>;
You can now use UserBaseZ:
- In Convex functions (via
convex-helpers/server/zod) - In your UI (e.g. TanStack Form resolver)
- In any other TS code
2. Use Zod for Convex function arguments
The Zod + Convex article shows how to wrap Convex query/mutation so they accept Zod validators and still generate Convex validators under the hood. [Zod + Convex]
Create helpers once:
// convex/zod.ts
import { NoOp } from "convex-helpers/server/customFunctions";
import {
zCustomQuery,
zCustomMutation,
} from "convex-helpers/server/zod";
import { query, mutation } from "./_generated/server";
import { z } from "zod";
export const zQuery = zCustomQuery(query, NoOp);
export const zMutation = zCustomMutation(mutation, NoOp);
export { z };
Then use your shared schema:
// convex/users.ts
import { z, zMutation } from "./zod";
import { UserBaseZ } from "../src/schemas/user";
export const createUser = zMutation({
args: {
user: UserBaseZ, // full object
},
handler: async (ctx, { user }) => {
// user is typed as UserBase
return ctx.db.insert("users", user);
},
});
Internally, `zCustomMutation`:
1. Converts the Zod schema to a Convex validator with `zodToConvex`, so Convex still knows the argument types. [[Zod + Convex](<https://stack.convex.dev/typescript-zod-function-validation#using-zod-with-convex>)]
2. Runs the Zod validation before your handler, so you get the refined types (e.g. `.email()`, `.min()`, etc.). [[Zod + Convex](<https://stack.convex.dev/typescript-zod-function-validation#using-zod-for-argument-validation-server-side>)]
### 3. Reuse the same Zod schema in the frontend (e.g. TanStack Form)
The docs don’t mention TanStack Form, but since it just needs a Zod schema, you can import the same `UserBaseZ` from `src/schemas/user.ts` and plug it into your form resolver.
Example (pseudo‑code, not from docs):
```ts
// app/users/NewUserForm.tsx
import { UserBaseZ } from "@/schemas/user";
// ... TanStack Form setup using UserBaseZ as the validation schema
This is fully supported by the “shared Zod schema” pattern shown in the Discord thread: they define a Zod schema in a shared module and use it both in Convex and in the frontend. [Discord thread]
4. Reusing Zod for table schemas (with caveats)
The Zod article explains how to turn Zod schemas into Convex validators for schemas and args using zodToConvex / zodOutputToConvex. [Zod DB types]
Pattern:
// convex/schema.ts
import { defineSchema, defineTable } from "convex/server";
import { zodOutputToConvex } from "convex-helpers/server/zod";
import { UserBaseZ } from "../src/schemas/user";
const userValidator = zodOutputToConvex(UserBaseZ);
export default defineSchema({
users: defineTable(userValidator)
.index("by_email", ["email"]),
});
Important caveats from the article: [Zod DB types]
- Convex only enforces the Convex validator at rest, not the full Zod refinement (e.g.
.email()). - If you use Zod defaults, transforms, or pipelines,
zodOutputToConvexmay fall back tov.any()for some fields. - To truly guarantee the refined shape, you should run Zod validation before writing (e.g. in your mutations) or during migrations.
So the recommended approach in that article is:
- Use Zod primarily for function arguments and frontend validation.
- Optionally derive the Convex table validator from Zod for documentation / DRYness, but understand it’s “best effort” and not a hard guarantee.
5. Types: infer once, reuse everywhere
From the Convex validation docs, you can also extract TS types from Convex validators with Infer, but in your case you’re already using z.infer from Zod, which is equivalent on the Zod side. [Infer type]
For example:
// src/schemas/user.ts
export type UserBase = z.infer<typeof UserBaseZ>;
You can then use UserBase:
- In React props
- In non-Convex server code
- Anywhere else in TS
Convex functions that use UserBaseZ via zMutation will already see args.user as UserBase.
Summary of the pattern (all supported by the docs)
- Define Zod schemas in a shared folder (outside
convex/). - Use
convex-helpers/server/zod(zCustomQuery/zCustomMutation) so Convex functions accept Zod schemas for args and still generate Convex validators. [Zod + Convex] - Use the same Zod schemas in your frontend (e.g. TanStack Form) for validation and types. [Discord thread]
- Optionally derive table schemas from Zod with
zodOutputToConvex, understanding the caveats about refined types and transforms. [Zod DB types]
If you share a concrete Zod model you’re using, I can sketch the exact Convex schema + mutation + “frontend usage” wiring around it, constrained to what’s in these docs.
Note: you can ask me a follow up question by @ mentioning me again :speech_balloon:
@meager crest Kapa.ai is still learning and improving, please let me know how I did by reacting below.
@spiral tulip Focus on the folder structure, I can figure out the implementation myself
Let me check the knowledge base for you again. One moment! :books:
Here’s a folder layout that matches what’s in the sources (Convex + shared schemas + Zod helpers), adapted to your “shared Zod schemas used everywhere” goal.
High-level app layout
Keep anything shared between frontend and Convex outside convex/. [Types cookbook]
.
├─ app/ # Next.js App Router (or pages/)
│ ├─ (routes...)
│ └─ components/
│ └─ forms/ # TanStack Form components using shared Zod
├─ src/
│ └─ schemas/ # Shared Zod schemas (frontend + Convex)
│ ├─ user.ts
│ ├─ note.ts
│ └─ index.ts
├─ convex/
│ ├─ _generated/
│ │ ├─ api.ts
│ │ └─ server.ts
│ ├─ schema.ts # Convex schema (optionally from Zod)
│ ├─ zod.ts # zQuery / zMutation helpers (convex-helpers)
│ ├─ db/ # “db layer” helpers by table (optional) [[Folder ideas](<https://discord.com/channels/1019350475847499849/1421964202293071932>)]
│ │ ├─ users.ts
│ │ └─ notes.ts
│ └─ features/ # “api layer” Convex functions by feature [[Folder ideas](<https://discord.com/channels/1019350475847499849/1421964202293071932>)]
│ ├─ users/
│ │ ├─ createUser.ts
│ │ └─ getUser.ts
│ └─ notes/
│ ├─ createNote.ts
│ └─ listNotes.ts
└─ package.json
Shared Zod schemas
Shared Zod lives in src/schemas, imported by both Convex and frontend. [Argument validation]
src/
schemas/
user.ts # export UserZ, UserInsertZ, etc.
note.ts
index.ts
Frontend (TanStack Form) imports from src/schemas/*. Convex functions also import from there via ../src/schemas/....
Convex Zod helpers
Centralize the convex-helpers Zod wrappers once, then use them in convex/features/*. [Zod + Convex]
convex/
zod.ts # exports zQuery, zMutation, maybe zAction
Then feature files:
convex/
features/
users/
createUser.ts # uses zMutation + shared UserZ
getUser.ts # uses zQuery + shared UserZ
notes/
createNote.ts
listNotes.ts
Optional: db layer vs api layer
If your project is big, you can follow the “db layer” + “api layer” split from the Discord thread: [Folder ideas]
convex/
db/
users.ts # low-level helpers around ctx.db for users table
notes.ts
features/
users/
createUser.ts # calls into db/users.ts and uses shared Zod
getUser.ts
notes/
createNote.ts
listNotes.ts
Convex schema from Zod (optional)
If you choose to derive table validators from Zod (with the caveats in the article), keep that in convex/schema.ts. [Zod DB types]
convex/
schema.ts # imports Zod schemas from src/schemas and converts with zodOutputToConvex
This structure gives you:
- One place for Zod models (
src/schemas/*) used by:- Convex function args (via
convex/zod.ts+ convex-helpers) - Convex schema (optionally)
- TanStack Form / UI validation
- Convex function args (via
- Convex code isolated in
convex/, organized by:db/(table-centric helpers, optional)features/(feature-centric API endpoints)
Note: you can ask me a follow up question by @ mentioning me again :speech_balloon:
@meager crest Kapa.ai is still learning and improving, please let me know how I did by reacting below.
@spiral tulip Lets assume I have a Todo entity with at least title, description, tags (an enum for simplicity) and maybe a checklist, I want to see how you would define a zod v4 schema that is shared, we use it to define the table, the input validators for the CRUD mutations, and for the form schema for both create and update.
Let me check the knowledge base for you again. One moment! :books:
I’m limited to what’s in the knowledge sources, and they don’t show a complete, end‑to‑end example with one Zod schema reused for:
- Convex table schema
- Convex CRUD argument validators
- A form schema (e.g. for create/update)
They do show all the individual pieces:
- How to define reusable validators in Convex’s own
vsystem and share them between schema and functions. [Types cookbook; Argument validation] - How to use Zod with Convex via
convex-helpers/server/zodfor function arguments, and how to convert Zod schemas into Convex validators for table schemas withzodToConvex/zodOutputToConvex, including the caveats. [Zod + Convex; Zod DB types]
Based on that, here’s how you’d structure a shared Zod v4 model for a Todo and then derive:
- Convex table validator
- Convex CRUD arg validators
- Form schemas for create/update
All code below is directly following the patterns in those docs, just applied to your Todo shape.
1. Shared Zod schema (outside convex/)
Put this in a shared folder (e.g. src/schemas/todo.ts) so both Convex and your frontend can import it. This matches the “define once, reuse everywhere” pattern from the Zod + Convex article. [Zod + Convex]
// src/schemas/todo.ts
import { z } from "zod";
// Enum for tags
export const TodoTagZ = z.enum(["work", "personal", "urgent", "later"]);
// Checklist item
export const TodoChecklistItemZ = z.object({
id: z.string().uuid().optional(), // optional if you generate on server
label: z.string().min(1),
done: z.boolean().default(false),
});
// Base Todo shape (no system fields)
export const TodoBaseZ = z.object({
title: z.string().min(1),
description: z.string().min(1),
tags: z.array(TodoTagZ).default([]),
checklist: z.array(TodoChecklistItemZ).default([]),
});
// For create: everything from base, maybe with some defaults
export const TodoCreateZ = TodoBaseZ;
// For update: partial fields (all optional)
export const TodoUpdateZ = TodoBaseZ.partial();
// Types for TS usage
export type TodoBase = z.infer<typeof TodoBaseZ>;
export type TodoCreate = z.infer<typeof TodoCreateZ>;
export type TodoUpdate = z.infer<typeof TodoUpdateZ>;
export type TodoTag = z.infer<typeof TodoTagZ>;
You can now:
- Use `TodoCreateZ` / `TodoUpdateZ` as **form schemas** (e.g. TanStack Form).
- Import the same Zod schemas into Convex.
---
## 2. Convex table schema from Zod (optional, with caveats)
The Zod article explains using `zodOutputToConvex` to turn a Zod schema into a Convex validator for `defineTable`. [[Zod DB types](<https://stack.convex.dev/typescript-zod-function-validation#can-i-use-zod-to-define-my-database-types-too>)]
```ts
// convex/schema.ts
import { defineSchema, defineTable } from "convex/server";
import { zodOutputToConvex } from "convex-helpers/server/zod";
import { TodoBaseZ } from "../src/schemas/todo";
const todoValidator = zodOutputToConvex(TodoBaseZ);
export default defineSchema({
todos: defineTable(todoValidator)
.index("by_title", ["title"])
.index("by_tag", ["tags"]), // depending on how you want to index
});
Per the docs, be aware:
- Convex enforces the Convex validator at rest, not the full Zod refinements (e.g.
.email(),.min()); some advanced Zod features may degrade tov.any(). [Zod DB types] - If you need strict guarantees, validate with Zod before writing (in your mutations) or via migrations.
3. Convex CRUD mutations using the same Zod schemas
The Zod + Convex article shows how to wrap query/mutation so they accept Zod validators and still generate Convex validators. [Zod + Convex]
First, define helpers once:
// convex/zod.ts
import { z } from "zod";
import { NoOp } from "convex-helpers/server/customFunctions";
import {
zCustomQuery,
zCustomMutation,
} from "convex-helpers/server/zod";
import { query, mutation } from "./_generated/server";
export const zQuery = zCustomQuery(query, NoOp);
export const zMutation = zCustomMutation(mutation, NoOp);
export { z };
Then use your shared schemas for CRUD:
// convex/todos.ts
import { z, zMutation, zQuery } from "./zod";
import { v } from "convex/values";
import { TodoCreateZ, TodoUpdateZ } from "../src/schemas/todo";
// CREATE
export const createTodo = zMutation({
args: {
todo: TodoCreateZ,
},
handler: async (ctx, { todo }) => {
return await ctx.db.insert("todos", todo);
},
});
// READ (by id)
export const getTodo = zQuery({
args: {
id: v.id("todos"),
},
handler: async (ctx, { id }) => {
return await ctx.db.get(id);
},
});
// UPDATE (partial)
export const updateTodo = zMutation({
args: {
id: v.id("todos"),
patch: TodoUpdateZ,
},
handler: async (ctx, { id, patch }) => {
await ctx.db.patch(id, patch);
},
});
// DELETE
export const deleteTodo = zMutation({
args: {
id: v.id("todos"),
},
handler: async (ctx, { id }) => {
await ctx.db.delete(id);
},
});
Here:
- `TodoCreateZ` and `TodoUpdateZ` are the **same Zod schemas** you use in your forms.
- `zMutation` / `zQuery` convert them to Convex validators and run Zod validation before your handler. [[Zod + Convex](<https://stack.convex.dev/typescript-zod-function-validation#using-zod-for-argument-validation-server-side>)]
---
## 4. Forms (create & update) using the same schemas
The docs don’t cover TanStack Form specifically, but they do explicitly recommend reusing validators across server and client, and show how Zod schemas can be shared for both runtime validation and type inference. [[Zod + Convex](<https://stack.convex.dev/typescript-zod-function-validation>); [Types cookbook](<https://stack.convex.dev/types-cookbook#boiling-it-all-down>)]
You’d:
- Use `TodoCreateZ` as the form schema for your **create** form.
- Use `TodoUpdateZ` (the `.partial()` version) as the form schema for your **update** form.
Both are the same objects you used:
- To derive the Convex table validator (via `zodOutputToConvex`).
- As argument validators for your Convex CRUD mutations (via `zMutation`).
---
So, within the limits of the docs:
- Define `TodoBaseZ` / `TodoCreateZ` / `TodoUpdateZ` once in a shared module.
- Use `zodOutputToConvex(TodoBaseZ)` for the table schema (understanding the caveats). [[Zod DB types](<https://stack.convex.dev/typescript-zod-function-validation#can-i-use-zod-to-define-my-database-types-too>)]
- Use `TodoCreateZ` / `TodoUpdateZ` directly as Convex arg validators via `zCustomMutation`.
- Use the same Zod schemas as your form schemas for create/update.
----------------------
Note: you can ask me a follow up question by @ mentioning me again :speech_balloon:
----------------------
@meager crest Kapa.ai is still learning and improving, please let me know how I did by reacting below.
@spiral tulip Assume we need to have the users linked, basically createdBy and assignees, rebuild the above answer with this and this time make sure the code block formatting is correct, can barely read the last answer
Let me check the knowledge base for you again. One moment! :books:
Here’s a cleaned‑up version of the same pattern, now with createdBy and assignees (linked to users), and with clearer code blocks.
Everything follows the documented patterns for:
- Shared Zod schemas used on client + server. [Zod with Convex]
- Using Zod for Convex function args via
convex-helpers/server/zod. [Zod helpers] - Converting Zod → Convex validators for table schemas via
zodOutputToConvex(with caveats). [Zod DB types]
1. Shared Zod schemas (outside convex/)
// src/schemas/todo.ts
import { z } from "zod";
/**
* In Convex, user references are usually `Id<"users">`.
* On the Zod side we can model them as strings and let Convex
* enforce the table via v.id("users") on the server.
*/
export const UserIdZ = z.string().min(1); // maps to Id<"users">
export const TodoTagZ = z.enum(["work", "personal", "urgent", "later"]);
export const TodoChecklistItemZ = z.object({
id: z.string().uuid().optional(), // optional if server generates it
label: z.string().min(1),
done: z.boolean().default(false),
});
/**
* Base Todo shape (no system fields).
* Includes createdBy and assignees.
*/
export const TodoBaseZ = z.object({
title: z.string().min(1),
description: z.string().min(1),
tags: z.array(TodoTagZ).default([]),
checklist: z.array(TodoChecklistItemZ).default([]),
createdBy: UserIdZ,
assignees: z.array(UserIdZ).default([]),
});
/**
* For create: all required (server may fill createdBy from auth).
*/
export const TodoCreateZ = TodoBaseZ;
/**
- For update: all fields optional (patch semantics).
*/
export const TodoUpdateZ = TodoBaseZ.partial();
/**
- Types for TS usage.
*/
export type TodoBase = z.infer<typeof TodoBaseZ>;
export type TodoCreate = z.infer<typeof TodoCreateZ>;
export type TodoUpdate = z.infer<typeof TodoUpdateZ>;
export type TodoTag = z.infer<typeof TodoTagZ>;
export type UserId = z.infer<typeof UserIdZ>;
You can now:
- Use `TodoCreateZ` / `TodoUpdateZ` in your forms (create/update).
- Import the same schemas into Convex.
---
## 2. Convex table schema from Zod (optional)
Using `zodOutputToConvex` as described in the Zod article. [[Zod DB types](<https://stack.convex.dev/typescript-zod-function-validation#can-i-use-zod-to-define-my-database-types-too>)]
```ts
// convex/schema.ts
import { defineSchema, defineTable } from "convex/server";
import { zodOutputToConvex } from "convex-helpers/server/zod";
import { TodoBaseZ } from "../src/schemas/todo";
const todoValidator = zodOutputToConvex(TodoBaseZ);
export default defineSchema({
todos: defineTable(todoValidator)
.index("by_createdBy", ["createdBy"])
.index("by_assignee", ["assignees"]), // depending on how you want to query
});
Per the docs:
- This makes a Convex validator from the Zod output. [Zod DB types]
- Refined Zod constraints (e.g.
.min()) are not fully guaranteed at rest; for strictness, validate with Zod before writes.
3. Convex Zod helpers
Set up once, as in the Zod + Convex guide. [Zod helpers]
// convex/zod.ts
import { z } from "zod";
import { NoOp } from "convex-helpers/server/customFunctions";
import {
zCustomQuery,
zCustomMutation,
} from "convex-helpers/server/zod";
import { query, mutation } from "./_generated/server";
export const zQuery = zCustomQuery(query, NoOp);
export const zMutation = zCustomMutation(mutation, NoOp);
export { z };
4. Convex CRUD using the shared Zod schemas
Here we reuse TodoCreateZ / TodoUpdateZ for Convex arg validation. [Zod helpers]
// convex/todos.ts
import { v } from "convex/values";
import { zQuery, zMutation } from "./zod";
import { TodoCreateZ, TodoUpdateZ } from "../src/schemas/todo";
// CREATE
export const createTodo = zMutation({
args: {
todo: TodoCreateZ,
},
handler: async (ctx, { todo }) => {
// Optionally override createdBy from auth:
// const user = await ctx.auth.getUserIdentity();
// const createdBy = user?.subject ?? todo.createdBy;
return await ctx.db.insert("todos", {
...todo,
// createdBy,
});
},
});
// READ (by id)
export const getTodo = zQuery({
args: {
id: v.id("todos"),
},
handler: async (ctx, { id }) => {
return await ctx.db.get(id);
},
});
// LIST by assignee
export const listTodosByAssignee = zQuery({
args: {
assigneeId: v.string(), // or v.id("users") if you prefer
},
handler: async (ctx, { assigneeId }) => {
return await ctx.db
.query("todos")
.withIndex("by_assignee", q => q.eq("assignees", assigneeId))
.collect();
},
});
// UPDATE (partial)
export const updateTodo = zMutation({
args: {
id: v.id("todos"),
patch: TodoUpdateZ,
},
handler: async (ctx, { id, patch }) => {
await ctx.db.patch(id, patch);
},
});
// DELETE
export const deleteTodo = zMutation({
args: {
id: v.id("todos"),
},
handler: async (ctx, { id }) => {
await ctx.db.delete(id);
},
});
`zMutation` / `zQuery`:
- Convert the Zod schemas to Convex validators (`zodToConvex`) so Convex still knows arg types. [[Zod helpers](<https://stack.convex.dev/typescript-zod-function-validation#using-zod-with-convex>)]
- Run Zod validation before your handler, so you get the refined types.
---
## 5. Forms (create & update)
On the frontend (e.g. TanStack Form), you reuse the same schemas:
- **Create form**: `TodoCreateZ`
- **Update form**: `TodoUpdateZ` (partial)
Because these live in `src/schemas/todo.ts`, both the UI and Convex can import them, matching the “shared Zod schema” pattern described in the Zod + Convex article. [[Zod with Convex](<https://stack.convex.dev/typescript-zod-function-validation>)]
----------------------
Note: you can ask me a follow up question by @ mentioning me again :speech_balloon:
----------------------
@meager crest Kapa.ai is still learning and improving, please let me know how I did by reacting below.
@spiral tulip I'm having a big issue with circular dependencies, specifically when the Zod schema has an ID to another entity, if I import the Id type sometimes TS will just give an error
Let me check the knowledge base for you again. One moment! :books:
TypeScript circular issues around IDs and Zod are a known pain point with Convex; the knowledge sources mention a few related patterns and workarounds, but they don’t give a single “official” fix for your exact case. I’ll summarize what is documented and how it applies.
1. Why IDs tend to trigger circular / deep type errors
There are two overlapping problems in the sources:
-
Circular imports / cycles
- If a file that defines validators or schemas imports something that (directly or indirectly) imports Convex generated types (
./_generated/api,./_generated/dataModel,./_generated/server), you can end up with import cycles. This is explicitly called out as a cause of “undefined validator” and other weird behavior. [Undefined validator] - Example cycle:
schema.ts → validators.ts → someFile.ts → schema.ts. [Undefined validator]
- If a file that defines validators or schemas imports something that (directly or indirectly) imports Convex generated types (
-
Circular type inference / “type instantiation is excessively deep”
- When Convex generated types (
Doc,Id,api.*,internal.*) are used in functions that call other Convex functions (ctx.runQuery,ctx.runMutation, etc.) and return their results, TypeScript can get into circular inference and blow up. [Deep instantiation; Run* example] - Support explicitly notes: if you use anything that relies on inference via Convex generated types (e.g.
Doc,Id), you’re still in the circle. [Very fun instance]
- When Convex generated types (
When you pull Id<"users"> into a Zod schema file that’s also used by Convex, it’s easy to accidentally create one of these cycles.
2. Recommended pattern for IDs in Zod schemas
The Zod + Convex helpers are designed so you don’t need to import Convex Id into your Zod schema:
- They provide
zid("tablename")/zodToConvex/zodOutputToConvexto bridge Zod ↔ Convex. [v.id → zid; Zod helpers] zid("users")creates a Zod schema that:- Is just a string check when you call
.parse()in the browser. - Is converted into a proper
v.id("users")when used as a Convex argument validator, so Convex still enforces the table name. [v.id → zid]
- Is just a string check when you call
So instead of:
// problematic: mixing Zod + Convex Id in the same shared schema
import type { Id } from "../convex/_generated/dataModel";
const TodoZ = z.object({
createdBy: z.custom<Id<"users">>(),
});
Use a pure Zod schema for shared code, and let the helpers handle the Convex side:
// shared, no Convex imports
import { z } from "zod";
export const UserIdZ = z.string().min(1); // or leave as plain string
Then, on the Convex side, wrap it with zid or use zCustom* helpers:
// convex side
import { zCustomMutation, zid } from "convex-helpers/server/zod";
import { mutation } from "./_generated/server";
const zMutation = zCustomMutation(mutation, NoOp);
export const createTodo = zMutation({
args: {
createdBy: zid("users"), // becomes v.id("users") under the hood
},
handler: async (ctx, args) => {
// ...
},
});
This keeps your shared Zod schemas free of Convex types, which avoids a lot of circular import / inference issues.
3. General strategies from the docs/Discord to avoid the TS explosions
From the various threads and docs:
-
Keep shared schemas “pure”
- Don’t import
Id,Doc,api,internal, or Convex context types into files that are also used on the client or in other shared libraries. [Undefined validator] - Use plain Zod types (
string,number, enums) in shared modules, and only “upgrade” them to Convex validators (v.id,zid) inside theconvex/folder.
- Don’t import
-
Avoid cycles via generated types
- If a helper file imports
../convex/_generated/apiand that helper is in turn imported (directly or indirectly) by something Convex uses for codegen, you can get circular imports. This is exactly what one Discord thread describes with helpers calling scheduled functions. [Exclude folder / circular] - The guidance there is: keep helpers that import
api/internalseparate from anything that’s used to define validators or schemas.
- If a helper file imports
-
Break circular type inference with explicit types
- When you must use
ctx.runQuery/ctx.runMutationetc., explicitly type either:- The
run*result, or - The handler’s return type. [Run* example; Deep instantiation]
- The
- This doesn’t directly solve your Zod schema issue, but it’s the same underlying “circular inference” problem.
- When you must use
4. What I can’t say from the sources
The knowledge sources don’t include:
- A concrete example of a Zod schema that directly uses
Id<"table">and how to make that compile cleanly. - A documented, official pattern for “Zod schema with Convex
Idtype that is shared everywhere without any risk of circular inference”.
So I can’t confidently give you a “just do X with Id in Zod and it will always work” answer.
Given what is documented, the safest pattern is:
- Do not import
Idinto your shared Zod schema files. - Model IDs as plain strings (or use
zid("table")on the Convex side) and let Convex enforce the table name viav.id("table")/zid("table"). [v.id → zid] - Keep any code that touches
Id,Doc,api,internal, orctx.run*inside theconvex/folder and out of shared schema modules.
If you can share a minimal snippet of how you’re currently mixing Id into your Zod schemas (file A imports Id, file B imports A, etc.), I can map it more directly to the specific circular patterns described in the docs/Discord and suggest where to cut the dependency chain, within those constraints.
Note: you can ask me a follow up question by @ mentioning me again :speech_balloon:
@meager crest Kapa.ai is still learning and improving, please let me know how I did by reacting below.
@spiral tulip Take in mind this schema is being used for both the validators and the table schema
Let me check the knowledge base for you again. One moment! :books:
You’re running into exactly the edge the docs warn about: when a single schema is used for both Convex table definitions and function validators, and you also try to make it a shared Zod schema, it’s very easy to create circular imports or circular type inference.
From the sources, there are two key constraints you have to respect:
- Shared schemas must not depend on Convex types (
Id,Doc,api,internal, etc.), or you risk circular imports / “undefined validator” / deep instantiation errors. [Undefined validator; Very fun instance] - If you want to use Zod for both table schema and argument validation, you should:
- Keep the Zod schema itself “pure” (no Convex imports).
- Convert it to Convex validators inside
convex/schema.tsand inside Convex functions using the helpers (zodOutputToConvex,zodToConvex,zCustomMutation, etc.). [Zod DB types; Zod helpers]
Given that, the pattern that fits your requirement “this schema is used for both validators and the table schema” and avoids circular Id issues is:
1. Keep the shared Zod schema pure (no Id)
Define your Todo Zod schema in a shared file with no Convex imports and model foreign keys as strings (or opaque IDs) there.
// src/schemas/todo.ts
import { z } from "zod";
export const TodoTagZ = z.enum(["work", "personal", "urgent", "later"]);
export const TodoChecklistItemZ = z.object({
id: z.string().uuid().optional(),
label: z.string().min(1),
done: z.boolean().default(false),
});
// In shared Zod, model user references as strings.
// Do NOT import Convex Id here.
export const UserIdZ = z.string().min(1);
export const TodoBaseZ = z.object({
title: z.string().min(1),
description: z.string().min(1),
tags: z.array(TodoTagZ).default([]),
checklist: z.array(TodoChecklistItemZ).default([]),
createdBy: UserIdZ,
assignees: z.array(UserIdZ).default([]),
});
export const TodoCreateZ = TodoBaseZ;
export const TodoUpdateZ = TodoBaseZ.partial();
This schema can now be used:
- In the **frontend** (forms, etc.).
- In **Convex functions** (via `zCustomMutation` / `zCustomQuery`).
- As the **basis for the table schema** (via `zodOutputToConvex`).
Crucially, it does **not** import `Id` or any Convex types, so it won’t participate in Convex’s type-generation cycles. [[Undefined validator](<https://docs.convex.dev/error#undefined-validator>)]
### 2. Derive the table schema from Zod inside `convex/schema.ts`
Use `zodOutputToConvex` to turn the Zod schema into a Convex validator for the table. [[Zod DB types](<https://stack.convex.dev/typescript-zod-function-validation#can-i-use-zod-to-define-my-database-types-too>)]
```ts
// convex/schema.ts
import { defineSchema, defineTable } from "convex/server";
import { zodOutputToConvex } from "convex-helpers/server/zod";
import { TodoBaseZ } from "../src/schemas/todo";
const todoValidator = zodOutputToConvex(TodoBaseZ);
export default defineSchema({
todos: defineTable(todoValidator)
.index("by_createdBy", ["createdBy"])
.index("by_assignee", ["assignees"]),
});
Per the docs, this gives you:
- A Convex validator derived from your Zod schema.
- Schema enforcement at rest based on that Convex validator (but not all Zod refinements are guaranteed; transforms/defaults can degrade to
v.any()). [Zod DB types]
3. Use the same Zod schema for function validators (inside Convex)
Inside convex/, use the Zod helpers so Convex functions accept Zod schemas and still get Convex validators generated. [Zod helpers]
// convex/zod.ts
import { z } from "zod";
import { NoOp } from "convex-helpers/server/customFunctions";
import {
zCustomQuery,
zCustomMutation,
} from "convex-helpers/server/zod";
import { query, mutation } from "./_generated/server";
export const zQuery = zCustomQuery(query, NoOp);
export const zMutation = zCustomMutation(mutation, NoOp);
export { z };
// convex/todos.ts
import { v } from "convex/values";
import { zQuery, zMutation } from "./zod";
import { TodoCreateZ, TodoUpdateZ } from "../src/schemas/todo";
export const createTodo = zMutation({
args: {
todo: TodoCreateZ,
},
handler: async (ctx, { todo }) => {
// Optionally override createdBy from auth here
return await ctx.db.insert("todos", todo);
},
});
export const updateTodo = zMutation({
args: {
id: v.id("todos"),
patch: TodoUpdateZ,
},
handler: async (ctx, { id, patch }) => {
await ctx.db.patch(id, patch);
},
});
Here:
TodoCreateZ/TodoUpdateZare the same schemas used for the table and for your forms.zMutationconverts them to Convex validators and runs Zod validation before your handler. [Zod helpers]
4. Where Id fits in (and why you should keep it out of the shared schema)
The sources don’t show a safe pattern where a shared Zod schema directly uses Id<"table"> and is also used for table schema + validators without risking circular type inference. They explicitly warn that:
- Using
Doc/Idand other generated types in places that participate in Convex’s type inference can keep you “in the circle” and trigger “type instantiation is excessively deep”. [Very fun instance] - Circular imports involving validators / schema files are a common source of runtime and type errors. [Undefined validator]
Because of that, the documented and defensible approach is:
- Do not import
Idinto the shared Zod schema that you’re using for both table and validators. - Treat IDs as strings in that shared schema.
- If you need stricter ID typing on the Convex side, use
v.id("users")orzid("users")in Convex-only code (e.g. function args), not in the shared Zod module. [v.id → zid]
So, taking into account that your schema is used for both validators and the table schema, the safest pattern supported by the docs is:
- One pure Zod schema (no Convex imports) in a shared file.
zodOutputToConvexto derive the table validator from that schema inconvex/schema.ts. [Zod DB types]zCustomMutation/zCustomQueryto reuse the same Zod schema as argument validators in Convex functions. [Zod helpers]- Keep
Idand other Convex types out of that shared schema to avoid the circular dependency / deep instantiation issues you’re seeing.