JSON in Appwrite: Documents, Queries, Functions, and TypeScript

Last updated:

Appwrite is an open-source backend-as-a-service that stores structured data as JSON documents in its Databases service. Each document belongs to a collection — define the collection schema in the Appwrite Console or via the Management API, and Appwrite validates every document against it. Unlike Firestore or PocketBase, Appwrite enforces a typed attribute schema: you define string, integer, float, boolean, email, url, enum, relationship, ip, datetime, and [] (array) attributes. A special json attribute (available since Appwrite 1.5) accepts any JSON-serializable value with no schema enforcement. Use sdk.databases.createDocument(databaseId, collectionId, ID.unique(), data) to create documents. The Appwrite JavaScript SDK returns documents as plain JavaScript objects — no fromFirestore converter or withConverter needed. Filter with type-safe Query helpers: Query.equal('status', 'active'), Query.greaterThan('score', 80). For TypeScript, pass an interface to the SDK generic: sdk.databases.getDocument<Product>(...) returns Models.Document & Product. This guide covers document CRUD, typed Query helpers, the json attribute type, Appwrite Functions JSON handling, Zod validation, and TypeScript generics.

Collections and Document Schema Design

Bottom line: define typed collection attributes for any field you need to filter or index; use the json attribute only for truly dynamic data you will not query by.

Create a collection with sdk.databases.createCollection(databaseId, collectionId, name). Add typed attributes using the corresponding SDK methods — each enforces its type at write time and supports Query filtering. The json attribute accepts any JSON-serializable value for that field but has 0 index support and cannot be used with Query helpers. When deciding between a typed attribute and the json attribute, ask: "Will I ever filter or sort by a subfield of this value?" If yes, use typed attributes. If the data is truly schemaless — user-defined settings, extension metadata, raw API responses — the json attribute is appropriate.

Appwrite supports up to 1,000 attributes per collection. Attribute types map to Appwrite's storage and validation layer: string enforces max length (1–36,000 characters), integer enforces a 64-bit signed range, float uses double-precision, and enum restricts to a list of up to 100 allowed values. All attributes can be marked required or optional, and can be flagged as array (up to 100 elements per document).

import { Client, Databases, ID } from "node-appwrite"

const client = new Client()
  .setEndpoint("https://cloud.appwrite.io/v1")
  .setProject("YOUR_PROJECT_ID")
  .setKey("YOUR_API_KEY")   // server-side API key (never expose in browser)

const sdk = new Databases(client)

// --- 1. Create a collection ---
await sdk.createCollection(
  "main",                  // databaseId
  ID.unique(),             // collectionId
  "Products"               // human-readable name
)

// --- 2. Add typed attributes (supports Query filtering) ---
await sdk.createStringAttribute(
  "main", "products",
  "name",     // attribute key
  255,        // max length
  true        // required
)

await sdk.createIntegerAttribute(
  "main", "products",
  "stock",    // attribute key
  false,      // required
  0,          // min
  100000,     // max
  0           // default value
)

await sdk.createBooleanAttribute(
  "main", "products",
  "active",
  false,      // required
  true        // default
)

await sdk.createFloatAttribute(
  "main", "products",
  "price",
  true,       // required
  0.0,        // min
  999999.99   // max
)

// --- 3. Add the json attribute for schema-free data (Appwrite 1.5+) ---
// No index support — cannot use Query helpers on subfields
await sdk.createJsonAttribute(
  "main", "products",
  "metadata", // attribute key
  false        // required = false
)

// --- Attribute comparison ---
// Typed (string, integer, etc.): Query filtering supported, indexed, schema enforced
// json attribute: no Query filtering, no index, any JSON value accepted

Document CRUD with the JavaScript SDK

Bottom line: use createDocument, getDocument, updateDocument, deleteDocument, and listDocuments — all return or accept plain JavaScript objects. Pass a TypeScript generic to get typed responses.

The Appwrite JavaScript SDK (both browser and Node.js flavors) wraps the REST API. Every document response includes 6 system fields added by Appwrite: $id, $createdAt, $updatedAt, $collectionId, $databaseId, and $permissions. These come from Models.Document. Your collection attributes are merged in via the TypeScript generic T, giving Models.Document & T.updateDocument performs a partial update — only include the fields you want to change. If you omit a field, its existing value is preserved. listDocuments returns Models.DocumentList<T>, an object with total (number of matching documents, ignoring pagination) and documents (array of Models.Document & T).

import { Client, Databases, ID, Query, Models } from "appwrite"  // browser SDK
// For Node.js server-side: import { Client, Databases, ID, Query } from "node-appwrite"

const client = new Client()
  .setEndpoint("https://cloud.appwrite.io/v1")
  .setProject("YOUR_PROJECT_ID")

const sdk = new Databases(client)

const DB = "main"
const COL = "products"

// --- TypeScript interface matching collection attributes ---
interface Product {
  name: string
  price: number
  stock: number
  active: boolean
  metadata?: Record<string, unknown>   // json attribute
}

// --- CREATE ---
const created = await sdk.createDocument<Product>(
  DB, COL,
  ID.unique(),     // generate a unique $id
  {
    name: "Widget Pro",
    price: 9.99,
    stock: 250,
    active: true,
    metadata: { tags: ["featured", "sale"], sku: "WP-001" },
  }
)
// created.$id, created.$createdAt, created.name, created.price — all typed

// --- READ a single document ---
const product = await sdk.getDocument<Product>(DB, COL, created.$id)
// product is Models.Document & Product
console.log(product.name, product.$createdAt)

// --- UPDATE (partial) ---
await sdk.updateDocument<Product>(
  DB, COL, created.$id,
  { price: 7.99, stock: 200 }   // only these fields change
)

// --- LIST with Query ---
const result = await sdk.listDocuments<Product>(DB, COL, [
  Query.equal("active", true),
  Query.orderDesc("$createdAt"),
  Query.limit(20),
])
// result.total — total matching documents (ignores limit)
// result.documents — array of Models.Document & Product
console.log(`Showing ${result.documents.length} of ${result.total}`)

// --- DELETE ---
await sdk.deleteDocument(DB, COL, created.$id)

Typed Query Helpers for JSON Filtering

Bottom line: Appwrite's Query class offers 20+ type-safe filter operators. All filters operate on typed collection attributes — json attribute subfields cannot be filtered.

Pass an array of Query expressions as the third argument to listDocuments. Multiple queries are combined with AND logic — Appwrite applies all filters simultaneously. For OR logic, run separate queries and merge results client-side; Appwrite has no built-in OR operator as of version 1.6. Cursor-based pagination with Query.cursorAfter(lastDocumentId) is more efficient than offset pagination for large collections — it avoids scanning and skipping rows. Full-text search with Query.search() requires a fulltext index on the attribute (set it in the Appwrite Console or with the Management API). The Query.contains() operator checks if an array attribute contains a specific value, not if a string contains a substring — use Query.search() for substring matching.

import { Databases, Query } from "appwrite"

// Assumes sdk is an initialized Databases instance (see previous section)

// --- Equality and inequality ---
await sdk.listDocuments(DB, COL, [Query.equal("active", true)])
await sdk.listDocuments(DB, COL, [Query.notEqual("status", "archived")])

// --- Numeric comparisons ---
await sdk.listDocuments(DB, COL, [Query.greaterThan("price", 5.00)])
await sdk.listDocuments(DB, COL, [Query.lessThanEqual("stock", 10)])
await sdk.listDocuments(DB, COL, [Query.between("price", 5.00, 50.00)])

// --- Null checks ---
await sdk.listDocuments(DB, COL, [Query.isNull("deletedAt")])
await sdk.listDocuments(DB, COL, [Query.isNotNull("publishedAt")])

// --- Array attribute: contains a value ---
// "tags" must be a string array attribute (not the json attribute)
await sdk.listDocuments(DB, COL, [Query.contains("tags", "featured")])

// --- Full-text search (requires fulltext index on the attribute) ---
await sdk.listDocuments(DB, COL, [Query.search("name", "widget")])

// --- Sorting ---
await sdk.listDocuments(DB, COL, [Query.orderDesc("$createdAt")])
await sdk.listDocuments(DB, COL, [Query.orderAsc("price")])

// --- Pagination: limit + offset ---
await sdk.listDocuments(DB, COL, [Query.limit(20), Query.offset(40)])

// --- Cursor-based pagination (preferred for large collections) ---
const page1 = await sdk.listDocuments(DB, COL, [
  Query.limit(20),
  Query.orderDesc("$createdAt"),
])
const lastId = page1.documents[page1.documents.length - 1].$id
const page2 = await sdk.listDocuments(DB, COL, [
  Query.limit(20),
  Query.orderDesc("$createdAt"),
  Query.cursorAfter(lastId),   // start after the last document of page 1
])

// --- Combining queries (AND logic) ---
await sdk.listDocuments(DB, COL, [
  Query.equal("active", true),
  Query.greaterThan("price", 5.00),
  Query.orderAsc("name"),
  Query.limit(50),
])

// --- NOTE: json attribute subfields cannot be filtered ---
// This does NOT work — "metadata.tags" is inside the json attribute:
// Query.equal("metadata.tags", "featured")  // ❌ Appwrite rejects this

Appwrite Functions JSON Handling

Bottom line: Appwrite Functions expose req.body as a pre-parsed JavaScript object for JSON requests, and res.json(data) to send typed JSON responses — no manual JSON.parse or JSON.stringify needed.

The function context destructures into 4 properties: req, res, log, and error. When the HTTP trigger receives Content-Type: application/json, Appwrite parses the body before invoking the function — req.body is a JavaScript object. For non-JSON bodies, req.body is the raw string. req.query is an object with all query string parameters as strings. req.headers is an object with all HTTP headers in lowercase keys. res.json(data, statusCode) sets Content-Type: application/json and serializes data. To call the Appwrite SDK from within a Function, use the internal endpoint http://appwrite/v1 (Docker network) with a server API key stored as a Function environment variable — never hardcode secrets.

import { Client, Databases, ID } from "node-appwrite"
import { z } from "zod"

// Appwrite Function entry point — TypeScript
export default async ({ req, res, log, error }: any) => {

  // --- req.body is pre-parsed JSON for Content-Type: application/json ---
  // For other content types, req.body is a raw string
  const body = req.body   // already a JavaScript object

  // --- Zod validation on the incoming JSON body ---
  const SubmitOrderSchema = z.object({
    productId: z.string().min(1),
    quantity:  z.number().int().min(1).max(1000),
    userId:    z.string().min(1),
  })

  const parsed = SubmitOrderSchema.safeParse(body)
  if (!parsed.success) {
    return res.json({ errors: parsed.error.flatten().fieldErrors }, 400)
  }

  const { productId, quantity, userId } = parsed.data

  // --- Access query string and headers ---
  const currency = req.query.currency ?? "USD"    // ?currency=EUR
  const authHeader = req.headers["x-custom-token"] ?? ""

  // --- Call Appwrite SDK from within the Function ---
  const client = new Client()
    .setEndpoint(process.env.APPWRITE_FUNCTION_API_ENDPOINT!)
    .setProject(process.env.APPWRITE_FUNCTION_PROJECT_ID!)
    .setKey(process.env.APPWRITE_API_KEY!)   // server API key from env var

  const databases = new Databases(client)

  try {
    const order = await databases.createDocument(
      "main", "orders",
      ID.unique(),
      { productId, quantity, userId, status: "pending", currency }
    )
    log("Order created:", order.$id)

    // --- res.json: serialize and send with status code ---
    return res.json({ success: true, orderId: order.$id }, 201)

  } catch (err: any) {
    error("Failed to create order:", err.message)
    return res.json({ error: "Internal error" }, 500)
  }
}

Zod Validation for Appwrite Responses

Bottom line: Appwrite SDK generics give compile-time types but no runtime validation. Pair Zod with the SDK to catch data shape mismatches before they propagate through your application.

The TypeScript generic sdk.databases.getDocument<Product>(...) makes the TypeScript compiler treat the returned object as Models.Document & Product. At runtime, Appwrite returns whatever is stored in the database — if the collection schema drifted from your TypeScript interface (e.g. a field was renamed in the Console, or a migration changed a type), TypeScript cannot catch it. Zod's safeParse validates the actual runtime shape. Use z.infer<typeof Schema> to derive the TypeScript type from the Zod schema — this keeps a single source of truth. Pass z.infer<typeof ProductSchema> as the SDK generic to ensure the TypeScript type and the Zod schema are always in sync. For listDocuments, validate each document in the documents array — a single malformed document should not crash the entire list.

import { Databases, ID } from "appwrite"
import { z } from "zod"

// --- 1. Define the Zod schema (single source of truth) ---
const ProductSchema = z.object({
  $id:         z.string(),
  $createdAt:  z.string(),
  $updatedAt:  z.string(),
  name:        z.string().min(1).max(255),
  price:       z.number().nonnegative(),
  stock:       z.number().int().nonnegative().default(0),
  active:      z.boolean().default(true),
  // json attribute field — optional, any shape
  metadata: z.object({
    tags: z.array(z.string()),
    sku:  z.string().optional(),
  }).optional(),
})

// --- 2. Derive TypeScript type from the schema ---
type Product = z.infer<typeof ProductSchema>
// Now use Product as the SDK generic — schema and type stay in sync

// --- 3. Typed wrapper function ---
async function getProduct(
  sdk: Databases,
  documentId: string
): Promise<Product> {
  // SDK generic matches z.infer<typeof ProductSchema>
  const raw = await sdk.getDocument<Product>("main", "products", documentId)

  // Runtime validation — throws ZodError if shape is wrong
  return ProductSchema.parse(raw)
}

// --- 4. safeParse for graceful error handling ---
async function getProductSafe(sdk: Databases, documentId: string) {
  const raw = await sdk.getDocument<Product>("main", "products", documentId)
  const result = ProductSchema.safeParse(raw)

  if (!result.success) {
    console.error("Document validation failed:", result.error.flatten())
    return null
  }

  return result.data  // typed as Product, runtime-validated
}

// --- 5. Validate a list of documents ---
async function listProducts(sdk: Databases): Promise<Product[]> {
  const list = await sdk.listDocuments<Product>("main", "products")

  return list.documents.flatMap((doc) => {
    const result = ProductSchema.safeParse(doc)
    if (!result.success) {
      console.warn(`Skipping malformed document ${doc.$id}`)
      return []   // skip invalid documents instead of crashing
    }
    return [result.data]
  })
}

Realtime Subscriptions for JSON Documents

Bottom line: Appwrite Realtime pushes full document JSON over WebSocket. Subscribe with a hierarchical channel string, cast response.payload to your type, and call the returned function to unsubscribe.

sdk.subscribe(channel, callback) opens or reuses a WebSocket connection. Appwrite multiplexes multiple subscriptions over a single socket. The channel string uses Appwrite's hierarchical naming: resource type, resource IDs, and sub-resources separated by dots, with wildcard * segments. The callback receives a RealtimeResponseEvent<Models.Document & T> object with 2 important fields: events (array of event strings describing what happened) and payload (the full document — all fields, including $id and system fields). Event strings follow the pattern databases.{dbId}.collections.{colId}.documents.{docId}.create — use wildcard matching with .includes() or .some(). Compared to FirebaseonSnapshot and Supabase channel.on(): all 3 use WebSocket-based push; Appwrite uses channel strings, Firebase uses query references, and Supabase uses PostgreSQL-style filter strings. Appwrite sends the complete document on every event — there is no delta/diff format as of version 1.6.

import { Client, Databases, RealtimeResponseEvent, Models } from "appwrite"

const client = new Client()
  .setEndpoint("https://cloud.appwrite.io/v1")
  .setProject("YOUR_PROJECT_ID")

const sdk = new Databases(client)

interface Product {
  name: string
  price: number
  stock: number
  active: boolean
  metadata?: Record<string, unknown>
}

const DB  = "main"
const COL = "products"

// --- Subscribe to ALL document events in a collection ---
const unsubscribeAll = client.subscribe(
  `databases.${DB}.collections.${COL}.documents`,
  (response: RealtimeResponseEvent<Models.Document & Product>) => {

    const { events, payload } = response

    if (events.some(e => e.endsWith(".create"))) {
      console.log("New product created:", payload.$id, payload.name)
    }

    if (events.some(e => e.endsWith(".update"))) {
      console.log("Product updated:", payload.$id, "new price:", payload.price)
    }

    if (events.some(e => e.endsWith(".delete"))) {
      console.log("Product deleted:", payload.$id)
    }
  }
)

// --- Subscribe to a SINGLE document ---
const docId = "abc123"
const unsubscribeDoc = client.subscribe(
  `databases.${DB}.collections.${COL}.documents.${docId}`,
  (response: RealtimeResponseEvent<Models.Document & Product>) => {
    console.log("Specific product changed:", response.payload.price)
  }
)

// --- Subscribe to multiple channels at once ---
const unsubscribeMulti = client.subscribe(
  [
    `databases.${DB}.collections.${COL}.documents`,
    `databases.${DB}.collections.orders.documents`,
  ],
  (response) => {
    console.log("Event on channel:", response.events[0])
  }
)

// --- Unsubscribe: call the return value of subscribe ---
// Store the function and call it when the component unmounts or subscription is no longer needed
unsubscribeAll()
unsubscribeDoc()
unsubscribeMulti()

// --- React useEffect pattern ---
// useEffect(() => {
//   const unsub = client.subscribe(`databases.${DB}.collections.${COL}.documents`, handler)
//   return () => unsub()   // cleanup on unmount
// }, [])

FAQ

How do I store JSON in Appwrite?

Appwrite stores all data as JSON documents inside collections. For typed fields, define collection attributes in the Console or via sdk.databases.createStringAttribute(), createIntegerAttribute(), etc. For schemaless data, use the json attribute (Appwrite 1.5+): sdk.databases.createJsonAttribute(databaseId, collectionId, 'metadata', false). Create documents with sdk.databases.createDocument(databaseId, collectionId, ID.unique(), data). Appwrite validates typed attributes at write time and returns documents as plain JavaScript objects — the return type is Models.Document & T when you pass a TypeScript generic.

How do I query documents by JSON field value in Appwrite?

Use the Query class from the Appwrite SDK. Query.equal('status', 'active'), Query.greaterThan('score', 80), Query.contains('tags', 'featured'). Pass an array of queries as the third argument to sdk.databases.listDocuments() — all queries are combined with AND. A key limitation: Query filters only work on typed collection attributes. Fields inside the json attribute type cannot be filtered. For queryable fields, always use typed attributes (string, integer, boolean, etc.).

How do I add TypeScript types to Appwrite document responses?

Pass a TypeScript interface as a generic to any SDK document method: sdk.databases.getDocument<Product>(databaseId, collectionId, documentId). The return type is Models.Document & Product. Models.Document adds 6 system fields: $id, $createdAt, $updatedAt, $collectionId, $databaseId, and $permissions. sdk.databases.listDocuments<Product>(...) returns Models.DocumentList<Product> with total and documents array.

How does Appwrite Functions handle JSON?

When an HTTP trigger receives Content-Type: application/json, Appwrite pre-parses the body:req.body is already a JavaScript object — no JSON.parse needed. For other content types, req.body is a raw string. Use res.json(data, statusCode) to send JSON responses; Appwrite sets Content-Type: application/json automatically. Validate with Zod: schema.safeParse(req.body), then return res.json({ errors: result.error.flatten() }, 400) on failure. Call the Appwrite SDK from within Functions using the internal endpoint and an API key from environment variables.

How do I validate Appwrite document data with Zod?

Define a Zod schema that includes the $id, $createdAt, and other system fields alongside your collection attributes. After fetching a document, call ProductSchema.parse(document) (throws) or ProductSchema.safeParse(document) (returns a result object). Use z.infer<typeof ProductSchema> to derive the TypeScript type — pass it as the SDK generic to keep schema and type in sync. Wrap the SDK call and Zod validation in a typed helper function to encapsulate the fetch-and-validate pattern.

How does Appwrite Realtime work for JSON documents?

Subscribe with client.subscribe(channel, callback). Channel strings follow a hierarchical pattern: `databases.${dbId}.collections.${colId}.documents` for all documents in a collection. The callback receives response.events (array of event strings) and response.payload (the full document JSON as Models.Document & T). Filter events with response.events.some(e => e.endsWith(".create")). client.subscribe returns an unsubscribe function — call it to clean up the WebSocket listener. Compared to Firebase onSnapshot and Supabase channel.on: all 3 push full document JSON over WebSocket; Appwrite uses hierarchical channel strings.

Using Firebase or Supabase instead?

JSON document storage patterns — typed fields, query filtering, real-time subscriptions — map across all BaaS platforms with slightly different APIs. See the equivalent guides for JSON in Firebase, JSON in Supabase, JSON in PocketBase, and JSON in Convex.

Open JSON Tools on Jsonic

Further reading and primary sources

  • Appwrite Databases documentationOfficial Appwrite documentation for the Databases service: collections, attributes, documents, indexes, and the JavaScript SDK methods
  • Appwrite Query class referenceFull reference for Appwrite Query helpers including equal, greaterThan, between, contains, search, orderDesc, cursorAfter, and all 20+ operators
  • Appwrite Functions documentationOfficial Appwrite documentation for Functions: HTTP triggers, context object (req/res/log/error), runtimes, and calling the Appwrite SDK from within a function
  • Appwrite Realtime documentationOfficial Appwrite documentation for the Realtime service: channel strings, event types, subscribe/unsubscribe pattern, and WebSocket connection management
  • Zod documentationOfficial Zod documentation for TypeScript-first runtime schema validation: object schemas, safeParse, z.infer, and integration patterns with external APIs and SDKs