JSON in Convex: Documents, Queries, Mutations, and TypeScript

Last updated:

Convex stores data as JSON-like documents in tables defined with its schema system. Unlike traditional ORMs, Convex generates end-to-end TypeScript types from your schema — the same type information flows from schema.ts through server-side query/mutation functions to React components. Every Convex document has a system-generated _id field (typed as Id<"tableName">) and a _creationTime timestamp. Define document shape with v validators: v.object({ name: v.string(), metadata: v.any() }). The v.any() validator accepts any JSON-compatible value — booleans, numbers, strings, null, arrays, and nested objects. Convex does not support undefined values (unlike JavaScript objects); use v.optional() for optional fields. Real-time subscriptions are automatic: useQuery(api.products.list) re-runs whenever the underlying data changes, streaming the updated JSON to the component with no WebSocket management needed. This guide covers Convex schema definitions, v validators for JSON fields, query and mutation functions, TypeScript types, real-time useQuery patterns, and HTTP action JSON endpoints.

Defining Document Schemas with v Validators

Bottom line: define every table in convex/schema.ts using defineSchema and defineTable, passing a v.object() validator that specifies each field's type. Convex generates TypeScript types from this schema automatically — no separate type file needed.

The v object from "convex/values" provides all validators. Key primitives: v.string(), v.number(), v.boolean(), v.null(). Structural: v.array(v.string()) for arrays, v.object({ key: v.string() }) for nested objects. v.any() is the escape hatch for fully dynamic JSON — equivalent to any in TypeScript. v.optional(v.string()) makes a field optional (can be absent from the document), which differs from v.union(v.string(), v.null()) (field must be present, can be null). v.union(v1, v2) accepts either type. Convex automatically adds _id: Id<"tableName"> and _creationTime: number (milliseconds since epoch) to every document — do not include these in your schema definition.

// convex/schema.ts
import { defineSchema, defineTable } from "convex/server"
import { v } from "convex/values"

export default defineSchema({
  products: defineTable(
    v.object({
      name:     v.string(),
      price:    v.number(),
      // v.any() — fully dynamic JSON: nested objects, arrays, any depth
      metadata: v.any(),
      tags:     v.array(v.string()),
      // v.optional() — field may be absent from the document
      sku:      v.optional(v.string()),
      // v.union() — field can be one of two types
      status:   v.union(v.literal("active"), v.literal("archived")),
    })
  ).index("by_status", ["status"]),

  users: defineTable(
    v.object({
      email:    v.string(),
      // structured nested object with known shape
      settings: v.object({
        theme:         v.union(v.literal("light"), v.literal("dark")),
        notifications: v.boolean(),
        // optional nested field
        plan:          v.optional(v.string()),
      }),
      // v.null() — field present but explicitly null
      deletedAt: v.union(v.number(), v.null()),
    })
  ).index("by_email", ["email"]),
})

// All validators at a glance:
// v.string()           → string
// v.number()           → number (integers and floats)
// v.boolean()          → boolean
// v.null()             → null
// v.array(v)           → array of type v
// v.object({ ... })    → object with specific shape
// v.any()              → any JSON-compatible value
// v.optional(v)        → field may be absent (undefined)
// v.union(v1, v2)      → either v1 or v2
// v.literal("value")   → exact string/number/boolean literal
// v.id("tableName")    → typed document ID (Id<"tableName">)
// System fields (auto-added, never declared):
//   _id: Id<"tableName">
//   _creationTime: number

Query Functions: Reading JSON Documents

Bottom line: Convex query functions are pure server-side functions that read documents from the database. They run on Convex's reactive runtime — any component subscribed via useQuery automatically receives updated results whenever the queried documents change.

Define query functions in any file under convex/ (commonly convex/queries.ts or co-located with the feature). Each function receives a ctx context with ctx.db for database access. Retrieve all documents with .collect(), limit with .take(10), and sort with .order("desc") — Convex sorts by _creationTime by default. Filter with .filter() using functional composition: q.eq, q.neq, q.lt, q.gt, q.and, q.or. Use dot notation for nested fields: q.field("metadata.plan"). Fetch a single document by ID with ctx.db.get(id) — returns null if not found. The typed reference api.products.list is generated by Convex codegen and used in components.

// convex/products.ts
import { query } from "./_generated/server"
import { v } from "convex/values"

// List all products — re-runs whenever products table changes
export const list = query({
  handler: async (ctx) => {
    return await ctx.db.query("products").collect()
  },
})

// Paginate: latest 10 active products
export const listActive = query({
  handler: async (ctx) => {
    return await ctx.db
      .query("products")
      .filter((q) => q.eq(q.field("status"), "active"))
      .order("desc")
      .take(10)
  },
})

// Filter on a nested JSON field using dot notation
export const listPro = query({
  handler: async (ctx) => {
    return await ctx.db
      .query("products")
      .filter((q) => q.eq(q.field("metadata.plan"), "pro"))
      .collect()
  },
})

// Fetch a single document by ID
export const byId = query({
  args: { id: v.id("products") },
  handler: async (ctx, args) => {
    return await ctx.db.get(args.id)  // returns Doc<"products"> | null
  },
})

// Use an index for O(log n) queries (defined in schema.ts)
export const byStatus = query({
  args: { status: v.union(v.literal("active"), v.literal("archived")) },
  handler: async (ctx, args) => {
    return await ctx.db
      .query("products")
      .withIndex("by_status", (q) => q.eq("status", args.status))
      .collect()
  },
})

// In a React component:
// import { useQuery } from "convex/react"
// import { api } from "@/convex/_generated/api"
//
// const products = useQuery(api.products.list)
// const product  = useQuery(api.products.byId, { id: productId })

Mutation Functions: Writing JSON Documents

Bottom line: Convex mutation functions handle all writes — insert, patch, replace, and delete. Every mutation runs as a transaction: all database operations within one mutation are atomic, and Convex auto-retries failed mutations up to 3 times on transient errors.

Define mutations with the mutation export from "./_generated/server". Declare argument types with v validators in the args field — Convex validates incoming args at the boundary, so no additional validation is needed inside the handler. Insert with ctx.db.insert("tableName", document), which returns the new document's Id. Patch (partial update) with ctx.db.patch(id, { field: newValue }) — only the specified fields are overwritten. Replace (full replacement) with ctx.db.replace(id, newDocument). Delete with ctx.db.delete(id). In React, call mutations with useMutation(api.products.create), which returns a function you call with the args.

// convex/products.ts (continued)
import { mutation } from "./_generated/server"
import { v } from "convex/values"

// Insert a new document — returns the new Id<"products">
export const create = mutation({
  args: {
    name:     v.string(),
    price:    v.number(),
    metadata: v.any(),          // accepts any JSON-compatible value
    tags:     v.array(v.string()),
  },
  handler: async (ctx, args) => {
    return await ctx.db.insert("products", {
      name:     args.name,
      price:    args.price,
      metadata: args.metadata,
      tags:     args.tags,
      status:   "active",
    })
  },
})

// Patch: update only the metadata field
export const updateMetadata = mutation({
  args: {
    id:       v.id("products"),
    metadata: v.any(),
  },
  handler: async (ctx, args) => {
    await ctx.db.patch(args.id, { metadata: args.metadata })
  },
})

// Replace: full document replacement
export const replace = mutation({
  args: {
    id:      v.id("products"),
    name:    v.string(),
    price:   v.number(),
    metadata: v.any(),
    tags:    v.array(v.string()),
    status:  v.union(v.literal("active"), v.literal("archived")),
  },
  handler: async (ctx, args) => {
    const { id, ...fields } = args
    await ctx.db.replace(id, fields)
  },
})

// Delete a document
export const remove = mutation({
  args: { id: v.id("products") },
  handler: async (ctx, args) => {
    await ctx.db.delete(args.id)
  },
})

// --- In a React component ---
// import { useMutation } from "convex/react"
// import { api } from "@/convex/_generated/api"
//
// const createProduct = useMutation(api.products.create)
//
// await createProduct({
//   name: "Widget",
//   price: 9.99,
//   metadata: { color: "blue", weight: 1.2 },
//   tags: ["new", "sale"],
// })

TypeScript Type Safety End-to-End

Bottom line: run npx convex dev once — Convex generates _generated/api.d.ts with exact TypeScript types for every query and mutation. Types flow from schema to server functions to React components with zero manual type annotations.

