#NextJS Zod Convex folder structure example?

48 messages · Page 1 of 1 (latest)

meager crest
#

I need an example of how to structure a big project that includes NextJS, Zod, Convex, the convex helpers (especially the Zod v4 helpers), and hopefully Clerk as well.

spiral tulipBOT
#

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:

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.

meager crest
#

@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

spiral tulipBOT
#

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, zodOutputToConvex may fall back to v.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)

#
  1. Define Zod schemas in a shared folder (outside convex/).
  2. Use convex-helpers/server/zod (zCustomQuery / zCustomMutation) so Convex functions accept Zod schemas for args and still generate Convex validators. [Zod + Convex]
  3. Use the same Zod schemas in your frontend (e.g. TanStack Form) for validation and types. [Discord thread]
  4. 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.

meager crest
#

@spiral tulip Focus on the folder structure, I can figure out the implementation myself

spiral tulipBOT
#

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 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.

meager crest
#

@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.

spiral tulipBOT
#

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 v system and share them between schema and functions. [Types cookbook; Argument validation]
  • How to use Zod with Convex via convex-helpers/server/zod for function arguments, and how to convert Zod schemas into Convex validators for table schemas with zodToConvex / 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 to v.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.

meager crest
#

@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

spiral tulipBOT
#

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.

meager crest
#

@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

spiral tulipBOT
#

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:

  1. 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]
  2. 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 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 / zodOutputToConvex to 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]

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:

  1. 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 the convex/ folder.
  2. Avoid cycles via generated types

    • If a helper file imports ../convex/_generated/api and 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 / internal separate from anything that’s used to define validators or schemas.
  3. Break circular type inference with explicit types

    • When you must use ctx.runQuery / ctx.runMutation etc., explicitly type either:
    • This doesn’t directly solve your Zod schema issue, but it’s the same underlying “circular inference” problem.

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 Id type 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 Id into 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 via v.id("table") / zid("table"). [v.id → zid]
  • Keep any code that touches Id, Doc, api, internal, or ctx.run* inside the convex/ 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.

meager crest
#

@spiral tulip Take in mind this schema is being used for both the validators and the table schema

spiral tulipBOT
#

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:

  1. 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]
  2. 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.ts and 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 / TodoUpdateZ are the same schemas used for the table and for your forms.
  • zMutation converts 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 / Id and 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 Id into 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") or zid("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.
  • zodOutputToConvex to derive the table validator from that schema in convex/schema.ts. [Zod DB types]
  • zCustomMutation / zCustomQuery to reuse the same Zod schema as argument validators in Convex functions. [Zod helpers]
  • Keep Id and other Convex types out of that shared schema to avoid the circular dependency / deep instantiation issues you’re seeing.

Note: you can ask me a follow up question by @ mentioning me again :speech_balloon: