JSON in PocketBase: Collections, JSON Fields, and TypeScript SDK

Last updated:

PocketBase stores structured JSON data in two ways: as individual typed fields (text, number, bool, select) within a collection schema, or as a single json field type that accepts any JSON-serializable value. A json field stores its content as a raw JSON string in SQLite — reads return the parsed JavaScript value via the PocketBase JavaScript SDK. For structured data, prefer individual typed fields (PocketBase validates each field independently, provides filter operators, and generates TypeScript interfaces). Use the json field when you need to store arbitrary or dynamic JSON structures that don't fit a fixed schema. The PocketBase JavaScript SDK provides pb.collection('posts').getList(), getOne(), create(), update(), and delete() — all return typed RecordModel objects that can be cast to your own interfaces. PocketBase's filter API supports dot notation for JSON field queries: pb.collection('posts').getList(1, 20, { filter: 'metadata.plan = "pro"' }). This guide covers collection schema setup, JSON field vs typed fields, SDK CRUD operations, TypeScript generics, filter expressions, and Zod validation for API responses.

Collection Schema: JSON Field vs Typed Fields

Bottom line: use individual typed fields (text, number, bool, select) for known, fixed structures. Use the json field only for dynamic or arbitrary data that can't be expressed as a fixed schema.

PocketBase collections define their schema in the Admin UI (at http://127.0.0.1:8090/_/) or programmatically via the REST API. Each field has a type: text, number, bool, email, url, date, select, relation, file, or json. PocketBase validates each typed field on write — a number field rejects strings, a select field rejects values outside the allowed list. The json field accepts any valid JSON value and stores it verbatim as a SQLite text column. PocketBase compiles filter expressions on json fields to SQLite json_extract() calls — there are no native JSON operators like PostgreSQL's ->>. For a products collection, the typed-fields approach uses price: number, name: text, tags: select (multiple). The json approach uses a single metadata: json field to hold an arbitrary object. The Admin UI renders each typed field with an appropriate input (number spinner, multi-select dropdown). The json field renders as a plain text area accepting any JSON string.

// Approach 1: Individual typed fields (recommended for fixed schemas)
// Define in Admin UI or via POST /api/collections

// products collection schema — typed fields
{
  "name": "products",
  "type": "base",
  "schema": [
    { "name": "name",  "type": "text",   "required": true },
    { "name": "price", "type": "number", "required": true },
    { "name": "inStock", "type": "bool", "required": false },
    {
      "name": "tags",
      "type": "select",
      "options": { "maxSelect": 5, "values": ["featured", "sale", "new", "digital"] }
    }
  ]
}

// Approach 2: json field for dynamic/arbitrary data
// Use when the shape varies per record or is defined at runtime

{
  "name": "products",
  "type": "base",
  "schema": [
    { "name": "name",     "type": "text", "required": true },
    { "name": "metadata", "type": "json" }   // accepts any JSON value
  ]
}

// Hybrid (most practical): typed fields for known columns + json for extras
{
  "name": "products",
  "type": "base",
  "schema": [
    { "name": "name",     "type": "text",   "required": true },
    { "name": "price",    "type": "number", "required": true },
    { "name": "metadata", "type": "json"  }  // arbitrary extra attributes
  ]
}

CRUD Operations with the JavaScript SDK

Bottom line: install pocketbase (npm package), instantiate new PocketBase('http://127.0.0.1:8090'), then call collection methods. All methods return promises. JSON field values are plain JavaScript objects on both read and write — no serialization needed.

The SDK's getList(page, perPage, options?) returns a ListResult<RecordModel> with 5 properties: page, perPage, totalItems, totalPages, and items. The create() method accepts a plain object — pass the json field value as a JavaScript object and PocketBase serializes it before storage. The update() method replaces the entire json field value; there is no partial merge for nested keys. Authentication is handled via pb.collection('users').authWithPassword(email, password) — the SDK stores the returned auth token in pb.authStore and includes it in all subsequent requests automatically. Realtime subscriptions use SSE (Server-Sent Events) — call pb.collection('products').subscribe('*', callback) to receive events for all records in the collection.

import PocketBase from 'pocketbase'

const pb = new PocketBase('http://127.0.0.1:8090')

// --- Authentication ---
await pb.collection('users').authWithPassword('admin@example.com', 'password123')
// pb.authStore.token is now set; included in all subsequent requests

// --- Create: pass json field as a plain JS object ---
const product = await pb.collection('products').create({
  name: 'Widget Pro',
  price: 49.99,
  metadata: {
    plan: 'pro',
    features: ['analytics', 'api-access', 'priority-support'],
    releaseDate: '2026-01-15',
  },
})
console.log(product.id)          // "abc123xyz..."
console.log(product.metadata)    // { plan: 'pro', features: [...], releaseDate: '...' }

// --- getList: paginated results ---
const records = await pb.collection('products').getList(1, 20)
// records.page         → 1
// records.perPage      → 20
// records.totalItems   → 143
// records.totalPages   → 8
// records.items        → RecordModel[]

// --- getOne: fetch single record ---
const record = await pb.collection('products').getOne(product.id)

// --- update: replaces the entire metadata json field ---
await pb.collection('products').update(product.id, {
  metadata: {
    plan: 'enterprise',      // full replacement — not a merge
    features: ['analytics', 'api-access', 'priority-support', 'sso'],
    releaseDate: '2026-01-15',
  },
})

// --- delete ---
await pb.collection('products').delete(product.id)

// --- Realtime subscription (SSE, no WebSocket needed) ---
pb.collection('products').subscribe('*', (e) => {
  console.log(e.action)   // 'create' | 'update' | 'delete'
  console.log(e.record)   // full RecordModel at time of event
})

// Unsubscribe when done
// pb.collection('products').unsubscribe()

TypeScript Type Safety with SDK Generics

Bottom line: extend RecordModel with your field types and pass the interface as a generic to SDK methods. The pocketbase-typegen CLI generates these interfaces automatically from your live database schema.

The RecordModel base type from the SDK includes 5 base fields: id (string), collectionId (string), collectionName (string), created (string ISO date), and updated (string ISO date). Your custom fields (including json fields) are typed as any on the base RecordModel — extend it with an interface to get specific types. SDK generics apply to getOne<T>, getList<T>, getFullList<T>, create<T>, and update<T>. The pocketbase-typegen package (npm: pocketbase-typegen) reads your SQLite data.db file or a running PocketBase instance and generates a TypedPocketBase class with full collection typing — no manual interface definitions needed.

import PocketBase, { RecordModel } from 'pocketbase'

// --- Define TypeScript interfaces for your collection fields ---
interface ProductMetadata {
  plan: 'free' | 'pro' | 'enterprise'
  features: string[]
  releaseDate: string
}

interface ProductRecord extends RecordModel {
  name: string
  price: number
  metadata: ProductMetadata
}

const pb = new PocketBase('http://127.0.0.1:8090')

// --- Typed getOne ---
const product = await pb.collection('products').getOne<ProductRecord>('abc123')
product.metadata.plan       // type: 'free' | 'pro' | 'enterprise'
product.metadata.features   // type: string[]

// --- Typed getList ---
const list = await pb.collection('products').getList<ProductRecord>(1, 20)
list.items[0].metadata.plan  // typed

// --- Auto-generate types with pocketbase-typegen ---
// Install: npm install -D pocketbase-typegen
// Generate from a running instance:
//   npx pocketbase-typegen --url http://127.0.0.1:8090 \
//     --email admin@example.com --password password \
//     --out src/pocketbase-types.ts
// Generate from the SQLite file directly (no running server needed):
//   npx pocketbase-typegen --db ./pb_data/data.db --out src/pocketbase-types.ts

// Generated output (example):
// export interface ProductsResponse extends BaseSystemFields {
//   name: string
//   price: number
//   metadata: unknown   // json fields are typed as unknown — add Zod for runtime safety
// }
// export type TypedPocketBase = PocketBase & {
//   collection(idOrName: 'products'): RecordService<ProductsResponse>
// }

// Usage with TypedPocketBase:
// import { TypedPocketBase } from './pocketbase-types'
// const pb = new PocketBase('http://127.0.0.1:8090') as TypedPocketBase
// const products = await pb.collection('products').getList(1, 20)
// products.items[0].name   // typed as string — no generic needed

Filter Expressions for JSON Fields

Bottom line: PocketBase filter syntax uses dot notation for JSON subfield access. Compile to SQLite json_extract() under the hood. Supported operators: =, !=, >, >=, <, <=, ~ (contains), and null checks.

PocketBase's filter API uses a custom DSL — not raw SQL. The filter string is passed in the options object of getList(), getFullList(), and getFirstListItem(). Combine conditions with && (AND) and || (OR); group with parentheses. Sort with the sort option: prefix a field with - for descending order. PocketBase compiles dot-notation JSON access to json_extract(col, '$.subfield') in SQLite. Limitation: PocketBase does not support array containment queries on json fields — for list membership (e.g. "find records where tags contains 'admin'"), use the select field type with multiple: true, which stores values in a form PocketBase can filter with the ?~ operator.

import PocketBase from 'pocketbase'

const pb = new PocketBase('http://127.0.0.1:8090')

// --- Equality filter on a json subfield ---
const proProducts = await pb.collection('products').getList(1, 20, {
  filter: 'metadata.plan = "pro"',
})

// --- Not-equal filter ---
const nonFree = await pb.collection('products').getList(1, 20, {
  filter: 'metadata.plan != "free"',
})

// --- Range filter (numeric subfield) ---
const highScore = await pb.collection('products').getList(1, 20, {
  filter: 'metadata.score >= 80',
})

// --- Null check ---
const notArchived = await pb.collection('products').getList(1, 20, {
  filter: 'metadata.archivedAt = null',
})

// --- String contains (~ operator, case-insensitive) ---
const matching = await pb.collection('products').getList(1, 20, {
  filter: 'metadata.description ~ "widget"',
})

// --- Combined with && and || ---
const filtered = await pb.collection('products').getList(1, 20, {
  filter: '(metadata.plan = "pro" || metadata.plan = "enterprise") && metadata.score >= 50',
})

// --- Sort descending on a json subfield ---
const sorted = await pb.collection('products').getList(1, 20, {
  filter: 'metadata.plan != "free"',
  sort: '-metadata.score',   // '-' prefix = descending
})

// --- getFirstListItem: returns the first match or throws 404 ---
const first = await pb.collection('products').getFirstListItem('metadata.plan = "enterprise"')

// --- Use select field (not json) for array containment ---
// Assuming 'tags' is a select field with multiple: true
const tagged = await pb.collection('products').getList(1, 20, {
  filter: 'tags ?~ "featured"',   // ?~ = array contains (select fields only)
})

Zod Validation for API Responses

Bottom line: PocketBase json fields arrive as unknown at the TypeScript level unless you extend RecordModel. Validate with Zod after every fetch to catch schema drift before it reaches application logic.

The pocketbase-typegen tool types json fields as unknown by default — there is no schema to infer from. Zod fills this gap: define a schema for the JSON field shape, call safeParse on the field value after fetching, and derive the TypeScript type with z.infer<typeof Schema>. Pass the inferred type to the SDK generic to align runtime validation with compile-time types. Use safeParse (not parse) in production — it returns a result object rather than throwing, so a single bad record doesn't crash an entire list fetch. Wrapping fetch and validation in a single function ensures validation is never skipped at call sites.

import PocketBase, { RecordModel } from 'pocketbase'
import { z } from 'zod'

const pb = new PocketBase('http://127.0.0.1:8090')

// --- Define Zod schema for the json field ---
const ProductMetadataSchema = z.object({
  plan: z.enum(['free', 'pro', 'enterprise']),
  features: z.array(z.string()),
  releaseDate: z.string().optional(),
})

// --- Full record schema (mirrors RecordModel base fields + custom fields) ---
const ProductRecordSchema = z.object({
  id: z.string(),
  collectionId: z.string(),
  collectionName: z.string(),
  created: z.string(),
  updated: z.string(),
  name: z.string(),
  price: z.number(),
  metadata: ProductMetadataSchema,
})

// --- Infer TypeScript type from Zod schema ---
type ProductRecord = z.infer<typeof ProductRecordSchema>

// --- Validate a single record ---
async function getProduct(id: string): Promise<ProductRecord> {
  const raw = await pb.collection('products').getOne(id)
  const result = ProductRecordSchema.safeParse(raw)

  if (!result.success) {
    console.error('Product record validation failed:', result.error.flatten())
    throw new Error('Invalid product data from PocketBase')
  }

  return result.data   // fully typed and runtime-validated ProductRecord
}

// --- Validate a list response ---
async function getProducts(page = 1): Promise<ProductRecord[]> {
  const list = await pb.collection('products').getList(page, 20)

  return list.items.flatMap((item) => {
    const result = ProductRecordSchema.safeParse(item)
    if (!result.success) {
      console.warn('Skipping invalid record:', item.id, result.error.flatten())
      return []   // skip bad records, don't crash the whole list
    }
    return [result.data]
  })
}

// --- Usage: types flow from Zod schema → SDK generic ---
const products = await getProducts()
products[0].metadata.plan       // type: 'free' | 'pro' | 'enterprise'
products[0].metadata.features   // type: string[]

Self-Hosting, Migrations, and Admin API

Bottom line: PocketBase is a single Go binary with zero external dependencies. Run it locally or in Docker. Schema changes are tracked in pb_migrations/ JavaScript files that execute on startup.

Download the binary for your platform from the GitHub releases page (github.com/pocketbase/pocketbase/releases) and run ./pocketbase serve. All data lives in ./pb_data/data.db (SQLite) and ./pb_data/storage/ (uploaded files) — back up both to recover from failures. The Admin API at /api/collections accepts JSON payloads for programmatic collection creation and updates. The pb_migrations/ folder holds JavaScript migration files — PocketBase runs any unapplied migrations in filename order on startup, making schema changes reproducible across environments. PocketBase supports up to 10,000 concurrent connections on a 1-vCPU server for read-heavy workloads.

# Download and run (macOS/Linux)
wget https://github.com/pocketbase/pocketbase/releases/download/v0.22.0/pocketbase_0.22.0_darwin_amd64.zip
unzip pocketbase_0.22.0_darwin_amd64.zip
./pocketbase serve
# Admin UI: http://127.0.0.1:8090/_/

# Docker (persistent data in ./pb_data)
docker run -d \
  -p 8090:8090 \
  -v ./pb_data:/pb/pb_data \
  ghcr.io/mucsi96/pocketbase

# Create a backup
./pocketbase admin backup create

# --- Programmatic collection creation via Admin API ---
# POST /api/collections  (requires admin auth token in Authorization header)
# {
#   "name": "products",
#   "type": "base",
#   "schema": [
#     { "name": "name",     "type": "text",   "required": true },
#     { "name": "price",    "type": "number", "required": true },
#     { "name": "metadata", "type": "json" }
#   ]
# }
// pb_migrations/1716800000_add_metadata_to_products.js
// PocketBase runs this file on startup if it hasn't been applied yet

migrate((db) => {
  const dao = new Dao(db)
  const collection = dao.findCollectionByNameOrId('products')

  // Add a json field to an existing collection
  collection.schema.addField(new SchemaField({
    name: 'metadata',
    type: 'json',
    required: false,
    options: {},
  }))

  return dao.saveCollection(collection)
}, (db) => {
  // Rollback: remove the field
  const dao = new Dao(db)
  const collection = dao.findCollectionByNameOrId('products')
  const field = collection.schema.getFieldByName('metadata')
  collection.schema.removeField(field.id)
  return dao.saveCollection(collection)
})

FAQ

How do I create a JSON field in PocketBase?

Open the Admin UI at http://127.0.0.1:8090/_/, select your collection, click "New field", and choose the "JSON" type. Alternatively, use the Admin API: POST /api/collections with a schema array entry { "name": "metadata", "type": "json" }. PocketBase stores json fields as a raw SQLite text column — the SDK auto-parses the value to a JavaScript object on read. For known, fixed structures, prefer individual typed fields (text, number, bool) — PocketBase validates them independently and filters them more efficiently.

How do I query a JSON field value in PocketBase?

Use PocketBase's filter DSL with dot notation: pb.collection("products").getList(1, 20, { filter: 'metadata.plan = "pro"' }). Range filters: 'metadata.score >= 80'. Null checks: 'metadata.archivedAt = null'. String contains: 'metadata.name ~ "widget"'. Combine with && and ||. Sort descending: sort: '-metadata.score'. PocketBase does not support array containment on json fields — use the select field type with multiple: true for list membership queries.

How do I add TypeScript types to PocketBase SDK responses?

Define an interface extending RecordModel and pass it as a generic: pb.collection("products").getOne<ProductRecord>(id). The RecordModel base includes id, collectionId, collectionName, created, and updated. For automatic type generation, run npx pocketbase-typegen --db ./pb_data/data.db --out src/pocketbase-types.ts — this produces a TypedPocketBase class with per-collection types. The SDK supports generics on getOne, getList, getFullList, create, and update (v0.21+).

How does PocketBase realtime work?

PocketBase realtime uses Server-Sent Events (SSE). Subscribe to all records: pb.collection('posts').subscribe('*', callback). Subscribe to one record: pb.collection('posts').subscribe(recordId, callback). The callback receives { action: "create" | "update" | "delete", record: RecordModel }. Unsubscribe: pb.collection('posts').unsubscribe(). The SDK automatically includes the pb.authStore token in SSE connections. Events arrive within 100–300 ms on a local network. PocketBase supports up to 500 concurrent SSE connections per server instance by default.

How do I validate PocketBase JSON field data with Zod?

Define a Zod schema for the json field structure and call schema.safeParse(record.metadata) after each fetch. Use z.infer<typeof Schema> to derive the TypeScript type and pass it to the SDK generic to align runtime validation with compile-time types. For lists, use safeParse and skip invalid records with flatMap rather than letting a single bad record crash the entire response. Wrap fetch and validate in a single function so validation is never skipped at call sites.

How do I self-host PocketBase?

Download the single binary from the GitHub releases page and run ./pocketbase serve. All data is stored in ./pb_data/data.db (SQLite) and ./pb_data/storage/. For Docker: docker run -d -p 8090:8090 -v ./pb_data:/pb/pb_data ghcr.io/mucsi96/pocketbase. Use the pb_migrations/ folder for schema evolution — JavaScript files in that folder run automatically on startup. Back up with ./pocketbase admin backup create. PocketBase handles up to 10,000 concurrent connections on a 1-vCPU server for read-heavy workloads.

Definitions

RecordModel
The base TypeScript interface returned by all PocketBase SDK collection methods. Includes id, collectionId, collectionName, created, and updated. Custom fields are typed as any unless you extend it with your own interface.
json field type
A PocketBase collection field that stores any JSON-serializable value. Internally stored as a SQLite text column containing the JSON string. The JavaScript SDK auto-parses the value to a JavaScript object on read.
Filter DSL
PocketBase's custom filter expression language used in getList(), getFullList(), and getFirstListItem(). Supports dot notation for JSON subfield access, operators (=, !=, >, ~), and logical connectors (&&, ||). Compiled to SQLite internally.
pb.authStore
The SDK's built-in auth token store. Populated after calling authWithPassword() or authWithOAuth2(). The SDK automatically includes the stored token in all subsequent HTTP and SSE requests.
pb_migrations
A folder in the PocketBase working directory containing JavaScript migration files. PocketBase runs unapplied migration files in filename order on every startup, enabling reproducible schema evolution across environments.
typegen
The pocketbase-typegen CLI tool (npm package) that reads a PocketBase SQLite database or a live instance and generates TypeScript interfaces for every collection, including a TypedPocketBase class that provides per-collection type safety without manual generic annotations.

Working with other databases?

The same JSON storage patterns — typed fields, filter DSL, Zod validation — appear across all backend-as-a-service platforms. See the equivalent guides for JSON in Firebase, JSON in Supabase, and JSON in Convex.

Open JSON Tools at jsonic.io

Further reading and primary sources

  • PocketBase JavaScript SDKOfficial PocketBase JavaScript/TypeScript SDK with API reference for collection methods, TypeScript generics, realtime subscriptions, and auth.
  • PocketBase collections APIOfficial PocketBase documentation for collection types, field types (including json), schema validation rules, and the Admin UI.
  • PocketBase filter syntaxOfficial PocketBase documentation for the filter DSL: operators, dot notation for JSON subfields, combining conditions, and sorting.
  • pocketbase-typegenCLI tool that generates TypeScript interfaces from a PocketBase database schema, including the TypedPocketBase client for collection-level type safety.
  • PocketBase migrationsOfficial PocketBase documentation for JavaScript migration files: adding fields, modifying collections, and running schema changes automatically on startup.