The two key utility types are Doc<"tableName"> (the full document including _id and _creationTime) and Id<"tableName"> (the opaque document ID). These are imported from "convex/_generated/dataModel". The typed reference api.products.list carries its full type: FunctionReference<"query", "public", {}, Doc<"products">[]>. In a component, useQuery(api.products.list) returns Doc<"products">[] | undefinedundefined while the first load is in flight, then the live array. Pass args to useQuery as the second argument: useQuery(api.products.byId, { id: productId }). The codegen flow is: schema change → npx convex dev regenerates types → TypeScript immediately catches all mismatches across the entire codebase. No any casting needed anywhere in the chain.

// Types generated by "npx convex dev" in convex/_generated/dataModel.d.ts:
// Doc<"products"> = {
//   _id:             Id<"products">
//   _creationTime:   number
//   name:            string
//   price:           number
//   metadata:        any
//   tags:            string[]
//   sku?:            string
//   status:          "active" | "archived"
// }

import type { Doc, Id } from "@/convex/_generated/dataModel"
import { useQuery, useMutation } from "convex/react"
import { api } from "@/convex/_generated/api"

// --- Use Doc<T> for typed document references ---
function ProductCard({ product }: { product: Doc<"products"> }) {
  // product._id          → Id<"products">  (opaque, not a plain string)
  // product._creationTime → number          (ms since epoch)
  // product.name         → string
  // product.metadata     → any             (v.any() in schema)
  // product.sku          → string | undefined (v.optional() in schema)
  return <div>{product.name} — ${product.price}</div>
}

// --- useQuery: undefined while loading, then live data ---
function ProductList() {
  // Type: Doc<"products">[] | undefined
  const products = useQuery(api.products.list)

  if (products === undefined) return <p>Loading...</p>

  return (
    <ul>
      {products.map((p) => (
        <li key={p._id}>{p.name}</li>
      ))}
    </ul>
  )
}

// --- useQuery with args ---
function ProductDetail({ id }: { id: Id<"products"> }) {
  // Type: Doc<"products"> | null | undefined
  // null  → document does not exist
  // undefined → still loading
  const product = useQuery(api.products.byId, { id })

  if (product === undefined) return <p>Loading...</p>
  if (product === null) return <p>Not found</p>
  return <ProductCard product={product} />
}

// --- Id<T> — opaque type, not assignable from plain string ---
// const id: Id<"products"> = "some-string"  ← TypeScript error
// const id: Id<"products"> = product._id    ← correct

Real-Time JSON with useQuery

Bottom line: useQuery subscribes to live data over a single persistent WebSocket connection. The component re-renders whenever the query result changes on the server — typically within 10–50ms of a mutation committing. No polling, no manual refetch, no WebSocket configuration.

Each browser client maintains exactly 1 WebSocket connection to Convex, regardless of how many useQuery calls are active. Compare to SWR or React Query, which poll over HTTP every 30–60 seconds (each poll takes 100–500ms and counts against your serverless function quota). To disable a subscription conditionally, pass "skip" as the args: Convex will not subscribe until a real args value is provided. Optimistic updates let you update local UI immediately before the server confirms — pass an optimisticUpdate function to useMutation that modifies the local query cache synchronously. The optimistic value is replaced by the server's confirmed result once the mutation completes, typically within 50–150ms on average network conditions.

"use client"
import { useQuery, useMutation } from "convex/react"
import { api } from "@/convex/_generated/api"
import type { Doc, Id } from "@/convex/_generated/dataModel"

// --- Basic real-time list ---
export function ProductList() {
  const products = useQuery(api.products.list)

  // undefined = first load in flight; show skeleton
  if (products === undefined) {
    return <div className="animate-pulse h-8 bg-gray-200 rounded" />
  }

  return (
    <ul className="space-y-2">
      {products.map((p) => (
        <li key={p._id} className="flex justify-between">
          <span>{p.name}</span>
          <span>${p.price}</span>
        </li>
      ))}
    </ul>
  )
}

// --- Conditional subscription: "skip" disables it ---
export function ProductDetail({ id }: { id?: Id<"products"> }) {
  // useQuery does not subscribe when args is "skip"
  const product = useQuery(
    api.products.byId,
    id ? { id } : "skip"
  )

  if (!id) return <p>Select a product</p>
  if (product === undefined) return <p>Loading...</p>
  if (product === null) return <p>Product not found</p>
  return <div>{product.name}</div>
}

// --- Optimistic update: update UI before server confirms ---
export function AddProductButton() {
  const addProduct = useMutation(api.products.create).withOptimisticUpdate(
    (localStore, args) => {
      // Immediately add a placeholder to the local cache
      const current = localStore.getQuery(api.products.list, {})
      if (current !== undefined) {
        const optimisticDoc: Doc<"products"> = {
          _id:           "temp" as Id<"products">,
          _creationTime: Date.now(),
          name:          args.name,
          price:         args.price,
          metadata:      args.metadata,
          tags:          args.tags,
          status:        "active",
        }
        localStore.setQuery(api.products.list, {}, [...current, optimisticDoc])
      }
    }
  )

  return (
    <button
      onClick={() => addProduct({
        name: "New Widget",
        price: 9.99,
        metadata: { color: "blue" },
        tags: ["new"],
      })}
    >
      Add Product
    </button>
  )
}

HTTP Actions for External JSON APIs

Bottom line: Convex HTTP actions handle raw HTTP requests using the standard Web API Request/Response interface. Use them for webhooks, OAuth callbacks, and external REST endpoints that need to call Convex from outside the React app.

Define HTTP actions in convex/http.ts using httpAction from "convex/server". Each action is an async function that receives (ctx, request) and returns a Response. Parse JSON request bodies with await request.json() (standard Fetch API). Return JSON responses with new Response(JSON.stringify(result), { headers: { "Content-Type": "application/json" } }). Wire up routing with httpRouter: call http.route({ path, method, handler }) for each endpoint and export the router as default. From inside an HTTP action, call Convex queries via ctx.runQuery(api.products.list, args) and mutations via ctx.runMutation(api.products.create, args). HTTP actions run outside the reactive runtime — no automatic retry (unlike mutations), no real-time subscriptions. Use them for Stripe webhooks, GitHub webhooks, and any external service that calls your backend over HTTPS.

// convex/http.ts
import { httpRouter, httpAction } from "convex/server"
import { api } from "./_generated/api"

const http = httpRouter()

// --- Receive a webhook and insert a document ---
const handleStripeWebhook = httpAction(async (ctx, request) => {
  // Parse raw JSON body (standard Fetch API)
  const body = await request.json() as {
    type: string
    data: { object: { id: string; amount: number; metadata: Record<string, string> } }
  }

  if (body.type === "payment_intent.succeeded") {
    const { id, amount, metadata } = body.data.object

    // Call a Convex mutation from inside the HTTP action
    await ctx.runMutation(api.orders.createFromWebhook, {
      stripeId:  id,
      amount,
      metadata,
    })
  }

  return new Response(JSON.stringify({ received: true }), {
    status: 200,
    headers: { "Content-Type": "application/json" },
  })
})

// --- Public REST endpoint: GET /api/products ---
const getProducts = httpAction(async (ctx, request) => {
  const url = new URL(request.url)
  const status = url.searchParams.get("status") ?? "active"

  // Call a Convex query — returns typed data
  const products = await ctx.runQuery(api.products.byStatus, {
    status: status as "active" | "archived",
  })

  return new Response(JSON.stringify(products), {
    status: 200,
    headers: {
      "Content-Type": "application/json",
      // Allow cross-origin requests if needed
      "Access-Control-Allow-Origin": "*",
    },
  })
})

// --- POST /api/products: create from external system ---
const createProduct = httpAction(async (ctx, request) => {
  const body = await request.json()

  // No automatic retry in HTTP actions — handle errors explicitly
  try {
    const id = await ctx.runMutation(api.products.create, {
      name:     body.name,
      price:    body.price,
      metadata: body.metadata ?? {},
      tags:     body.tags ?? [],
    })

    return new Response(JSON.stringify({ id }), {
      status: 201,
      headers: { "Content-Type": "application/json" },
    })
  } catch (err) {
    return new Response(JSON.stringify({ error: String(err) }), {
      status: 400,
      headers: { "Content-Type": "application/json" },
    })
  }
})

// --- Register all routes ---
http.route({ path: "/webhooks/stripe", method: "POST", handler: handleStripeWebhook })
http.route({ path: "/api/products",    method: "GET",  handler: getProducts })
http.route({ path: "/api/products",    method: "POST", handler: createProduct })

export default http

FAQ

How do I define a JSON field in a Convex schema?

Use v.any() for fully unstructured JSON — it accepts any JSON-compatible value (booleans, numbers, strings, null, arrays, nested objects). For a known shape, use v.object({ key: v.string(), count: v.number() }). Mark optional fields with v.optional() rather than v.null(): v.optional() means the field may be absent, while v.null() requires the field to be present with a null value. Convex automatically adds _id (Id<"tableName">) and _creationTime (number) to every document — never declare these in your schema.

How do I query a nested JSON field value in Convex?

Use dot notation inside q.field() inside a .filter() call: .filter((q) => q.eq(q.field("metadata.plan"), "pro")). Convex's filter API uses functional composition — q.eq, q.neq, q.lt, q.lte, q.gt, q.gte for comparisons; q.and, q.or to combine conditions. The main limitation: you cannot filter on array elements by index. For frequently filtered nested fields, define a Convex index with .index("by_plan", ["metadata.plan"]) to get O(log n) performance instead of a full table scan.

How does TypeScript type safety work in Convex?

Run npx convex dev — Convex generates _generated/api.d.ts with exact types for every function. Doc<"tableName"> is the full document type (all schema fields plus _id and _creationTime). Id<"tableName"> is the opaque ID type. In components, useQuery(api.products.list) returns Doc<"products">[] | undefinedundefined while loading. The codegen flow is: schema change → npx convex dev → regenerated types → TypeScript catches all mismatches. Zero any casting needed in the entire chain.

How do I update a JSON field in Convex?

Use ctx.db.patch(id, { fieldName: newValue }) for partial updates — it merges only the specified fields into the existing document. Use ctx.db.replace(id, newDocument) for full replacement — all fields must be provided (except _id and _creationTime). For nested JSON fields, pass the complete new value: ctx.db.patch(id, { metadata: { ...existing, plan: "pro" } }). Mutations are atomic transactions — all operations within one mutation function either all succeed or all roll back. Convex auto-retries failed mutations up to 3 times. No markModified needed.

How does Convex real-time work for JSON data?

Convex maintains 1 persistent WebSocket connection per browser client. useQuery subscribes the component to the query result — re-renders happen within 10–50ms whenever any document read by the query changes. Pass "skip" as the args to disable the subscription conditionally: useQuery(api.products.byId, id ? { id } : "skip"). For optimistic updates, pass an optimisticUpdate function to useMutation to update local cache immediately. Compare to SWR/React Query: those poll over HTTP every 30–60 seconds (100–500ms per request); Convex pushes changes at 10–50ms latency.

How do I expose a JSON REST API from Convex?

Define HTTP actions in convex/http.ts using httpAction. Parse incoming JSON with await request.json(). Return JSON with new Response(JSON.stringify(result), { headers: { "Content-Type": "application/json" } }). Wire up routes with httpRouter: http.route({ path: "/api/products", method: "GET", handler }). Call Convex mutations from inside the action with ctx.runMutation(api.products.create, args). HTTP actions have no automatic retry — handle errors explicitly. Use them for Stripe/GitHub webhooks and public REST APIs.

Using Firebase or Supabase instead?

The same real-time JSON document patterns apply across all three backends. See the equivalent guides for JSON in Firebase and JSON in Supabase.

Open JSON Formatter

Further reading and primary sources

  • Convex Schema DocumentationOfficial Convex reference for defineSchema, defineTable, and all v validators including v.any(), v.optional(), v.union(), and nested v.object().
  • Convex Query FunctionsOfficial Convex documentation for writing query functions, using ctx.db, filter composition, ordering, pagination, and the reactive runtime.
  • Convex Mutation FunctionsOfficial Convex documentation for mutations, ctx.db.insert/patch/replace/delete, transaction atomicity, and auto-retry behavior.
  • Convex TypeScript Types (Doc, Id)Official Convex documentation for Doc<T>, Id<T>, the code generation flow, and how schema types flow end-to-end through queries and React components.
  • Convex HTTP ActionsOfficial Convex documentation for httpAction, httpRouter, parsing request bodies, returning JSON responses, and calling mutations from HTTP actions.